Skip to main content

ballistics_engine/
cli_api.rs

1// CLI API module - provides simplified interfaces for command-line tool
2use crate::atmosphere::{PressureReferenceMode, StationAtmospherePresence};
3use crate::cluster_bc::ClusterBCDegradation;
4use crate::gravity::Gravity;
5use crate::mc_stats::{wilson_interval, BernoulliConfidenceSequence, ConfidenceLevel, Welford};
6use crate::pitch_damping::{calculate_pitch_damping_coefficient, PitchDampingCoefficients};
7use crate::precession_nutation::{
8    calculate_combined_angular_motion, projectile_moments_of_inertia, AngularState,
9    PrecessionNutationParams,
10};
11use crate::trajectory_observation::{bracket_param, Bracket, TrajectoryTermination};
12use crate::trajectory_sampling::{
13    projected_sample_count, sample_trajectory, TrajectoryData, TrajectoryOutputs, TrajectorySample,
14};
15use crate::wind_shear::WindShearModel;
16use crate::DragModel;
17use nalgebra::{Vector3, Vector6};
18use std::error::Error;
19use std::fmt;
20
21/// Unit system for CLI-style inputs and outputs.
22///
23/// The single crate-wide unit-system selector, shared by the CLI binary
24/// (`--units`), the truing core ([`crate::truing`]) and the WEZ sweep core
25/// ([`crate::wez`]). It only selects how user-facing quantities are
26/// interpreted and displayed; the solver itself always works in SI.
27///
28/// Variant order (Metric first) is load-bearing for the CLI: clap lists
29/// `--units` possible values in declaration order.
30#[derive(Debug, Clone, Copy, PartialEq)]
31#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
32pub enum UnitSystem {
33    /// Metric units (velocity in m/s, mass in grams, distance in meters, diameter in mm, Celsius)
34    Metric,
35    /// Imperial units (velocity in fps, mass in grains, distance in yards, diameter in inches, Fahrenheit)
36    Imperial,
37}
38
39// Output format for results
40#[derive(Debug, Clone, Copy, PartialEq)]
41pub enum OutputFormat {
42    Table,
43    Json,
44    Csv,
45}
46
47/// Which standard atmosphere a ballistic coefficient's numeric value is referenced to
48/// (MBA-1365).
49///
50/// A BC is not a pure property of the bullet — it also encodes an assumed reference air
51/// density. This engine's retardation constant ([`crate::constants::CD_TO_RETARD`]) is
52/// calibrated against the ICAO Standard Atmosphere's sea-level density
53/// ([`crate::constants::ICAO_DENSITY_LB_FT3`]), which most modern published BCs already
54/// assume. Some vendors (notably Sierra, Hornady, and Barnes for many bullets) instead
55/// publish BCs referenced to the older, denser Army Standard Metro atmosphere
56/// ([`crate::constants::ASM_DENSITY_LB_FT3`]). Feeding an ASM-referenced BC into this
57/// engine as if it were ICAO-referenced under-predicts drag by about 1.8% — declaring
58/// `ArmyStandardMetro` here corrects for that exactly once, at input normalization (see
59/// [`crate::constants::ASM_TO_ICAO_BC`] and `TrajectorySolver::new`).
60///
61/// Does not apply when a custom drag table (`BallisticInputs::custom_drag_table`) is flown as
62/// the projectile's own curve: that path divides by sectional density, not a BC value, so no
63/// BC reference conversion is physically meaningful there (see
64/// `BallisticInputs::bc_reference_standard_inert_warning`). A table flown as a reference curve
65/// (`BallisticInputs::custom_drag_reference`) divides by the BC exactly as a built-in model
66/// does, so the conversion applies to it as it does to a G-model.
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
68#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
69pub enum BcReferenceStandard {
70    /// The ICAO Standard Atmosphere reference density — matches this engine's
71    /// retardation constant. The default; byte-identical to pre-MBA-1365 behavior.
72    #[default]
73    Icao,
74    /// The (older) Army Standard Metro reference density used by some vendor-published
75    /// BCs (e.g. many Sierra/Hornady/Barnes bullets).
76    ArmyStandardMetro,
77}
78
79/// Shared text for the MBA-1365 "`--bc-reference army-standard-metro` is inert" warning —
80/// single source of truth for every surface that can trigger it (native CLI, WASM,
81/// [`BallisticInputs::bc_reference_standard_inert_warning`]) so the wording can't drift.
82pub const BC_REFERENCE_STANDARD_INERT_WARNING: &str =
83    "warning: --bc-reference army-standard-metro has no effect together with a custom drag \
84     table (--drag-table): the deck's Cd is divided by sectional density, not a BC value, so \
85     no BC-reference conversion applies";
86
87// Error type for CLI operations
88#[derive(Debug)]
89pub struct BallisticsError {
90    message: String,
91}
92
93impl fmt::Display for BallisticsError {
94    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
95        write!(f, "{}", self.message)
96    }
97}
98
99impl Error for BallisticsError {}
100
101impl From<String> for BallisticsError {
102    fn from(msg: String) -> Self {
103        BallisticsError { message: msg }
104    }
105}
106
107impl From<&str> for BallisticsError {
108    fn from(msg: &str) -> Self {
109        BallisticsError {
110            message: msg.to_string(),
111        }
112    }
113}
114
115/// Which plane sampled drop values are referenced to (MBA-1403).
116///
117/// See [`BallisticInputs::drops_reference`]. This is an output-mode toggle for the
118/// trajectory sampler, not new plane machinery: the solver implements incline as gravity
119/// rotation into the shot frame, so sampled drop is already perpendicular to the LOS and
120/// the JBM-equivalent "target" mode is a `1 / cos(shooting_angle)` reference transform
121/// plus relabeling.
122#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
123pub enum DropsReference {
124    /// Drop measured perpendicular to the line of sight (the historical default).
125    #[default]
126    Los,
127    /// Drop measured vertically in the target plane (JBM's "target plane" reference):
128    /// the LOS-perpendicular drop scaled by `1 / cos(shooting_angle)`.
129    Target,
130}
131
132// Ballistic input parameters - MBA-151 Reconciled Structure
133// Unified structure used by both ballistics-engine and ballistics_rust
134// Duplicates removed, all necessary fields included
135#[derive(Debug, Clone)]
136pub struct BallisticInputs {
137    // Core ballistics parameters (using intuitive names)
138    pub bc_value: f64,      // Ballistic coefficient (G1, G7, etc.)
139    pub bc_type: DragModel, // Drag model (G1, G7, G8, etc.)
140    /// Which standard atmosphere `bc_value`/`bc_segments`/`bc_segments_data` are
141    /// referenced to (MBA-1365). `Icao` (the default) is a no-op; `ArmyStandardMetro`
142    /// is converted to the ICAO reference exactly once, in `TrajectorySolver::new`,
143    /// before any retardation math runs. See [`BcReferenceStandard`].
144    pub bc_reference_standard: BcReferenceStandard,
145    pub bullet_mass: f64,     // kg
146    pub muzzle_velocity: f64, // m/s
147    pub bullet_diameter: f64, // meters
148    pub bullet_length: f64,   // meters
149
150    // Targeting and positioning
151    pub muzzle_angle: f64,    // radians (launch angle)
152    pub target_distance: f64, // meters
153    pub azimuth_angle: f64, // horizontal aiming angle in radians (small aim offset within the shot frame)
154    /// Compass bearing the shot is fired ALONG, radians, 0 = North, π/2 = East.
155    /// Used only by the Coriolis model (Earth-rotation depends on which way downrange
156    /// points relative to true North). Distinct from `azimuth_angle`, which is the
157    /// small horizontal *aiming* offset and rotates the launch velocity.
158    pub shot_azimuth: f64,
159    pub shooting_angle: f64, // uphill/downhill angle in radians
160    /// Rifle cant angle in radians about the line of sight — positive = clockwise from the
161    /// shooter's view (top of the scope tips right). Rotates the sight-frame aim offsets
162    /// (`muzzle_angle`, `azimuth_angle`) about the LOS and swings the bore's sight-height
163    /// offset laterally, producing the classic canted-rifle POI error (right-and-low for
164    /// clockwise cant with an upward zero). Zeroing always solves un-canted ("zero level,
165    /// fire canted"). NOTE: treats `muzzle_angle` as a sight-frame offset — the standard
166    /// zero-then-fire usage; a raw gravity-frame launch angle would not rotate physically.
167    /// 0.0 = level rifle (bit-identical to pre-cant behavior). (MBA-1286)
168    pub cant_angle: f64,
169    pub sight_height: f64, // meters above bore
170    /// Lateral offset between the sight axis and the bore axis, meters (MBA-1396;
171    /// offset-mounted optics): positive = the sight sits RIGHT of the bore, so the bore
172    /// starts LEFT of the line of sight (initial lateral position `z -= offset`). When a
173    /// zero is solved, a windage-zero convergence term of `offset / zero_distance` is
174    /// added to `azimuth_angle` (see [`BallisticInputs::windage_zero_bias_rad`]) so the
175    /// trajectory crosses the LOS laterally at the zero range — matching AB/JBM/Shooter,
176    /// not a constant parallel offset. Without a zero solve (an explicit muzzle angle),
177    /// only the physical displacement applies. Distinct from `zero_poi_horizontal_m`,
178    /// which is an angular ZERO-STATE bias, not mount geometry. 0.0 (the default) is
179    /// byte-identical to pre-MBA-1396 behavior.
180    pub sight_offset_lateral_m: f64,
181    pub muzzle_height: f64, // meters above ground
182    pub target_height: f64, // meters above ground for zeroing
183    /// Deliberate vertical point-of-impact offset AT THE ZERO RANGE, meters (MBA-1359;
184    /// Kestrel "zero height" semantics): positive = the rifle is deliberately zeroed to
185    /// impact HIGH by this much at the zero distance. Applied POST-solve by
186    /// `calculate_and_set_zero_angle` as a constant angular bias of
187    /// `zero_poi_vertical_m / zero_distance` on the solved elevation — the zero trials
188    /// themselves always solve perfect convergence, and the small-angle addition is
189    /// identical in both zero target frames. 0.0 (the default) is byte-identical to
190    /// pre-MBA-1359 behavior.
191    pub zero_poi_vertical_m: f64,
192    /// Deliberate horizontal point-of-impact offset AT THE ZERO RANGE, meters (MBA-1359;
193    /// Kestrel "zero offset" semantics): positive = impacts RIGHT by this much at the
194    /// zero distance. Applied POST-solve as an azimuth bias of
195    /// `zero_poi_horizontal_m / zero_distance` when a zero is solved — see
196    /// [`BallisticInputs::windage_zero_bias_rad`]. 0.0 (the default) is neutral.
197    pub zero_poi_horizontal_m: f64,
198    pub ground_threshold: f64, // meters below which to stop
199
200    // Environmental conditions
201    pub altitude: f64,    // meters
202    pub temperature: f64, // Celsius
203    pub pressure: f64,    // millibars/hPa
204    /// Relative humidity as a FRACTION in `[0, 1]` (e.g. 0.5 = 50%). NOTE the scale
205    /// differs from [`AtmosphericConditions::humidity`], which is a PERCENT in `[0, 100]`.
206    /// The atmosphere helpers (`calculate_air_density_*`) expect percent, so convert via
207    /// [`BallisticInputs::humidity_percent`] before passing this value to them (MBA-722).
208    pub humidity: f64,
209    pub latitude: Option<f64>, // degrees
210
211    // Wind conditions
212    pub wind_speed: f64, // m/s
213    pub wind_angle: f64, // radians (0=headwind, PI/2=from right)
214
215    // Bullet characteristics
216    pub twist_rate: f64,               // inches per turn
217    pub is_twist_right: bool,          // right-hand twist
218    pub caliber_inches: f64,           // diameter in inches
219    pub weight_grains: f64,            // mass in grains
220    pub manufacturer: Option<String>,  // Bullet manufacturer
221    pub bullet_model: Option<String>,  // Bullet model name
222    pub bullet_id: Option<String>,     // Unique bullet identifier
223    pub bullet_cluster: Option<usize>, // BC cluster ID for cluster_bc module
224
225    // Integration method selection
226    pub use_rk4: bool,           // Use RK4 integration instead of Euler
227    pub use_adaptive_rk45: bool, // Use RK45 adaptive step size integration
228
229    // Advanced effects flags
230    pub enable_advanced_effects: bool,
231    pub enable_magnus: bool,   // Magnus force (independent of Coriolis)
232    pub enable_coriolis: bool, // Coriolis deflection (requires latitude)
233    pub use_powder_sensitivity: bool,
234    pub powder_temp_sensitivity: f64, // m/s per degree Celsius
235    pub powder_temp: f64,             // Celsius
236    /// Optional measured powder-temperature -> muzzle-velocity curve, as
237    /// (temperature_celsius, muzzle_velocity_m_s) points sorted ascending by
238    /// temperature. When present it supersedes the linear `powder_temp_sensitivity`
239    /// model: the muzzle velocity is interpolated from this table at the ambient
240    /// `temperature` (clamped to the endpoints — no extrapolation beyond measured
241    /// data). This is the data-driven, non-linear alternative to the constant slope.
242    pub powder_temp_curve: Option<Vec<(f64, f64)>>,
243    /// Temperature (Celsius) at which to interpolate `powder_temp_curve` — the POWDER
244    /// temperature, which may differ from the ambient `temperature` (air). `None` uses
245    /// `temperature`. Decouples the velocity lookup from the air-density temperature.
246    pub powder_curve_temp_c: Option<f64>,
247    pub tipoff_yaw: f64,            // radians
248    pub tipoff_decay_distance: f64, // meters
249    /// Quadratic yaw-drag coefficient CD_delta2 (per rad^2, actual-Cd space) applied
250    /// additively to the tip-off yaw: CD = CD0 + CD_delta2 * delta^2 (McCoy, Modern
251    /// Exterior Ballistics). Literature values for spitzer rifle bullets run ~4-20
252    /// per rad^2 (7.62 M80 ~= 9.6); the default 7.5 is a documented mid-range
253    /// constant. Only active when `tipoff_yaw` is nonzero (MBA-1227).
254    pub cd_delta2: f64,
255    /// Enables velocity-keyed `bc_segments_data`. Explicit Mach-keyed `bc_segments` retain their
256    /// legacy behavior and remain active when this flag is false.
257    ///
258    /// An empty `bc_segments_data` is the same as `None`. With the flag on and no velocity
259    /// ladder, every solver flies a non-empty Mach `bc_segments` table. Without one,
260    /// `TrajectorySolver` and `fast_trajectory::fast_integrate` fly the scalar BC, while the RK45
261    /// kernel behind `fast_integrate_with_segments` /
262    /// `trajectory_integration::integrate_trajectory` / `derivatives::compute_derivatives`
263    /// auto-estimates a ladder when `bc_segments` is `None` (MBA-955, legacy opt-in).
264    pub use_bc_segments: bool,
265    pub bc_segments: Option<Vec<(f64, f64)>>, // Mach-BC pairs
266    pub bc_segments_data: Option<Vec<crate::BCSegmentData>>, // Velocity-BC segments
267    pub use_enhanced_spin_drift: bool,
268    /// Legacy compatibility flag. Name-derived "form factors" are intentionally not multiplied
269    /// into reference Cd when `bc_value` is already the retardation denominator (MBA-1184).
270    pub use_form_factor: bool,
271    pub enable_wind_shear: bool,
272    pub wind_shear_model: String,
273    pub enable_trajectory_sampling: bool,
274    pub sample_interval: f64, // meters
275    /// Which plane sampled drop values are referenced to (MBA-1403). The default
276    /// [`DropsReference::Los`] keeps the historical behavior (drop perpendicular to the
277    /// line of sight) byte-identical; [`DropsReference::Target`] reports drop as vertical
278    /// in the target plane — the LOS-perpendicular drop scaled by
279    /// `1 / cos(shooting_angle)` (JBM's "target plane" checkbox) — and, when a non-zero
280    /// `target_height` is supplied, slopes the sampler's LOS datum toward it. This is an
281    /// OUTPUT-mode toggle: the solved trajectory itself is unchanged, and zero-solve
282    /// `target_height` semantics are untouched.
283    pub drops_reference: DropsReference,
284    pub enable_pitch_damping: bool,
285    pub enable_precession_nutation: bool,
286    // MBA-959: apply aerodynamic jump as a muzzle launch-angle perturbation.
287    // EXPERIMENTAL — the underlying model is heuristic and not yet validated; default OFF.
288    pub enable_aerodynamic_jump: bool,
289    pub use_cluster_bc: bool, // Use cluster-based BC degradation
290
291    // Custom drag model support
292    /// A caller-supplied Cd-vs-Mach curve that replaces the `bc_type` G-model curve at every
293    /// drag site (MBA-940). It is used as-is: no transonic correction and no form factor is
294    /// applied to it, and outside its Mach range the nearest end value is held. What the curve
295    /// describes, and so what it is divided by, is decided by [`Self::custom_drag_reference`]:
296    ///
297    /// * `false` (the default): the projectile's OWN drag coefficient, divided by its
298    ///   sectional density. `bc_value` and any BC segments are unused for drag. This is the
299    ///   only reading the CLI's `--drag-table`, the WASM terminal, the array FFI and saved
300    ///   profiles have, and solve-json v1's `kind: "projectile"`.
301    /// * `true`: a REFERENCE curve, such as an airgun pellet law, that the BCs were measured
302    ///   against, divided by the resolved BC exactly as a built-in model's curve is.
303    ///
304    /// See [`Self::custom_drag_denominator`].
305    pub custom_drag_table: Option<crate::drag::DragTable>,
306    /// Whole-curve multiplier applied to the custom deck's interpolated Cd (MBA-1356):
307    /// `Cd_used = table.interpolate(mach) * cd_scale`. Meaningful only alongside
308    /// `custom_drag_table` — it is read at the three custom-deck interpolation sites in
309    /// cli_api.rs/derivatives.rs/fast_trajectory.rs and left untouched on the standard
310    /// G-model/BC path (those users true their drag via `--bc-adjustment` instead; scaling
311    /// a G-model's reference table too would double-count). It multiplies the curve before
312    /// the division, under either reading of [`Self::custom_drag_reference`]. solve-json v1's
313    /// `kind: "reference"` table without a BC ladder is installed as the own-curve path with
314    /// `cd_scale = SD / BC`, which flies `Cd_ref / BC`; with a ladder it is installed with
315    /// `custom_drag_reference` set and `cd_scale = 1.0` instead, since one whole-curve scale
316    /// cannot carry a BC that changes from band to band. Default `1.0` is neutral
317    /// (byte-identical to the pre-MBA-1356 behavior). Validated finite and > 0 by
318    /// `validate_for_solve`.
319    pub cd_scale: f64,
320    /// Whether `custom_drag_table` is a REFERENCE curve that the BCs were measured against
321    /// (`true`), rather than the projectile's own Cd (`false`, the default). Inert without a
322    /// table.
323    ///
324    /// When set, the drag sites in cli_api.rs, derivatives.rs and fast_trajectory.rs divide the
325    /// table's Cd (times `cd_scale`) by the BC each of them has already resolved for the current
326    /// speed, exactly as they divide a built-in model's Cd: a velocity ladder's band BC
327    /// (`bc_segments_data` under `use_bc_segments`, selected and smoothstep-blended by
328    /// `velocity_segment_bc`), a Mach-keyed `bc_segments` BC, or the scalar `bc_value` in an
329    /// interior gap or without a ladder. The BC matters again on this path, so
330    /// `validate_for_solve` requires a finite positive `bc_value`, and an `ArmyStandardMetro`
331    /// `bc_reference_standard` is converted exactly as it is for a built-in model.
332    ///
333    /// solve-json v1 sets it for a `kind: "reference"` `projectile.drag_table` sent with a
334    /// `projectile.bc_segments` ladder; no other front end sets it. Every path that copies a
335    /// table between input structs must carry it alongside `cd_scale`
336    /// (`TrajectoryParams::custom_drag_reference`): dropped beside `cd_scale = 1.0`, it would fly
337    /// the reference curve as the projectile's own Cd over sectional density, wrong by the
338    /// projectile's whole form factor (the MBA-1356 dropped-field class).
339    pub custom_drag_reference: bool,
340
341    // Legacy field for compatibility
342    pub bc_type_str: Option<String>,
343}
344
345impl BallisticInputs {
346    /// `humidity` as a PERCENT in `[0, 100]`, clamped — the scale the atmosphere
347    /// density helpers expect. Centralizes the 0–1 → 0–100 conversion so callers don't
348    /// re-derive it (and can't accidentally feed the raw 0–1 fraction as a percentage).
349    /// See the field doc on [`BallisticInputs::humidity`] (MBA-722).
350    pub fn humidity_percent(&self) -> f64 {
351        (self.humidity * 100.0).clamp(0.0, 100.0)
352    }
353
354    /// Azimuth bias (radians) a zero solved at `zero_distance_m` applies to the launch
355    /// direction. Two independent terms share this one convergence point:
356    /// * MBA-1359 — the deliberate horizontal POI offset at the zero range
357    ///   (`zero_poi_horizontal_m`), an angular zero-state bias;
358    /// * MBA-1396 — the lateral sight-mount offset (`sight_offset_lateral_m`): the bore
359    ///   starts `offset` left of the LOS (see `initial_position`), so the windage zero
360    ///   steers `offset / zero_distance` right to cross the LOS at the zero range.
361    ///
362    /// `calculate_and_set_zero_angle` applies this to its OWN solver's `azimuth_angle`;
363    /// callers that copy a solved zero angle onto separate flight inputs (the CLI/WASM
364    /// auto-zero paths) must add this to their flight `azimuth_angle` themselves — the
365    /// returned elevation angle cannot carry it. Returns exactly `0.0` when both offsets
366    /// are `0.0` or `zero_distance_m` is not positive, so default inputs stay
367    /// byte-identical.
368    pub fn windage_zero_bias_rad(&self, zero_distance_m: f64) -> f64 {
369        if zero_distance_m > 0.0 {
370            (self.zero_poi_horizontal_m + self.sight_offset_lateral_m) / zero_distance_m
371        } else {
372            0.0
373        }
374    }
375
376    /// Sectional density in lb/in²: `weight_grains / 7000 / diameter_in²`.
377    ///
378    /// Derived from the imperial mirror fields (`weight_grains` / `caliber_inches`), falling
379    /// back to the SI `bullet_mass` (kg) / `bullet_diameter` (meters) for SI-only callers
380    /// (mirrors the fallbacks in derivatives.rs). `None` when neither source is usable.
381    pub fn sectional_density_lb_in2(&self) -> Option<f64> {
382        let weight_gr = if self.weight_grains > 0.0 {
383            self.weight_grains
384        } else {
385            self.bullet_mass / crate::constants::GRAINS_TO_KG // kg -> grains
386        };
387        let diameter_in = if self.caliber_inches > 0.0 {
388            self.caliber_inches
389        } else {
390            self.bullet_diameter / 0.0254 // meters -> inches
391        };
392        if weight_gr > 0.0 && diameter_in > 0.0 {
393            Some(weight_gr / 7000.0 / (diameter_in * diameter_in))
394        } else {
395            None
396        }
397    }
398
399    /// Retardation denominator to use when `custom_drag_table` is active.
400    ///
401    /// `resolved_bc` is the BC the calling drag site has already resolved for the current
402    /// speed, by the same precedence it applies to a built-in model: a velocity ladder's band
403    /// BC, else a Mach-segment BC, else the scalar `bc_value`.
404    ///
405    /// * **Reference curve** ([`Self::custom_drag_reference`]): returns `resolved_bc`, the very
406    ///   denominator a built-in model divides by, so the table flies `Cd_ref / BC` and a BC
407    ///   ladder's band BCs reach it.
408    /// * **The projectile's own curve** (the default): the table supplies the projectile's
409    ///   ACTUAL drag coefficient, so the point-mass retardation formula must divide it by the
410    ///   projectile's SECTIONAL DENSITY (lb/in²), not by a ballistic coefficient: BC = SD / i
411    ///   (form factor i vs the reference projectile), and with the projectile's own curve
412    ///   i == 1, so Cd_own / SD == Cd_ref / BC. Dividing the curve's Cd by `bc_value` made
413    ///   custom-table trajectories wrongly scale with whatever BC happened to be set. Falls back
414    ///   to `resolved_bc` (with a one-time stderr warning) when mass/diameter are unavailable,
415    ///   so degenerate inputs degrade to the old behavior instead of panicking.
416    pub fn custom_drag_denominator(&self, resolved_bc: f64) -> f64 {
417        if self.custom_drag_reference {
418            return resolved_bc;
419        }
420        match self.sectional_density_lb_in2() {
421            Some(sd) => sd,
422            None => {
423                static WARN_ONCE: std::sync::Once = std::sync::Once::new();
424                WARN_ONCE.call_once(|| {
425                    eprintln!(
426                        "Warning: custom drag table active but bullet mass/diameter are \
427                         unavailable; falling back to bc_value for the retardation denominator"
428                    );
429                });
430                resolved_bc
431            }
432        }
433    }
434
435    /// Whether a declared `bc_reference_standard` is inert on this configuration
436    /// (MBA-1365), and if so, the message explaining why.
437    ///
438    /// A custom drag table (`custom_drag_table`) flown as the projectile's own curve supplies
439    /// its actual Cd and divides by sectional density, not by a BC value (see
440    /// `custom_drag_denominator`), so an `ArmyStandardMetro` reference has no physical effect
441    /// once such a table is active. A table flown as a reference curve
442    /// (`custom_drag_reference`) divides by the BC like a built-in model, so the conversion
443    /// is meaningful there and this returns `None`. Pure and side-effect-free — same shape as
444    /// `main.rs`'s `adjustment_unit_noop_warning` (MBA-1414) — so each surface decides how to
445    /// surface it: the native CLI prints it to stderr once per run, while the WASM browser
446    /// terminal (no visible stderr) splices it into its table-output text instead.
447    pub fn bc_reference_standard_inert_warning(&self) -> Option<&'static str> {
448        if self.custom_drag_table.is_some()
449            && !self.custom_drag_reference
450            && matches!(
451                self.bc_reference_standard,
452                BcReferenceStandard::ArmyStandardMetro
453            )
454        {
455            Some(BC_REFERENCE_STANDARD_INERT_WARNING)
456        } else {
457            None
458        }
459    }
460
461    /// Apply every input-conditioning step integration requires, exactly once (MBA-1415).
462    ///
463    /// **Every public entry point into integration must call this**, not just
464    /// [`TrajectorySolver::new`]. `fast_trajectory::fast_integrate` and
465    /// `fast_integrate_with_segments` are `pub` and are called directly by the Python
466    /// binding, so conditioning that lives only in the solver constructor is invisible to
467    /// those consumers: the caller sets a field, gets no error, and the value is silently
468    /// ignored. That exact shape has already cost this project twice — MBA-1296 (a dropped
469    /// field that zeroed Coriolis in production) and the MBA-1356 review catch
470    /// (`cd_scale` dropped by the segmented fast path). Adding a step here rather than at a
471    /// call site is what keeps the next one from repeating it.
472    ///
473    /// **Idempotent**, and it must stay that way: Monte Carlo builds a solver per sample, and
474    /// the fast entry points may receive inputs that already passed through a solver. Each
475    /// step below is either an absolute override (derived fields, powder-resolved velocity) or
476    /// self-disarming (the BC conversion rewrites `bc_reference_standard` to the reference it
477    /// just converted into, so a second call finds nothing to do). A step that scales a value
478    /// in place without disarming itself would double-apply silently — do not add one.
479    pub fn normalize_for_solve(&mut self) {
480        // MBA-1365: normalize a declared Army-Standard-Metro BC reference to the ICAO
481        // reference this engine's retardation constant (CD_TO_RETARD) is calibrated against,
482        // before any retardation math runs. Every BC representation on these inputs (the
483        // scalar, explicit Mach segments, and velocity segments) is a real BC and gets the
484        // same treatment. `Icao` (the default) takes this branch never, so callers that never
485        // set the field are byte-identical to pre-MBA-1365 behavior. A custom drag table flown
486        // as the projectile's own curve divides by sectional density rather than a BC, so the
487        // conversion is physically inert there — see `bc_reference_standard_inert_warning`,
488        // which callers surface at their own display sites (native stderr / WASM table text)
489        // from their OWN copy of the inputs, before this runs. A table flown as a reference
490        // curve (`custom_drag_reference`) divides by these same BCs, so it needs the conversion
491        // exactly as a built-in model does.
492        if matches!(
493            self.bc_reference_standard,
494            BcReferenceStandard::ArmyStandardMetro
495        ) {
496            self.bc_value *= crate::constants::ASM_TO_ICAO_BC;
497            if let Some(segments) = self.bc_segments.as_mut() {
498                for (_mach, bc) in segments.iter_mut() {
499                    *bc *= crate::constants::ASM_TO_ICAO_BC;
500                }
501            }
502            if let Some(segments) = self.bc_segments_data.as_mut() {
503                for segment in segments.iter_mut() {
504                    segment.bc_value *= crate::constants::ASM_TO_ICAO_BC;
505                }
506            }
507            // Disarm: these BC values are now ICAO-referenced, which is simply true, and it
508            // makes a second call a no-op instead of a silent second scaling.
509            self.bc_reference_standard = BcReferenceStandard::Icao;
510        }
511
512        // Derived imperial fields, recomputed from the canonical SI values. These are an
513        // absolute override, NOT a fallback: a caller that sets only `caliber_inches` /
514        // `weight_grains` and leaves the SI fields at their defaults has those imperial values
515        // overwritten here. SI is authoritative on these inputs.
516        self.caliber_inches = self.bullet_diameter / 0.0254;
517        self.weight_grains = self.bullet_mass / crate::constants::GRAINS_TO_KG;
518
519        // Resolve the muzzle velocity for the ambient temperature before integration. A
520        // measured powder-temperature -> velocity curve (data-driven, non-linear) takes
521        // precedence when supplied; otherwise fall back to the linear powder-temperature-
522        // sensitivity model (MBA-963). Both operate in canonical SI (Celsius, m/s), so every
523        // solver built from these inputs — the main trajectory AND the zero-angle search —
524        // sees the same temperature-resolved velocity. In particular, when a zero solve passes
525        // the zero-day temperature, the curve automatically yields the zero-day velocity.
526        // (The curve interpolates at the POWDER temperature — powder_curve_temp_c, falling
527        // back to ambient. Air temperature still drives density separately; this only sets the
528        // velocity. Absolute override, so applying it twice is a no-op.)
529        self.muzzle_velocity = resolve_powder_adjusted_velocity(
530            self.muzzle_velocity,
531            self.temperature,
532            self.use_powder_sensitivity,
533            self.powder_temp_sensitivity,
534            self.powder_temp,
535            self.powder_temp_curve.as_deref(),
536            self.powder_curve_temp_c,
537        );
538    }
539}
540
541impl Default for BallisticInputs {
542    fn default() -> Self {
543        let mass_kg = 0.01;
544        let diameter_m = 0.00762;
545        let bc = 0.5;
546        let muzzle_angle_rad = 0.0;
547        let bc_type = DragModel::G1;
548
549        Self {
550            // Core ballistics parameters
551            bc_value: bc,
552            bc_type,
553            bc_reference_standard: BcReferenceStandard::Icao,
554            bullet_mass: mass_kg,
555            muzzle_velocity: 800.0,
556            bullet_diameter: diameter_m,
557            // MBA-1135: mass-based length estimate so the default is self-consistent with the
558            // default mass/diameter (was a mass-blind 4.5-caliber literal). The twist default below
559            // stays a fixed 1:12" per the ticket (a constant is a sensible velocity-agnostic default).
560            bullet_length: crate::stability::estimate_bullet_length_m(diameter_m, mass_kg),
561
562            // Targeting and positioning
563            muzzle_angle: muzzle_angle_rad,
564            target_distance: 100.0,
565            azimuth_angle: 0.0,
566            shot_azimuth: 0.0,
567            shooting_angle: 0.0,
568            cant_angle: 0.0,
569            sight_height: 0.05,
570            sight_offset_lateral_m: 0.0, // Sight directly above the bore (MBA-1396)
571            muzzle_height: 0.0,          // Default 0 - height is in sight_height
572            target_height: 0.0,          // Target at ground level by default
573            zero_poi_vertical_m: 0.0,    // No deliberate POI offset at the zero range (MBA-1359)
574            zero_poi_horizontal_m: 0.0,
575            ground_threshold: -100.0, // Effectively disable ground detection (allow bullet to drop 100m below start)
576
577            // Environmental conditions
578            altitude: 0.0,
579            temperature: 15.0,
580            pressure: 1013.25, // Standard sea level pressure (millibars)
581            humidity: 0.5,     // 50% relative humidity
582            latitude: None,
583
584            // Wind conditions
585            wind_speed: 0.0,
586            wind_angle: 0.0,
587
588            // Bullet characteristics
589            twist_rate: 12.0, // 1:12" typical
590            is_twist_right: true,
591            caliber_inches: diameter_m / 0.0254, // Convert to inches
592            weight_grains: mass_kg / crate::constants::GRAINS_TO_KG, // Convert to grains
593            manufacturer: None,
594            bullet_model: None,
595            bullet_id: None,
596            bullet_cluster: None,
597
598            // Integration method selection
599            use_rk4: true,           // Use Runge-Kutta methods by default
600            use_adaptive_rk45: true, // Default to RK45 adaptive for best accuracy
601
602            // Advanced effects (disabled by default)
603            enable_advanced_effects: false,
604            enable_magnus: false,
605            enable_coriolis: false,
606            use_powder_sensitivity: false,
607            powder_temp_sensitivity: 0.0,
608            powder_temp: 15.0,
609            powder_temp_curve: None,
610            powder_curve_temp_c: None,
611            tipoff_yaw: 0.0,
612            tipoff_decay_distance: 50.0,
613            cd_delta2: 7.5,
614            use_bc_segments: false,
615            bc_segments: None,
616            bc_segments_data: None,
617            use_enhanced_spin_drift: false,
618            use_form_factor: false,
619            enable_wind_shear: false,
620            wind_shear_model: "none".to_string(),
621            enable_trajectory_sampling: false,
622            sample_interval: 10.0,                // Default 10 meter intervals
623            drops_reference: DropsReference::Los, // historical LOS-perpendicular drops
624            enable_pitch_damping: false,
625            enable_precession_nutation: false,
626            enable_aerodynamic_jump: false,
627            use_cluster_bc: false, // Disabled by default for backward compatibility
628
629            // Custom drag model support
630            custom_drag_table: None,
631            cd_scale: 1.0,
632            custom_drag_reference: false,
633
634            // Legacy field for compatibility
635            bc_type_str: None,
636        }
637    }
638}
639
640/// Interpolate a muzzle velocity (m/s) from a measured powder-temperature curve at
641/// `temp_c` (Celsius). `curve` is `(temperature_celsius, velocity_m_s)` points; it is
642/// sorted ascending by temperature before use. Values below the first point or above
643/// the last are CLAMPED to the endpoint velocity (no extrapolation beyond measured
644/// data), and segments are linearly interpolated. A single point yields a constant.
645pub fn interpolate_powder_temp_curve(curve: &[(f64, f64)], temp_c: f64) -> f64 {
646    debug_assert!(!curve.is_empty());
647    if curve.is_empty() {
648        return 0.0;
649    }
650    // Defensive: accept unsorted input by sorting a local copy only when needed.
651    // Callers (CLI/WASM parsers) already sort, so the common path is a no-op scan.
652    let mut sorted;
653    let pts: &[(f64, f64)] = if curve.windows(2).all(|w| w[0].0 <= w[1].0) {
654        curve
655    } else {
656        sorted = curve.to_vec();
657        sorted.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
658        &sorted
659    };
660    let n = pts.len();
661    if temp_c <= pts[0].0 {
662        return pts[0].1; // clamp below the coldest measured point
663    }
664    if temp_c >= pts[n - 1].0 {
665        return pts[n - 1].1; // clamp above the hottest measured point
666    }
667    for i in 1..n {
668        let (t0, v0) = pts[i - 1];
669        let (t1, v1) = pts[i];
670        if temp_c <= t1 {
671            let span = t1 - t0;
672            if span.abs() < f64::EPSILON {
673                return v1; // coincident temps: avoid divide-by-zero, take the upper
674            }
675            let f = (temp_c - t0) / span;
676            return v0 + f * (v1 - v0);
677        }
678    }
679    pts[n - 1].1
680}
681
682/// Parse a `--sweep START:END:STEP` temperature range (display units) for the
683/// `powder` command — ONE parser shared by the native CLI and WASM so their
684/// validation cannot drift. Returns the
685/// inclusive row temperatures. Guarded: STEP must be positive, END >= START, and the
686/// row count is capped so a typo can't emit an unbounded table.
687pub fn parse_powder_sweep(s: &str) -> Result<Vec<f64>, String> {
688    const MAX_SWEEP_ROWS: usize = 500;
689    let parts: Vec<&str> = s.split(':').collect();
690    if parts.len() != 3 {
691        return Err(format!(
692            "Invalid --sweep '{}': expected START:END:STEP (e.g. \"20:110:10\")",
693            s
694        ));
695    }
696    let parse = |p: &str, name: &str| -> Result<f64, String> {
697        p.trim()
698            .parse::<f64>()
699            .map_err(|_| format!("Invalid --sweep {}: '{}' is not a number", name, p.trim()))
700    };
701    let start = parse(parts[0], "START")?;
702    let end = parse(parts[1], "END")?;
703    let step = parse(parts[2], "STEP")?;
704    if !step.is_finite() || step <= 0.0 {
705        return Err(format!("Invalid --sweep STEP {}: must be positive", step));
706    }
707    if !start.is_finite() || !end.is_finite() || end < start {
708        return Err(format!(
709            "Invalid --sweep range {}:{}: END must be >= START",
710            start, end
711        ));
712    }
713    // Row count computed and bounds-checked in f64 BEFORE the usize cast: a huge
714    // range would saturate the cast and overflow the `+ 1` (panic under
715    // overflow-checks, wrap-to-zero in release — silently bypassing this cap).
716    // The epsilon keeps a fractional STEP whose quotient lands a hair below an
717    // integer (e.g. 0:0.3:0.1 -> 2.9999999999999996) from dropping the END row.
718    let n_f = ((end - start) / step + 1e-9).floor();
719    if !n_f.is_finite() || n_f + 1.0 > MAX_SWEEP_ROWS as f64 {
720        return Err(format!(
721            "--sweep would produce more than {} rows; use a larger STEP",
722            MAX_SWEEP_ROWS
723        ));
724    }
725    let n = n_f as usize + 1;
726    // Index-multiplied (not accumulated) so float drift can't drop the END row.
727    Ok((0..n).map(|i| start + step * i as f64).collect())
728}
729
730/// Resolve the powder-temperature-adjusted muzzle velocity (m/s) — the velocity the
731/// solver actually flies. A non-empty measured `powder_temp_curve` takes precedence
732/// (interpolated at `powder_curve_temp_c`, falling back to the ambient
733/// `ambient_temperature_c`; clamped at the endpoints) and REPLACES the nominal
734/// velocity; otherwise, when `use_powder_sensitivity` is set, the linear model adds
735/// `sensitivity x (ambient - reference)` to it. All inputs canonical SI (Celsius,
736/// m/s). This is the single shared implementation behind the trajectory solve, the
737/// `powder` CLI/WASM command (MBA-737), and anything else that must agree with them.
738pub fn resolve_powder_adjusted_velocity(
739    nominal_velocity_mps: f64,
740    ambient_temperature_c: f64,
741    use_powder_sensitivity: bool,
742    powder_temp_sensitivity_mps_per_c: f64,
743    powder_reference_temp_c: f64,
744    powder_temp_curve: Option<&[(f64, f64)]>,
745    powder_curve_temp_c: Option<f64>,
746) -> f64 {
747    if let Some(curve) = powder_temp_curve {
748        if !curve.is_empty() {
749            let lookup_c = powder_curve_temp_c.unwrap_or(ambient_temperature_c);
750            return interpolate_powder_temp_curve(curve, lookup_c);
751        }
752        // A supplied-but-empty curve has always suppressed the linear fallback
753        // (the historical `else if`); preserve that exactly.
754        return nominal_velocity_mps;
755    }
756    if use_powder_sensitivity {
757        let temp_delta_c = ambient_temperature_c - powder_reference_temp_c;
758        return nominal_velocity_mps + powder_temp_sensitivity_mps_per_c * temp_delta_c;
759    }
760    nominal_velocity_mps
761}
762
763// Wind conditions
764#[derive(Debug, Clone)]
765pub struct WindConditions {
766    pub speed: f64, // m/s
767    // radians, wind-FROM convention: 0 = headwind, PI/2 = from the right,
768    // PI = tailwind, 3*PI/2 = from the left (matches WindSock / the bindings).
769    pub direction: f64,
770    /// Vertical wind component, m/s. POSITIVE = UPDRAFT (raises POI downrange); negative =
771    /// downdraft. Default 0.0. Enters the wind vector via [`crate::wind::wind_vector`]'s third
772    /// argument (MBA-728). Boundary-layer shear scales horizontal wind only — vertical passes
773    /// through unscaled wherever shear is applied on top of this. This scalar field (like
774    /// [`WindConditions::speed`]/[`WindConditions::direction`]) is ignored once downrange wind
775    /// segments are set on the solver — each [`crate::wind::WindSegment`] carries its own
776    /// `vertical_mps` instead.
777    pub vertical_speed: f64,
778}
779
780impl Default for WindConditions {
781    fn default() -> Self {
782        Self {
783            speed: 0.0,
784            direction: 0.0,
785            vertical_speed: 0.0,
786        }
787    }
788}
789
790// Atmospheric conditions
791#[derive(Debug, Clone)]
792pub struct AtmosphericConditions {
793    pub temperature: f64, // Celsius
794    pub pressure: f64,    // hPa
795    /// Relative humidity as a PERCENT in `[0, 100]`. NOTE: [`BallisticInputs::humidity`]
796    /// uses a 0–1 FRACTION instead — convert with `BallisticInputs::humidity_percent` when
797    /// crossing between them (MBA-722).
798    pub humidity: f64,
799    pub altitude: f64, // meters
800}
801
802impl Default for AtmosphericConditions {
803    fn default() -> Self {
804        Self {
805            temperature: 15.0,
806            pressure: 1013.25,
807            humidity: 50.0,
808            altitude: 0.0,
809        }
810    }
811}
812
813// Trajectory point data
814#[derive(Debug, Clone)]
815pub struct TrajectoryPoint {
816    pub time: f64,
817    pub position: Vector3<f64>,
818    pub velocity_magnitude: f64,
819    pub kinetic_energy: f64,
820    /// The projectile's own drag coefficient at this point (MBA-1423).
821    ///
822    /// Filled in one pass after integration finishes, not at the sites that build this struct,
823    /// so a solver family added later cannot silently omit it. `None` when sectional density is
824    /// unavailable — see [`TrajectorySolver::effective_drag_coefficient`] for the definition and
825    /// why this is the projectile's Cd rather than the reference table's.
826    pub drag_coefficient: Option<f64>,
827}
828
829impl TrajectoryPoint {
830    /// The value `--with-drag-coefficient` emits for this point, or `None` when the JSON key
831    /// must be ABSENT — flag off, or sectional density unknown (MBA-1427).
832    ///
833    /// Host-compilable on purpose. The browser terminal's emit site lives in `src/wasm.rs`,
834    /// which is `cfg(target_arch = "wasm32")`-gated out of every native build — CI never
835    /// executes a line of it. Keeping the gating rule here means a native test can pin the
836    /// absent-not-null contract even though the surface that applies it only compiles for wasm.
837    pub fn drag_coefficient_json_value(&self, with_drag_coefficient: bool) -> Option<f64> {
838        if with_drag_coefficient {
839            self.drag_coefficient
840        } else {
841            None
842        }
843    }
844}
845
846// Trajectory result
847#[derive(Debug, Clone)]
848pub struct TrajectoryResult {
849    pub max_range: f64,
850    pub max_height: f64,
851    pub time_of_flight: f64,
852    pub impact_velocity: f64,
853    pub impact_energy: f64,
854    /// Projectile mass used to derive full-state observation energy.
855    pub projectile_mass_kg: f64,
856    /// Height of the horizontal line of sight in the solver's ground-referenced frame.
857    pub line_of_sight_height_m: f64,
858    /// Station speed of sound used for Mach observations and transition flags.
859    pub station_speed_of_sound_mps: f64,
860    /// Explicit reason the integration stopped; consumers must not infer this from the endpoint.
861    pub termination: TrajectoryTermination,
862    pub points: Vec<TrajectoryPoint>,
863    pub sampled_points: Option<Vec<TrajectorySample>>, // Trajectory samples at regular intervals
864    pub min_pitch_damping: Option<f64>, // Minimum pitch damping coefficient (for stability warning)
865    pub transonic_mach: Option<f64>,    // Mach number when entering transonic regime
866    pub angular_state: Option<AngularState>, // Final angular state if precession/nutation enabled
867    pub max_yaw_angle: Option<f64>,     // Maximum yaw angle during flight (radians)
868    pub max_precession_angle: Option<f64>, // Maximum precession angle (radians)
869    // MBA-959: aerodynamic-jump components applied at the muzzle (None unless
870    // enable_aerodynamic_jump). EXPERIMENTAL.
871    pub aerodynamic_jump: Option<crate::aerodynamic_jump::AerodynamicJumpComponents>,
872    /// Downrange distance (m) of the downward Mach 1.2 crossing (station speed of sound),
873    /// populated identically by all three solver paths (Euler/RK4/RK45). `None` if the
874    /// trajectory never crosses 1.2 while descending (e.g. launched already below 1.2, or the
875    /// solve terminates while still above it). MBA-1405: feeds `mv_calibration_window`.
876    pub mach_1_2_distance_m: Option<f64>,
877    /// Downrange distance (m) of the downward Mach 1.0 crossing. `None` if the trajectory
878    /// never goes subsonic within the solve. MBA-1405: the far edge of the MV calibration
879    /// window (`mv_calibration_window`) is this distance.
880    pub mach_1_0_distance_m: Option<f64>,
881    /// Downrange distance (m) of the downward Mach 0.9 crossing. `None` if the trajectory
882    /// never crosses 0.9 while descending. MBA-1405: feeds `dsf_window_start`. NOTE: unlike
883    /// the 1.2/1.0 crossings, this one is intentionally NOT reflected in the historical flat
884    /// `transonic_distances` Vec threaded through
885    /// [`crate::trajectory_sampling::TrajectoryData`] — existing consumers of that Vec
886    /// interpret it strictly as `{1.2, 1.0}`, so this field is the only place the 0.9
887    /// crossing is recorded.
888    pub mach_0_9_distance_m: Option<f64>,
889}
890
891const RK45_TOLERANCE: f64 = 1e-6;
892const RK45_SAFETY_FACTOR: f64 = 0.9;
893const RK45_MAX_DT: f64 = 0.01;
894const RK45_MIN_DT: f64 = 1e-6;
895const TRAJECTORY_TIME_LIMIT_S: f64 = 100.0;
896
897/// Hard ceiling for points retained by a single [`TrajectorySolver`] result.
898///
899/// The cap applies across Euler, fixed RK4, and adaptive RK45, including the exact terminal
900/// endpoint. Solves that would exceed it return [`BallisticsError`] instead of
901/// truncating or growing their point buffer without bound.
902pub const MAX_TRAJECTORY_POINTS: usize = 250_000;
903
904/// Pack the CLI solver's split position/velocity vectors into the shared six-component RK45 norm.
905fn cli_rk45_error_norm(
906    position: &Vector3<f64>,
907    velocity: &Vector3<f64>,
908    fifth_position: &Vector3<f64>,
909    fifth_velocity: &Vector3<f64>,
910    fourth_position: &Vector3<f64>,
911    fourth_velocity: &Vector3<f64>,
912) -> f64 {
913    let pack_state = |position: &Vector3<f64>, velocity: &Vector3<f64>| {
914        Vector6::new(
915            position.x, position.y, position.z, velocity.x, velocity.y, velocity.z,
916        )
917    };
918    let state = pack_state(position, velocity);
919    let fifth_order = pack_state(fifth_position, fifth_velocity);
920    let fourth_order = pack_state(fourth_position, fourth_velocity);
921
922    crate::trajectory_integration::rk45_error_norm(&state, &fifth_order, &fourth_order)
923}
924
925struct Rk45Trial {
926    position: Vector3<f64>,
927    velocity: Vector3<f64>,
928    suggested_dt: f64,
929    error: f64,
930}
931
932struct Rk45AcceptedStep {
933    position: Vector3<f64>,
934    velocity: Vector3<f64>,
935    used_dt: f64,
936    next_dt: f64,
937    error: f64,
938}
939
940/// Tracks the downward (decelerating) Mach crossings of a solved trajectory.
941///
942/// `record_downward_crossings` is called once per integration step from all three solver
943/// paths (Euler/RK4/RK45). It both (a) appends to the historical flat `distances` Vec — used
944/// unchanged by [`crate::trajectory_sampling`] to flag sampled points — and (b) records each
945/// threshold's exact crossing distance on `self`, for callers that want the labeled value
946/// directly (MBA-1405) rather than positionally decoding the flat Vec.
947///
948/// IMPORTANT: only the historical 1.2/1.0 thresholds are appended to `distances`. The 0.9
949/// threshold added for MBA-1405 is recorded ONLY on `self.mach_0_9_distance_m` — existing
950/// consumers of the flat Vec interpret it strictly as `{mach_1_2, mach_1_0}` (in that order,
951/// when present), so appending a third entry would silently corrupt their positional
952/// assumptions.
953#[derive(Default)]
954struct MachTransitionTracker {
955    previous_mach: Option<f64>,
956    crossed_transonic: bool,
957    crossed_subsonic: bool,
958    crossed_narrow: bool,
959    /// Downrange distance (m) of the downward Mach 1.2 crossing, once seen. Mirrors the first
960    /// flat-Vec entry.
961    mach_1_2_distance_m: Option<f64>,
962    /// Downrange distance (m) of the downward Mach 1.0 crossing, once seen. Mirrors the second
963    /// flat-Vec entry.
964    mach_1_0_distance_m: Option<f64>,
965    /// Downrange distance (m) of the downward Mach 0.9 crossing, once seen. NOT reflected in
966    /// the flat Vec (see struct docs).
967    mach_0_9_distance_m: Option<f64>,
968}
969
970impl MachTransitionTracker {
971    fn record_downward_crossings(&mut self, mach: f64, downrange_m: f64, distances: &mut Vec<f64>) {
972        if !mach.is_finite() {
973            self.previous_mach = None;
974            return;
975        }
976
977        if let Some(previous_mach) = self.previous_mach {
978            if !self.crossed_transonic && previous_mach >= 1.2 && mach < 1.2 {
979                self.crossed_transonic = true;
980                distances.push(downrange_m);
981                self.mach_1_2_distance_m = Some(downrange_m);
982            }
983            if !self.crossed_subsonic && previous_mach >= 1.0 && mach < 1.0 {
984                self.crossed_subsonic = true;
985                distances.push(downrange_m);
986                self.mach_1_0_distance_m = Some(downrange_m);
987            }
988            if !self.crossed_narrow && previous_mach >= 0.9 && mach < 0.9 {
989                self.crossed_narrow = true;
990                // Intentionally NOT pushed to `distances` — see struct docs.
991                self.mach_0_9_distance_m = Some(downrange_m);
992            }
993        }
994        self.previous_mach = Some(mach);
995    }
996}
997
998impl TrajectoryResult {
999    /// Interpolate position at a given downrange distance (X coordinate, McCoy).
1000    /// Returns the interpolated (x, y, z) position at that range.
1001    /// If the target range exceeds the trajectory, returns the last point.
1002    pub fn position_at_range(&self, target_range: f64) -> Option<Vector3<f64>> {
1003        if self.points.is_empty() {
1004            return None;
1005        }
1006
1007        // Find the two points that bracket the target range
1008        for i in 0..self.points.len() - 1 {
1009            let p1 = &self.points[i];
1010            let p2 = &self.points[i + 1];
1011
1012            // Check if target range is between these two points (X is downrange)
1013            if p1.position.x <= target_range && p2.position.x >= target_range {
1014                // Linear interpolation factor
1015                let dx = p2.position.x - p1.position.x;
1016                if dx.abs() < 1e-10 {
1017                    return Some(p1.position);
1018                }
1019                let t = (target_range - p1.position.x) / dx;
1020
1021                // Interpolate Y and Z, use exact target_range for X
1022                return Some(Vector3::new(
1023                    target_range,
1024                    p1.position.y + t * (p2.position.y - p1.position.y),
1025                    p1.position.z + t * (p2.position.z - p1.position.z),
1026                ));
1027            }
1028        }
1029
1030        // Target range is beyond trajectory - return last point
1031        self.points.last().map(|p| p.position)
1032    }
1033}
1034
1035// Trajectory solver
1036#[derive(Clone)]
1037pub struct TrajectorySolver {
1038    gravity: Gravity,
1039    inputs: BallisticInputs,
1040    wind: WindConditions,
1041    atmosphere: AtmosphericConditions,
1042    /// Which of `atmosphere`'s temperature and pressure the caller supplied (0.49.0). A field
1043    /// that is not supplied keeps the legacy default-sentinel rule; see
1044    /// [`crate::atmosphere::resolve_station_conditions_with_presence`].
1045    station_presence: StationAtmospherePresence,
1046    max_range: f64,
1047    time_step: f64,
1048    max_trajectory_points: usize,
1049    cluster_bc: Option<ClusterBCDegradation>,
1050    /// Geometry-derived `(longitudinal, transverse)` moments used by angular diagnostics.
1051    precession_nutation_inertias: (f64, f64),
1052    /// Optional downrange-segmented wind. When `Some`, the per-step wind vector is
1053    /// looked up by downrange distance from this `WindSock` and the scalar `wind`
1054    /// field is ignored. When `None`, the constant `wind` vector is used (default),
1055    /// so a non-segmented solve is numerically identical to pre-feature behavior.
1056    wind_sock: Option<crate::wind::WindSock>,
1057    /// Optional downrange-segmented atmosphere (MBA-1137). When `Some`, the per-substep local
1058    /// atmosphere recompute samples the base (station-referenced) temperature/pressure/humidity by
1059    /// downrange distance from this `AtmoSock`, then feeds them through the SAME altitude-lapse
1060    /// pipeline as a single-station solve — so the downrange zone and the vertical altitude lapse
1061    /// compose without double-counting. When `None` (default), the resolved single-station
1062    /// conditions are used.
1063    atmo_sock: Option<crate::atmosphere::AtmoSock>,
1064}
1065
1066/// Which frame a zero solve's `target_height_m` lives in (MBA-1412).
1067///
1068/// `SightLine`: the classic zero contract — the height is sight geometry (typically the sight
1069/// height) and the rifle zeroes LEVEL, per the MBA-1286 doctrine that a zero is a property of a
1070/// level rifle; `shooting_angle` applies to the subsequent shot only. Used by
1071/// `calculate_zero_angle_with_conditions` and every legacy surface behind it (bindings, WASM,
1072/// FFI, CLI).
1073///
1074/// `WorldVertical`: solve-json v1's documented contract (docs/SOLVE_JSON_V1.md) — the height is
1075/// an absolute world-vertical height above the ground datum; inclined zeroing projects the
1076/// shot-frame trajectory into the world frame (MBA-1302 behavior, unchanged).
1077#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1078pub(crate) enum ZeroTargetFrame {
1079    SightLine,
1080    WorldVertical,
1081}
1082
1083/// Both line-of-sight crossings a single fixed bore angle can produce (Tier 2 whole-branch
1084/// review, C2): a rifle whose sight sits above its bore generally crosses a level line of
1085/// sight TWICE on a rising shot — an ascending "near zero" close to the muzzle, and a
1086/// descending "far zero" past the apex. The classic 25/300-yard battle-zero pairing is one
1087/// bore angle producing both. `TrajectorySolver`'s internal `find_zero_range` and the public
1088/// `calculate_zero_range_from_angle_*` functions return both rather than silently choosing
1089/// one, because `find_zero_angle`'s own choice of root depends on the target distance it was
1090/// asked to solve for — the forward and inverse solvers are NOT a single-valued pair.
1091///
1092/// Either field may be `None` when the solved envelope doesn't reach that crossing (e.g. a
1093/// short `--max-range` that stops before the far crossing, or a target height high enough
1094/// that the trajectory never rises to it at all). At least one is always `Some` on `Ok`.
1095#[derive(Debug, Clone, Copy, PartialEq)]
1096pub struct ZeroCrossings {
1097    /// Downrange distance (meters) of the near, ascending crossing, if the solved envelope
1098    /// reaches it.
1099    pub near_m: Option<f64>,
1100    /// Downrange distance (meters) of the far, descending crossing -- what "sighted in at D
1101    /// yards" conventionally means -- if the solved envelope reaches it.
1102    pub far_m: Option<f64>,
1103}
1104
1105impl TrajectorySolver {
1106    /// Construct a solver from a presence-blind [`AtmosphericConditions`].
1107    ///
1108    /// Presence-blind: a bare `f64` cannot say whether it was supplied, so at an altitude above
1109    /// 1 m a temperature within 0.1 °C of 15 °C or a pressure within 0.5 hPa of 1013.25 hPa is
1110    /// read as omitted and the ICAO standard value at that altitude is flown instead. A caller
1111    /// that knows which values were supplied uses [`Self::new_with_station_presence`]; this is
1112    /// that constructor with [`StationAtmospherePresence::NEITHER`].
1113    pub fn new(
1114        inputs: BallisticInputs,
1115        wind: WindConditions,
1116        atmosphere: AtmosphericConditions,
1117    ) -> Self {
1118        Self::new_with_station_presence(
1119            inputs,
1120            wind,
1121            atmosphere,
1122            StationAtmospherePresence::NEITHER,
1123        )
1124    }
1125
1126    /// Construct a solver that knows which of `atmosphere`'s temperature and pressure the
1127    /// caller supplied (0.49.0).
1128    ///
1129    /// A supplied value flies as given, whatever its value: a typed 15 °C or 1013.25 hPa at
1130    /// altitude is a reading, not a default. An omitted value keeps the legacy rule of
1131    /// [`Self::new`], so [`StationAtmospherePresence::NEITHER`] is bit-identical to it and
1132    /// [`StationAtmospherePresence::BOTH`] to [`Self::new_with_resolved_station_atmosphere`].
1133    /// The pressure is absolute station pressure; reduce a QNH first, or resolve the pair with
1134    /// [`crate::atmosphere::resolve_station_conditions_with_presence`] in
1135    /// [`PressureReferenceMode::Qnh`] and pass the result with `BOTH`.
1136    pub fn new_with_station_presence(
1137        inputs: BallisticInputs,
1138        wind: WindConditions,
1139        atmosphere: AtmosphericConditions,
1140        station_presence: StationAtmospherePresence,
1141    ) -> Self {
1142        Self::new_with_station_presence_and_gravity(
1143            inputs,
1144            wind,
1145            atmosphere,
1146            station_presence,
1147            Gravity::EARTH,
1148        )
1149    }
1150
1151    /// Construct a solver from station temperature and pressure that a presence-aware service
1152    /// has already resolved. Unlike [`Self::new`], exact sea-level standard values remain
1153    /// authoritative at nonzero altitude rather than acting as legacy omission sentinels.
1154    ///
1155    /// `pub` (MBA-1397; was `pub(crate)`): a QNH-aware caller (native CLI, WASM) that has
1156    /// already resolved the station air, for example with
1157    /// [`crate::atmosphere::resolve_station_conditions_with_presence`] (which reduces a declared
1158    /// altimeter setting to station pressure), needs this constructor too, and those callers
1159    /// live in a different crate (`src/main.rs` is a separate binary crate over the library).
1160    /// It is [`Self::new_with_station_presence`] with [`StationAtmospherePresence::BOTH`].
1161    pub fn new_with_resolved_station_atmosphere(
1162        inputs: BallisticInputs,
1163        wind: WindConditions,
1164        atmosphere: AtmosphericConditions,
1165    ) -> Self {
1166        Self::new_with_station_presence(inputs, wind, atmosphere, StationAtmospherePresence::BOTH)
1167    }
1168
1169    fn new_with_station_presence_and_gravity(
1170        mut inputs: BallisticInputs,
1171        wind: WindConditions,
1172        atmosphere: AtmosphericConditions,
1173        station_presence: StationAtmospherePresence,
1174        gravity: Gravity,
1175    ) -> Self {
1176        // MBA-1415: every input-conditioning step lives in one shared, idempotent helper that
1177        // the fast_integrate* entry points call too. Keeping it out of this constructor is the
1178        // point: conditioning that lives only here is invisible to the external bindings that
1179        // call the fast path directly.
1180        inputs.normalize_for_solve();
1181
1182        // Initialize cluster BC if enabled
1183        let cluster_bc = if inputs.use_cluster_bc {
1184            Some(ClusterBCDegradation::new())
1185        } else {
1186            None
1187        };
1188        let precession_nutation_inertias = projectile_moments_of_inertia(
1189            inputs.bullet_mass,
1190            inputs.bullet_diameter,
1191            inputs.bullet_length,
1192        );
1193
1194        Self {
1195            gravity,
1196            inputs,
1197            wind,
1198            atmosphere,
1199            station_presence,
1200            max_range: 1000.0,
1201            time_step: 0.001,
1202            max_trajectory_points: MAX_TRAJECTORY_POINTS,
1203            cluster_bc,
1204            precession_nutation_inertias,
1205            wind_sock: None,
1206            atmo_sock: None,
1207        }
1208    }
1209
1210    pub fn set_max_range(&mut self, range: f64) {
1211        self.max_range = range;
1212    }
1213
1214    pub fn set_time_step(&mut self, step: f64) {
1215        self.time_step = step;
1216    }
1217
1218    /// Calculate a level-rifle zero with this solver's configured atmosphere, wind (including
1219    /// downrange segments), effects, integration method, and time step, then install the resulting
1220    /// muzzle angle on this solver. The solver is mutated only after a zero has converged.
1221    pub(crate) fn calculate_and_set_zero_angle(
1222        &mut self,
1223        target_distance_m: f64,
1224        target_height_m: f64,
1225        frame: ZeroTargetFrame,
1226    ) -> Result<f64, BallisticsError> {
1227        let angle = self.find_zero_angle(target_distance_m, target_height_m, frame)?;
1228        // MBA-1359: a deliberate POI offset at the zero range (Kestrel ZH/ZO) is an angular
1229        // bias ON the solved angle, applied post-solve so the zero trials above still solve
1230        // perfect convergence. The small-angle addition is frame-independent (identical in
1231        // SightLine and WorldVertical), and with both offsets at their 0.0 defaults the
1232        // additions below are exact no-ops (byte-identical). The returned angle carries the
1233        // vertical bias so auto-zero callers that copy it onto flight inputs inherit it; the
1234        // horizontal bias lands on THIS solver's azimuth_angle — separate flight inputs must
1235        // add `windage_zero_bias_rad` themselves.
1236        let angle = if target_distance_m > 0.0 {
1237            angle + self.inputs.zero_poi_vertical_m / target_distance_m
1238        } else {
1239            angle
1240        };
1241        self.inputs.muzzle_angle = angle;
1242        self.apply_windage_zero_bias(target_distance_m);
1243        Ok(angle)
1244    }
1245
1246    /// Apply just the windage-zero convergence bias (see
1247    /// [`BallisticInputs::windage_zero_bias_rad`]) to this solver's azimuth, for a zero at
1248    /// `target_distance_m`, without searching for or installing an elevation.
1249    ///
1250    /// Used by [`Self::calculate_and_set_zero_angle`] above as part of a full zero search, and
1251    /// by solve_v1's round-trip re-solve path (0.33.0 decision-support Task 2) on its own: when
1252    /// an explicit `muzzle_angle_rad` already carries the correct elevation (round-tripped from
1253    /// a previous `resolved_request`, which bakes any `zero_poi_vertical_m` bias into that
1254    /// angle already), the elevation search does not run, but the windage term is a SEPARATE
1255    /// quantity that depends only on `zero_distance_m` -- not on whether the elevation was
1256    /// searched for or supplied directly -- so it must still be applied here or an
1257    /// offset-mounted sight / deliberate horizontal zero bias would silently stop converging
1258    /// once a request round-trips.
1259    pub(crate) fn apply_windage_zero_bias(&mut self, target_distance_m: f64) {
1260        self.inputs.azimuth_angle += self.inputs.windage_zero_bias_rad(target_distance_m);
1261    }
1262
1263    fn find_zero_angle(
1264        &self,
1265        target_distance_m: f64,
1266        target_height_m: f64,
1267        frame: ZeroTargetFrame,
1268    ) -> Result<f64, BallisticsError> {
1269        // Binary search for the angle that hits the target. The bracket starts at [0, 0.2] rad
1270        // and widens downward for a target below where a level bore lands, or upward for one
1271        // beyond the initial top angle.
1272        let mut low_angle = 0.0;
1273        let mut high_angle = 0.2; // about 11 degrees
1274        let tolerance = 1e-7;
1275        let max_iterations = 60;
1276
1277        // MBA-194: validate the initial bracket before starting the binary search.
1278        let low_height = self.zero_trial_height_at(low_angle, target_distance_m, frame)?;
1279        let high_height = self.zero_trial_height_at(high_angle, target_distance_m, frame)?;
1280
1281        match (low_height, high_height) {
1282            (Some(low_height), Some(high_height)) => {
1283                let low_error = low_height - target_height_m;
1284                let high_error = high_height - target_height_m;
1285
1286                if low_error > 0.0 && high_error > 0.0 {
1287                    // Both angles overshoot: the target sits below where a level bore lands
1288                    // (a shooter above the target), so the bore must point below horizontal.
1289                    // The search used to stop at 0 rad and fail. Lower the bottom of the
1290                    // bracket in the same steps the undershoot branch raises the top, down to
1291                    // 45 degrees. A trial that ends short of the target distance (the ground
1292                    // stop) is below the target too, exactly as the bisection below treats it.
1293                    let mut expanded = false;
1294                    for multiplier in [1.0, 2.0, 3.0, 4.0] {
1295                        let new_low = (-0.2_f64 * multiplier).max(-0.785);
1296                        match self.zero_trial_height_at(new_low, target_distance_m, frame) {
1297                            Ok(Some(height)) if height - target_height_m > 0.0 => {}
1298                            Ok(_) => {
1299                                high_angle = low_angle;
1300                                low_angle = new_low;
1301                                expanded = true;
1302                                break;
1303                            }
1304                            Err(_) => {}
1305                        }
1306                    }
1307                    if !expanded {
1308                        return Err("Cannot find zero angle: target is below the trajectory even at the steepest downward angle".into());
1309                    }
1310                } else if low_error < 0.0 && high_error < 0.0 {
1311                    // Both angles undershoot. Preserve the historical expansion up to 45 degrees.
1312                    let mut expanded = false;
1313                    for multiplier in [2.0, 3.0, 4.0] {
1314                        let new_high = (high_angle * multiplier).min(0.785);
1315                        if let Ok(Some(height)) =
1316                            self.zero_trial_height_at(new_high, target_distance_m, frame)
1317                        {
1318                            if height - target_height_m > 0.0 {
1319                                high_angle = new_high;
1320                                expanded = true;
1321                                break;
1322                            }
1323                        }
1324                        if new_high >= 0.785 {
1325                            break;
1326                        }
1327                    }
1328                    if !expanded {
1329                        return Err("Cannot find zero angle: target beyond effective range even at maximum angle".into());
1330                    }
1331                }
1332            }
1333            (None, Some(_)) => {
1334                // The low angle does not reach the target while the high angle does; the search
1335                // will raise the low end until it reaches a valid trajectory.
1336            }
1337            (Some(_), None) => {
1338                return Err(
1339                    "Cannot find zero angle: high angle trajectory doesn't reach target distance"
1340                        .into(),
1341                );
1342            }
1343            (None, None) => {
1344                return Err(
1345                    "Cannot find zero angle: trajectory cannot reach target distance at any angle"
1346                        .into(),
1347                );
1348            }
1349        }
1350
1351        for _ in 0..max_iterations {
1352            let mid_angle = (low_angle + high_angle) / 2.0;
1353            match self.zero_trial_height_at(mid_angle, target_distance_m, frame)? {
1354                Some(height) => {
1355                    let error = height - target_height_m;
1356                    // MBA-193: height accuracy is the primary convergence criterion. At 0.1 mm,
1357                    // short-range zero-day atmosphere differences remain observable.
1358                    if error.abs() < 0.0001 {
1359                        return Ok(mid_angle);
1360                    }
1361
1362                    // Only use angle tolerance after precision is exhausted and the remaining
1363                    // height error is still practically acceptable.
1364                    if (high_angle - low_angle).abs() < tolerance {
1365                        if error.abs() < 0.01 {
1366                            return Ok(mid_angle);
1367                        }
1368                        return Err("Zero angle did not converge: residual height error too large (target not reachable / not bracketed)".into());
1369                    }
1370
1371                    if error > 0.0 {
1372                        high_angle = mid_angle;
1373                    } else {
1374                        low_angle = mid_angle;
1375                    }
1376                }
1377                None => {
1378                    low_angle = mid_angle;
1379                    if (high_angle - low_angle).abs() < tolerance {
1380                        return Err("Trajectory cannot reach target distance - angle converged without valid solution".into());
1381                    }
1382                }
1383            }
1384        }
1385
1386        Err("Failed to find zero angle".into())
1387    }
1388
1389    /// Solve one zero-angle trial without losing any solver configuration. Only the trial clone's
1390    /// launch angle, level-rifle convention, and integration range differ from the final solve.
1391    fn zero_trial_height_at(
1392        &self,
1393        angle_rad: f64,
1394        target_distance_m: f64,
1395        frame: ZeroTargetFrame,
1396    ) -> Result<Option<f64>, BallisticsError> {
1397        let mut trial = self.clone();
1398        trial.inputs.muzzle_angle = angle_rad;
1399        // MBA-959: zero on the bare bore so aerodynamic jump remains an additive fire-time POI
1400        // shift rather than being silently absorbed by the zero search.
1401        trial.inputs.enable_aerodynamic_jump = false;
1402        // MBA-1286: a zero is a property of a level rifle's sight geometry. Cant is applied only
1403        // to the subsequent shot.
1404        trial.inputs.cant_angle = 0.0;
1405        // MBA-1412: in the SightLine contract the incline is likewise a shot-time condition,
1406        // not sight geometry. MBA-1302 left it in the trial while shot_frame_altitude lifted
1407        // heights into the gravity frame, adding d*sin(shooting_angle) (~9 m at 5.71 deg /
1408        // 100 yd) against a sight-frame target height, so no inclined legacy caller could
1409        // bracket. Zero level; shoot inclined. solve-json v1's WorldVertical contract keeps
1410        // the MBA-1302 projection (its callers supply world-frame heights).
1411        if frame == ZeroTargetFrame::SightLine {
1412            trial.inputs.shooting_angle = 0.0;
1413        }
1414        trial.set_max_range(target_distance_m * 2.0);
1415        let result = trial.solve()?;
1416
1417        for (index, point) in result.points.iter().enumerate() {
1418            if point.position.x >= target_distance_m {
1419                let shot_y_m = if index == 0 {
1420                    point.position.y
1421                } else {
1422                    let previous = &result.points[index - 1];
1423                    let span = point.position.x - previous.position.x;
1424                    let fraction = (target_distance_m - previous.position.x) / span;
1425                    previous.position.y + fraction * (point.position.y - previous.position.y)
1426                };
1427                return Ok(Some(crate::atmosphere::shot_frame_altitude(
1428                    0.0,
1429                    target_distance_m,
1430                    shot_y_m,
1431                    trial.inputs.shooting_angle,
1432                )));
1433            }
1434        }
1435        Ok(None)
1436    }
1437
1438    /// Inverse of [`Self::find_zero_angle`] (MBA-1402), corrected by the Tier 2 whole-branch
1439    /// review (C2): given a FIXED bore angle rather than searching for one, run that
1440    /// trajectory and locate the downrange distance(s) where it crosses `target_height_m` —
1441    /// the zero RANGE(S) a stored/portable bore angle implies under THESE conditions. Hornady
1442    /// and Kestrel 4DOF treat the zero angle as the portable quantity (captured once, then
1443    /// reusable independent of the day it was solved); this is what makes that value usable
1444    /// again.
1445    ///
1446    /// A rifle whose sight sits above the bore generally crosses a level line of sight TWICE
1447    /// on a rising shot: once ascending, close to the muzzle (the "near zero"), and once
1448    /// descending past the apex (the "far zero"). Both are equally real zero ranges for the
1449    /// SAME bore angle — this is the classic 25/300-yard battle-zero relationship: a rifle
1450    /// zeroed at 25 yd (the near crossing of its bore angle) is, for the same angle, ALSO
1451    /// zeroed again around 300 yd (the far crossing). [`Self::find_zero_angle`]'s own choice
1452    /// of root depends on which target distance it was asked to solve for, so this is not a
1453    /// single-valued inverse of that function — it is this function's job to report both
1454    /// roots it can find rather than silently pick one and call it "the" zero range.
1455    ///
1456    /// Returns [`ZeroCrossings`] with the near (first ascending, error negative → positive)
1457    /// and far (descending, error positive → negative) crossings independently, either of
1458    /// which may be absent if the solved envelope doesn't reach it (e.g. `--max-range` too
1459    /// short to reach the far crossing, or an angle so shallow / a target height so high that
1460    /// the near crossing never happens). Errors only when NEITHER crossing is found. Each
1461    /// crossing distance is linearly interpolated between its two bracketing sample points,
1462    /// mirroring `zero_trial_height_at`'s own height-at-a-known-distance interpolation in the
1463    /// other direction.
1464    fn find_zero_range(
1465        &self,
1466        angle_rad: f64,
1467        target_height_m: f64,
1468        frame: ZeroTargetFrame,
1469    ) -> Result<ZeroCrossings, BallisticsError> {
1470        let mut trial = self.clone();
1471        trial.inputs.muzzle_angle = angle_rad;
1472        // Mirrors zero_trial_height_at's trial setup exactly (MBA-959 / MBA-1286 / MBA-1412),
1473        // so both directions apply the same conventions.
1474        trial.inputs.enable_aerodynamic_jump = false;
1475        trial.inputs.cant_angle = 0.0;
1476        if frame == ZeroTargetFrame::SightLine {
1477            trial.inputs.shooting_angle = 0.0;
1478        }
1479        let result = trial.solve()?;
1480
1481        // The height-above-target-height error is, for a normal flat-fire trajectory,
1482        // unimodal: it starts below the line of sight (bore below sight), rises through it
1483        // ONCE (ascending -- the near zero), continues to the apex, then falls back through
1484        // it ONCE more (descending -- the far zero) before diverging away for good. So at
1485        // most one ascending and one descending crossing exist; classify each sign change by
1486        // direction rather than by position (first/last) so a near-only trajectory (solved
1487        // range too short to reach the far crossing) is never mislabeled as "far".
1488        let mut near_crossing: Option<f64> = None;
1489        let mut far_crossing: Option<f64> = None;
1490        let mut previous: Option<(f64, f64)> = None; // (downrange_m, height_error_m)
1491        for point in &result.points {
1492            let height = crate::atmosphere::shot_frame_altitude(
1493                0.0,
1494                point.position.x,
1495                point.position.y,
1496                trial.inputs.shooting_angle,
1497            );
1498            let error = height - target_height_m;
1499            if let Some((prev_x, prev_error)) = previous {
1500                if prev_error == 0.0 {
1501                    // An exact touch is itself a crossing; assign it to whichever slot is
1502                    // still open (near first, since it can only occur before far).
1503                    if near_crossing.is_none() {
1504                        near_crossing = Some(prev_x);
1505                    } else {
1506                        far_crossing = Some(prev_x);
1507                    }
1508                }
1509                if prev_error * error < 0.0 {
1510                    let fraction = prev_error / (prev_error - error);
1511                    let crossing = prev_x + fraction * (point.position.x - prev_x);
1512                    if prev_error < 0.0 && error > 0.0 {
1513                        // Ascending: the near zero. Keep only the first one found.
1514                        if near_crossing.is_none() {
1515                            near_crossing = Some(crossing);
1516                        }
1517                    } else {
1518                        // Descending: the far zero.
1519                        far_crossing = Some(crossing);
1520                    }
1521                }
1522            }
1523            previous = Some((point.position.x, error));
1524        }
1525        // The loop above only resolves a point once a later point makes it `previous`; catch
1526        // the trajectory's very last sampled point landing exactly on the line too.
1527        if let Some((last_x, last_error)) = previous {
1528            if last_error == 0.0 {
1529                if near_crossing.is_none() {
1530                    near_crossing = Some(last_x);
1531                } else {
1532                    far_crossing = Some(last_x);
1533                }
1534            }
1535        }
1536
1537        if near_crossing.is_none() && far_crossing.is_none() {
1538            return Err(BallisticsError::from(
1539                "Cannot find zero range: this angle never crosses the target height within the \
1540                 solved range (angle too shallow to reach it, or both crossings lie beyond \
1541                 the solver's max range)."
1542                    .to_string(),
1543            ));
1544        }
1545
1546        Ok(ZeroCrossings {
1547            near_m: near_crossing,
1548            far_m: far_crossing,
1549        })
1550    }
1551
1552    /// Equivalent horizontal range for an inclined shot (MBA-1395): the flat-fire range
1553    /// whose ANGULAR elevation correction — relative to the SAME dialed zero — equals the
1554    /// inclined solution's angular correction at `target_range_m`. This is the
1555    /// "shoot-to" range SIG BDX (AMR), Leica (EHR), and Gunwerks BR2 report so users of
1556    /// fixed BDC turrets/reticles can dial as if the shot were flat, computed by an
1557    /// inverse lookup over one flat re-solve (McDonald's Sierra inclined-fire treatment /
1558    /// Litz) rather than the rifleman's-rule cosine approximation, which matches LINEAR
1559    /// drop and accumulates error at long range.
1560    ///
1561    /// The solver's current state IS the inclined solution: `muzzle_angle` carries the
1562    /// solved zero and `shooting_angle` the look angle. The flat reference clones the
1563    /// solver and zeroes only `shooting_angle` (the WASM auto-zero flat-clone precedent),
1564    /// keeping the zero, wind, atmosphere, and integration configuration identical, so
1565    /// both angular corrections are measured against the same zero.
1566    ///
1567    /// Returns `None` where the inverse is ill-defined instead of guessing:
1568    /// - `target_range_m <= zero_distance_m` (inside the zero, no BDC correction exists);
1569    /// - the inclined correction at the target is not positive (bullet at/above the LOS);
1570    /// - either solve fails, or the flat solve cannot bracket the correction.
1571    ///
1572    /// Past the zero range the flat correction is monotone in range, so a plain bisection
1573    /// over the flat solve's interpolated corrections converges; the answer is refined to
1574    /// centimeter level, far below any display precision.
1575    pub fn equivalent_horizontal_range(
1576        &self,
1577        target_range_m: f64,
1578        zero_distance_m: f64,
1579    ) -> Option<f64> {
1580        if !target_range_m.is_finite() || !zero_distance_m.is_finite() {
1581            return None;
1582        }
1583        if target_range_m <= zero_distance_m || target_range_m <= 0.0 {
1584            return None;
1585        }
1586
1587        // Height of the bullet path at a downrange distance, linearly interpolated
1588        // between integration points (mirrors zero_trial_height_at's interpolation).
1589        // In the shot frame the LOS is the x-axis, so drop below the LOS is
1590        // `los_height - y` for the inclined and flat solves alike.
1591        fn path_y_at(points: &[TrajectoryPoint], distance_m: f64) -> Option<f64> {
1592            match bracket_param(points.len(), |i| points[i].position.x, distance_m) {
1593                // Below the first point: clamp to it (mirrors zero_trial_height_at's
1594                // interpolation, which the doc comment above promises). Fewer than two points
1595                // is not a case a real solved trajectory reaches; treat it like Above (no
1596                // extrapolation) rather than guessing.
1597                Bracket::Below => Some(points[0].position.y),
1598                Bracket::Above | Bracket::Degenerate => None,
1599                Bracket::Inside { lo, t } => {
1600                    let hi = lo + 1;
1601                    Some(
1602                        points[lo].position.y + t * (points[hi].position.y - points[lo].position.y),
1603                    )
1604                }
1605            }
1606        }
1607
1608        // Inclined solution's angular correction at the target (radians, small-angle:
1609        // drop below the LOS divided by range). Sampling is display-side only — turn it
1610        // off in both internal solves.
1611        let mut inclined = self.clone();
1612        inclined.inputs.enable_trajectory_sampling = false;
1613        let inclined_result = inclined.solve().ok()?;
1614        let los_height = inclined_result.line_of_sight_height_m;
1615        let inclined_drop = los_height - path_y_at(&inclined_result.points, target_range_m)?;
1616        let correction = inclined_drop / target_range_m;
1617        if correction <= 0.0 {
1618            return None;
1619        }
1620
1621        // One flat re-solve against the same zero.
1622        let mut flat = self.clone();
1623        flat.inputs.enable_trajectory_sampling = false;
1624        flat.inputs.shooting_angle = 0.0;
1625        let flat_result = flat.solve().ok()?;
1626        let flat_correction_at = |range_m: f64| -> Option<f64> {
1627            Some((los_height - path_y_at(&flat_result.points, range_m)?) / range_m)
1628        };
1629
1630        // Bracket, then bisect. Gravity's along-LOS component only shrinks under a look
1631        // angle, so the flat correction at the true range bounds the inclined one from
1632        // above and the equivalent range lies in (zero_distance, target_range]. The flat
1633        // clone can strike the ground SHORT of the inclined terminal (an uphill shot
1634        // stays airborne longer), so the upper bracket is additionally capped at the
1635        // flat solve's own terminal distance — the shoot-to range is shorter than the
1636        // true range, so the cap does not exclude the root.
1637        let flat_terminal_m = flat_result.points.last().map(|p| p.position.x)?;
1638        let mut low = zero_distance_m.max(1.0);
1639        let mut high = target_range_m.min(flat_terminal_m);
1640        if high <= low {
1641            return None;
1642        }
1643        if flat_correction_at(low)? - correction > 0.0 {
1644            return None; // no bracket below
1645        }
1646        if flat_correction_at(high)? - correction < 0.0 {
1647            return None; // no bracket above (correction not reachable flat)
1648        }
1649        for _ in 0..60 {
1650            let mid = 0.5 * (low + high);
1651            let error = flat_correction_at(mid)? - correction;
1652            if error.abs() == 0.0 {
1653                return Some(mid);
1654            }
1655            if error < 0.0 {
1656                low = mid;
1657            } else {
1658                high = mid;
1659            }
1660            if high - low < 0.01 {
1661                break;
1662            }
1663        }
1664        Some(0.5 * (low + high))
1665    }
1666
1667    /// Reject malformed state before it reaches an integration loop.
1668    ///
1669    /// `new` resolves powder-temperature velocity overrides and refreshes the imperial mirror
1670    /// fields, so validation belongs here: it sees the effective muzzle velocity, covers values
1671    /// changed through solver setters, and applies uniformly to Euler, RK4, and RK45.
1672    fn validate_for_solve(&self) -> Result<(), BallisticsError> {
1673        let require_finite = |name: &str, value: f64| {
1674            if value.is_finite() {
1675                Ok(())
1676            } else {
1677                Err(BallisticsError::from(format!("{name} must be finite")))
1678            }
1679        };
1680        let require_positive = |name: &str, value: f64| {
1681            if value.is_finite() && value > 0.0 {
1682                Ok(())
1683            } else {
1684                Err(BallisticsError::from(format!(
1685                    "{name} must be finite and greater than zero"
1686                )))
1687            }
1688        };
1689
1690        // These four quantities are required by every point-mass solve. In particular, validate
1691        // muzzle_velocity after `new` has applied a measured curve or linear powder correction.
1692        // A custom drag table flown as the projectile's own curve supplies the actual Cd and
1693        // divides by sectional density, so bc_value is physically ignored there (see
1694        // custom_drag_denominator). A table flown as a reference curve (custom_drag_reference)
1695        // divides by the BC exactly as a G-model does, so bc_value is required in every case but
1696        // the own-curve one; mass + diameter are always required (they drive the SD denominator
1697        // when an own-curve table is set).
1698        if self.inputs.custom_drag_table.is_none() || self.inputs.custom_drag_reference {
1699            require_positive("bc_value", self.inputs.bc_value)?;
1700        }
1701        require_positive("bullet_mass", self.inputs.bullet_mass)?;
1702        require_positive("bullet_diameter", self.inputs.bullet_diameter)?;
1703        require_positive("muzzle_velocity", self.inputs.muzzle_velocity)?;
1704        // MBA-1356: cd_scale multiplies the custom-deck Cd; a non-finite or non-positive value
1705        // would zero/invert drag or poison integration with NaN. Required unconditionally (not
1706        // gated on custom_drag_table) since the default (1.0) is always valid and a caller could
1707        // set an invalid scale without ever setting a table. The [0.5, 2.0] "unusual" range is a
1708        // CLI-level warning (Task 2), not a hard engine rule.
1709        require_positive("cd_scale", self.inputs.cd_scale)?;
1710
1711        require_finite("muzzle_angle", self.inputs.muzzle_angle)?;
1712        require_finite("azimuth_angle", self.inputs.azimuth_angle)?;
1713        require_finite("shooting_angle", self.inputs.shooting_angle)?;
1714        require_finite("cant_angle", self.inputs.cant_angle)?;
1715        require_finite("muzzle_height", self.inputs.muzzle_height)?;
1716
1717        // MBA-1359: a deliberate zero POI offset is a small linear offset at the zero range
1718        // (fractions of an inch to a few inches). |1 m| is far beyond any plausible
1719        // deliberate offset and almost certainly a unit error (inches/cm passed as meters).
1720        for (name, value) in [
1721            ("zero_poi_vertical_m", self.inputs.zero_poi_vertical_m),
1722            ("zero_poi_horizontal_m", self.inputs.zero_poi_horizontal_m),
1723        ] {
1724            require_finite(name, value)?;
1725            if value.abs() >= 1.0 {
1726                return Err(BallisticsError::from(format!(
1727                    "{name} must be smaller than 1.0 m in magnitude (it is a linear POI \
1728                     offset at the zero range, in meters)"
1729                )));
1730            }
1731        }
1732
1733        // MBA-1396: a lateral sight-mount offset is physically bounded by rail/mount
1734        // geometry (an inch or two). |0.5 m| is almost certainly a unit error
1735        // (inches/mm passed as meters).
1736        require_finite("sight_offset_lateral_m", self.inputs.sight_offset_lateral_m)?;
1737        if self.inputs.sight_offset_lateral_m.abs() >= 0.5 {
1738            return Err(BallisticsError::from(
1739                "sight_offset_lateral_m must be smaller than 0.5 m in magnitude (it is \
1740                 the lateral sight-to-bore mount offset, in meters)",
1741            ));
1742        }
1743
1744        // Negative infinity is the documented ignore-ground sentinel. NaN and positive infinity
1745        // make the loop condition meaningless and are rejected.
1746        if !(self.inputs.ground_threshold.is_finite()
1747            || self.inputs.ground_threshold == f64::NEG_INFINITY)
1748        {
1749            return Err(BallisticsError::from(
1750                "ground_threshold must be finite or negative infinity",
1751            ));
1752        }
1753
1754        match &self.wind_sock {
1755            Some(wind_sock) => wind_sock
1756                .validate_segments()
1757                .map_err(BallisticsError::from)?,
1758            None => {
1759                require_finite("wind.speed", self.wind.speed)?;
1760                require_finite("wind.direction", self.wind.direction)?;
1761                require_finite("wind.vertical_speed", self.wind.vertical_speed)?;
1762            }
1763        }
1764
1765        require_finite("atmosphere.temperature", self.atmosphere.temperature)?;
1766        require_finite("atmosphere.pressure", self.atmosphere.pressure)?;
1767        require_finite("atmosphere.humidity", self.atmosphere.humidity)?;
1768        require_finite("atmosphere.altitude", self.atmosphere.altitude)?;
1769
1770        require_positive("max_range", self.max_range)?;
1771        // Adaptive RK45 owns its step size; the caller-provided fixed step is used only by Euler
1772        // and fixed RK4.
1773        if !self.inputs.use_rk4 || !self.inputs.use_adaptive_rk45 {
1774            require_positive("time_step", self.time_step)?;
1775        }
1776
1777        if self.inputs.enable_trajectory_sampling {
1778            require_finite("sight_height", self.inputs.sight_height)?;
1779            require_positive("sample_interval", self.inputs.sample_interval)?;
1780            projected_sample_count(self.max_range, self.inputs.sample_interval)?;
1781        }
1782
1783        // MBA-1403: the target-plane drops reference divides sampled drop by
1784        // cos(shooting_angle); at or beyond 90 degrees the transform is undefined.
1785        // Gated on the non-default mode only, so LOS-mode validation is unchanged.
1786        if self.inputs.drops_reference == DropsReference::Target {
1787            require_finite("target_height", self.inputs.target_height)?;
1788            if self.inputs.shooting_angle.cos() <= 1e-9 {
1789                return Err(BallisticsError::from(
1790                    "drops reference 'target' is undefined for shooting angles at or beyond 90 degrees",
1791                ));
1792            }
1793        }
1794
1795        if self.inputs.enable_coriolis {
1796            require_finite("shot_azimuth", self.inputs.shot_azimuth)?;
1797            if let Some(latitude) = self.inputs.latitude {
1798                require_finite("latitude", latitude)?;
1799            }
1800        }
1801
1802        Ok(())
1803    }
1804
1805    /// Public solve results must never report success with NaN or infinity, nor with values a
1806    /// physical trajectory cannot produce: a negative terminal downrange distance, time of
1807    /// flight, speed, or energy (MBA-1293 — a stiff-input integration explosion reported
1808    /// `Ok(max_range: -50.59)`). The input gate catches malformed scalar state; this
1809    /// postcondition also covers overflow and malformed optional tables/segments without
1810    /// imposing arbitrary upper bounds on otherwise finite inputs.
1811    fn validate_result_sanity(&self, result: &TrajectoryResult) -> Result<(), BallisticsError> {
1812        let require_finite = |name: &str, value: f64| {
1813            if value.is_finite() {
1814                Ok(())
1815            } else {
1816                Err(BallisticsError::from(format!(
1817                    "trajectory result contains non-finite {name}"
1818                )))
1819            }
1820        };
1821        let require_non_negative = |name: &str, value: f64| {
1822            if value >= 0.0 {
1823                Ok(())
1824            } else {
1825                Err(BallisticsError::from(format!(
1826                    "trajectory result contains non-physical negative {name} ({value})"
1827                )))
1828            }
1829        };
1830        let require_indexed_finite = |collection: &str, index: usize, field: &str, value: f64| {
1831            if value.is_finite() {
1832                Ok(())
1833            } else {
1834                Err(BallisticsError::from(format!(
1835                    "trajectory result contains non-finite {collection}[{index}].{field}"
1836                )))
1837            }
1838        };
1839        let require_indexed_non_negative = |collection: &str,
1840                                            index: usize,
1841                                            field: &str,
1842                                            value: f64| {
1843            if value >= 0.0 {
1844                Ok(())
1845            } else {
1846                Err(BallisticsError::from(format!(
1847                        "trajectory result contains non-physical negative {collection}[{index}].{field} ({value})"
1848                    )))
1849            }
1850        };
1851
1852        require_finite("max_range", result.max_range)?;
1853        require_finite("max_height", result.max_height)?;
1854        require_finite("time_of_flight", result.time_of_flight)?;
1855        require_finite("impact_velocity", result.impact_velocity)?;
1856        require_finite("impact_energy", result.impact_energy)?;
1857        require_finite("projectile_mass_kg", result.projectile_mass_kg)?;
1858        require_finite("line_of_sight_height_m", result.line_of_sight_height_m)?;
1859        require_finite(
1860            "station_speed_of_sound_mps",
1861            result.station_speed_of_sound_mps,
1862        )?;
1863
1864        // The solve starts at x = 0 and only ever fires downrange, so these scalars are
1865        // non-negative for every physically meaningful trajectory. (max_height is exempt:
1866        // points can legitimately sit below y = 0 with an elevated muzzle.)
1867        require_non_negative("max_range", result.max_range)?;
1868        require_non_negative("time_of_flight", result.time_of_flight)?;
1869        require_non_negative("impact_velocity", result.impact_velocity)?;
1870        require_non_negative("impact_energy", result.impact_energy)?;
1871        require_non_negative("projectile_mass_kg", result.projectile_mass_kg)?;
1872        require_non_negative(
1873            "station_speed_of_sound_mps",
1874            result.station_speed_of_sound_mps,
1875        )?;
1876
1877        for (index, point) in result.points.iter().enumerate() {
1878            require_indexed_finite("points", index, "time", point.time)?;
1879            require_indexed_finite("points", index, "position.x", point.position.x)?;
1880            require_indexed_finite("points", index, "position.y", point.position.y)?;
1881            require_indexed_finite("points", index, "position.z", point.position.z)?;
1882            require_indexed_finite(
1883                "points",
1884                index,
1885                "velocity_magnitude",
1886                point.velocity_magnitude,
1887            )?;
1888            require_indexed_finite("points", index, "kinetic_energy", point.kinetic_energy)?;
1889            require_indexed_non_negative("points", index, "time", point.time)?;
1890            require_indexed_non_negative(
1891                "points",
1892                index,
1893                "velocity_magnitude",
1894                point.velocity_magnitude,
1895            )?;
1896            require_indexed_non_negative("points", index, "kinetic_energy", point.kinetic_energy)?;
1897        }
1898
1899        if let Some(samples) = &result.sampled_points {
1900            for (index, sample) in samples.iter().enumerate() {
1901                require_indexed_finite("sampled_points", index, "distance_m", sample.distance_m)?;
1902                require_indexed_finite("sampled_points", index, "drop_m", sample.drop_m)?;
1903                require_indexed_finite(
1904                    "sampled_points",
1905                    index,
1906                    "wind_drift_m",
1907                    sample.wind_drift_m,
1908                )?;
1909                require_indexed_finite(
1910                    "sampled_points",
1911                    index,
1912                    "velocity_mps",
1913                    sample.velocity_mps,
1914                )?;
1915                require_indexed_finite("sampled_points", index, "energy_j", sample.energy_j)?;
1916                require_indexed_finite("sampled_points", index, "time_s", sample.time_s)?;
1917            }
1918        }
1919
1920        for (name, value) in [
1921            ("min_pitch_damping", result.min_pitch_damping),
1922            ("transonic_mach", result.transonic_mach),
1923            ("max_yaw_angle", result.max_yaw_angle),
1924            ("max_precession_angle", result.max_precession_angle),
1925        ] {
1926            if let Some(value) = value {
1927                require_finite(name, value)?;
1928            }
1929        }
1930
1931        if let Some(state) = result.angular_state {
1932            for (name, value) in [
1933                ("angular_state.pitch_angle", state.pitch_angle),
1934                ("angular_state.yaw_angle", state.yaw_angle),
1935                ("angular_state.pitch_rate", state.pitch_rate),
1936                ("angular_state.yaw_rate", state.yaw_rate),
1937                ("angular_state.precession_angle", state.precession_angle),
1938                ("angular_state.nutation_phase", state.nutation_phase),
1939            ] {
1940                require_finite(name, value)?;
1941            }
1942        }
1943
1944        if let Some(jump) = result.aerodynamic_jump {
1945            for (name, value) in [
1946                ("aerodynamic_jump.vertical_jump_moa", jump.vertical_jump_moa),
1947                (
1948                    "aerodynamic_jump.horizontal_jump_moa",
1949                    jump.horizontal_jump_moa,
1950                ),
1951                ("aerodynamic_jump.jump_angle_rad", jump.jump_angle_rad),
1952                (
1953                    "aerodynamic_jump.magnus_component_moa",
1954                    jump.magnus_component_moa,
1955                ),
1956                ("aerodynamic_jump.yaw_component_moa", jump.yaw_component_moa),
1957                (
1958                    "aerodynamic_jump.stabilization_factor",
1959                    jump.stabilization_factor,
1960                ),
1961            ] {
1962                require_finite(name, value)?;
1963            }
1964        }
1965
1966        Ok(())
1967    }
1968
1969    /// Integration methods store the pre-step state in `points`. Validate each newly accepted
1970    /// state as well, otherwise a poisoned final step could terminate the loop and leave only the
1971    /// previous finite point in an apparently successful result.
1972    ///
1973    /// Beyond finiteness, an accepted state must respect the physical speed budget: drag is
1974    /// dissipative (it drives the projectile toward the wind frame, never past it), Magnus and
1975    /// Coriolis act perpendicular to the velocity and do no work, and gravity adds at most g*t.
1976    /// Ground-frame speed therefore cannot legitimately exceed muzzle speed + strongest wind +
1977    /// g*t. Exceeding that budget means the integrator itself diverged — for stiff inputs the
1978    /// minimum-step RK45 acceptance can multiply speed by orders of magnitude in one step
1979    /// (MBA-1293: 13x and a sign reversal in a single 1 microsecond step) — so the solve must
1980    /// fail rather than report the garbage as `Ok`.
1981    fn validate_integration_state(
1982        &self,
1983        position: &Vector3<f64>,
1984        velocity: &Vector3<f64>,
1985        time: f64,
1986    ) -> Result<(), BallisticsError> {
1987        if !(position.iter().all(|value| value.is_finite())
1988            && velocity.iter().all(|value| value.is_finite())
1989            && time.is_finite())
1990        {
1991            return Err(BallisticsError::from(
1992                "trajectory integration produced a non-finite state (often from physically \
1993                 extreme inputs — e.g. an absurd bore/muzzle height placing the launch far \
1994                 from sea level, or a degenerate atmosphere; check those inputs, or set \
1995                 --altitude explicitly)",
1996            ));
1997        }
1998
1999        let speed = velocity.magnitude();
2000        let budget = self.speed_budget(time);
2001        if speed > budget {
2002            return Err(BallisticsError::from(format!(
2003                "trajectory integration diverged: speed {speed:.3e} m/s at t={time:.6}s exceeds \
2004                 the physical budget of {budget:.3e} m/s"
2005            )));
2006        }
2007        Ok(())
2008    }
2009
2010    /// Ceiling on ground-frame speed a physical trajectory can reach by time `t` (see
2011    /// [`Self::validate_integration_state`]). The factor-2 slack absorbs boundary-layer
2012    /// wind-shear amplification and integrator transients; genuine divergence clears the
2013    /// budget by orders of magnitude.
2014    fn speed_budget(&self, time: f64) -> f64 {
2015        let scalar_wind = self.wind.speed.abs() + self.wind.vertical_speed.abs();
2016        let wind_bound = match &self.wind_sock {
2017            Some(sock) => scalar_wind.max(sock.max_speed_mps()),
2018            None => scalar_wind,
2019        };
2020        2.0 * (self.inputs.muzzle_velocity + wind_bound + 10.0) + self.gravity.magnitude() * time
2021    }
2022
2023    /// Store one public trajectory point, collapsing a downrange stall, without exceeding the
2024    /// per-solve resource budget. `collapsed_steps` is the solve's own count of earlier calls that
2025    /// replaced the previous point instead of storing a new one.
2026    fn push_trajectory_point(
2027        &self,
2028        points: &mut Vec<TrajectoryPoint>,
2029        point: TrajectoryPoint,
2030        collapsed_steps: &mut usize,
2031    ) -> Result<(), BallisticsError> {
2032        // The budget counts calls, not stored points. Every earlier call either stored a point or
2033        // collapsed one, so points.len() + collapsed_steps is the number of earlier calls, which
2034        // is what points.len() was when every call stored a point. The limit therefore fires at
2035        // the same step, with the same message, whether or not the flight stalled (MBA-1599).
2036        if points.len() + *collapsed_steps >= self.max_trajectory_points {
2037            return Err(BallisticsError::from(format!(
2038                "trajectory point limit of {} exceeded",
2039                self.max_trajectory_points
2040            )));
2041        }
2042        // MBA-1599: the public trajectory is indexed by downrange distance, so stored x must
2043        // strictly increase. A drag-dominated flight (the bottom of the BC range, a slow
2044        // bc_segments band, or a long fall to a deep ground_threshold) can shed so much downrange
2045        // speed that a whole step moves x by less than half a ULP: x + vx*dt == x while y keeps
2046        // falling. Two states cannot both be kept at one representable range, so the newer state
2047        // wins, the same rule append_terminal_endpoint applies to the terminal point. The
2048        // collapsed step is still counted against max_trajectory_points above, so a stall that
2049        // outlasts the budget fails at the step where it always did; only the stored points
2050        // shrink. A strict DECREASE is deliberately still pushed: that is a reversal, which the
2051        // checked observation API reports.
2052        if let Some(last) = points.last_mut() {
2053            if point.position.x == last.position.x {
2054                *last = point;
2055                *collapsed_steps += 1;
2056                return Ok(());
2057            }
2058        }
2059        points.push(point);
2060        Ok(())
2061    }
2062
2063    /// Supply downrange-segmented wind. Each segment is `(speed_kmh, angle_deg,
2064    /// until_distance_m)`; the wind for a given downrange distance is the first
2065    /// segment whose `until_distance_m` exceeds it (a step function), and wind is
2066    /// zero beyond the last segment. An empty list clears segmented wind (reverts
2067    /// to the scalar `wind`). The angle convention matches `WindConditions`
2068    /// (0 = headwind, 90 = from the right).
2069    pub fn set_wind_segments(&mut self, segments: Vec<crate::wind::WindSegment>) {
2070        self.wind_sock = if segments.is_empty() {
2071            None
2072        } else {
2073            Some(crate::wind::WindSock::new(segments))
2074        };
2075    }
2076
2077    /// Supply downrange-segmented atmosphere (MBA-1137). Each segment is
2078    /// `(temp_c, pressure_hpa, humidity_percent, until_distance_m)`, defined at the shooter base
2079    /// altitude; the per-substep local-atmosphere recompute selects the active zone by downrange
2080    /// distance (first zone whose `until_distance_m` exceeds it; the last zone is held beyond the
2081    /// final threshold). The zone's base conditions are composed with the vertical altitude lapse
2082    /// via `get_local_atmosphere_humid`, so a steeply-arcing shot still sees the y-lapse on top of
2083    /// the zone base. An empty list clears segmented atmosphere (reverts to the resolved
2084    /// single-station conditions).
2085    pub fn set_atmo_segments(&mut self, segments: Vec<crate::atmosphere::AtmoSegment>) {
2086        self.atmo_sock = if segments.is_empty() {
2087            None
2088        } else {
2089            Some(crate::atmosphere::AtmoSock::new(segments))
2090        };
2091    }
2092
2093    /// Effective initial launch direction `(elevation, azimuth)` in radians, including
2094    /// the aerodynamic-jump muzzle perturbation when `enable_aerodynamic_jump` is set.
2095    ///
2096    /// Aerodynamic jump is the fixed angular departure imparted as the projectile
2097    /// transitions from the constrained bore to free flight; applying it as an initial
2098    /// launch-angle offset is the physically correct integration point. Returns the bare
2099    /// `(muzzle_angle, azimuth_angle)` when the flag is off, so a default solve is
2100    /// numerically identical to pre-feature behavior. (MBA-959)
2101    fn launch_angles_from(
2102        &self,
2103        aj: Option<&crate::aerodynamic_jump::AerodynamicJumpComponents>,
2104    ) -> (f64, f64) {
2105        let (mut elev, mut azim) = (self.inputs.muzzle_angle, self.inputs.azimuth_angle);
2106        // MBA-1286: cant rotates the sight-frame aim offsets about the line of sight.
2107        // Positive = clockwise from the shooter: the upward zero correction leaks right
2108        // (+z) and shrinks by cos(cant) -> POI right and low. Exactly-0.0 skips all float
2109        // ops so un-canted solves stay bit-identical. Aerodynamic jump is added AFTER the
2110        // rotation: it arises at bore exit from crosswind/spin in the ground frame, not
2111        // from the rifle's sight geometry.
2112        if self.inputs.cant_angle != 0.0 {
2113            let (sin_c, cos_c) = self.inputs.cant_angle.sin_cos();
2114            let (e0, a0) = (elev, azim);
2115            elev = e0 * cos_c - a0 * sin_c;
2116            azim = a0 * cos_c + e0 * sin_c;
2117        }
2118        match aj {
2119            Some(c) => {
2120                // vertical_/horizontal_jump_moa ARE the jump angles expressed in MOA.
2121                const MOA_PER_RAD: f64 = 3437.7467707849;
2122                (
2123                    elev + c.vertical_jump_moa / MOA_PER_RAD,
2124                    azim + c.horizontal_jump_moa / MOA_PER_RAD,
2125                )
2126            }
2127            None => (elev, azim),
2128        }
2129    }
2130
2131    /// Compute the aerodynamic-jump components for the current inputs, or `None` when the
2132    /// feature is disabled / inputs are degenerate.
2133    ///
2134    /// Uses Bryan Litz's crosswind aerodynamic-jump estimator
2135    /// (`Y = 0.01*Sg - 0.0024*L + 0.032` MOA/mph) fed by the engine's own Miller Sg.
2136    /// Aerodynamic jump is a vertical effect, so only the elevation is perturbed.
2137    /// The estimator is a regression best near Sg ~ 1.75 — see MBA-959.
2138    fn aerodynamic_jump_components(
2139        &self,
2140    ) -> Option<crate::aerodynamic_jump::AerodynamicJumpComponents> {
2141        if !self.inputs.enable_aerodynamic_jump {
2142            return None;
2143        }
2144        // Reject degenerate/non-finite inputs before they can reach the launch angle.
2145        // A bare `<= 0.0` test lets NaN through (NaN comparisons are always false), and a
2146        // NaN/Inf here would poison the muzzle angle and collapse the whole trajectory.
2147        let diameter_m = self.inputs.bullet_diameter;
2148        if !(self.inputs.twist_rate.is_finite()
2149            && self.inputs.twist_rate != 0.0
2150            && diameter_m.is_finite()
2151            && diameter_m > 0.0
2152            && self.inputs.bullet_length.is_finite()
2153            && self.inputs.bullet_length > 0.0
2154            && self.inputs.muzzle_velocity.is_finite())
2155        {
2156            return None;
2157        }
2158
2159        // Engine's own gyroscopic (Miller) stability factor — same Sg shown elsewhere.
2160        let (_, _, temp_c, pressure_hpa) = self.resolved_atmosphere();
2161        let sg = crate::stability::compute_stability_coefficient(
2162            &self.inputs,
2163            (self.atmosphere.altitude, temp_c, pressure_hpa, 0.0),
2164        );
2165        if !(sg.is_finite() && sg > 0.0) {
2166            return None;
2167        }
2168        let length_calibers = self.inputs.bullet_length / diameter_m;
2169
2170        // Crosswind-from-the-right (mph) for Litz's estimator. Wind direction uses the
2171        // wind-FROM convention (0 = headwind, +90deg = from the right), matching the
2172        // fast-integrate path (fast_trajectory::aerodynamic_jump_launch_offset_rad) and
2173        // the lateral windage sign, so a from-the-right wind on a right-twist barrel
2174        // jumps the impact UP and drifts it left.
2175        const MS_TO_MPH: f64 = 2.236_936_292_054_4;
2176        let crosswind_from_right_mps = if let Some(sock) = &self.wind_sock {
2177            -sock.vector_for_range_stateless(0.0)[2]
2178        } else {
2179            self.wind.speed * self.wind.direction.sin()
2180        };
2181        let crosswind_from_right_mph = crosswind_from_right_mps * MS_TO_MPH;
2182
2183        let vertical_jump_moa = crate::aerodynamic_jump::litz_crosswind_jump_moa(
2184            sg,
2185            length_calibers,
2186            crosswind_from_right_mph,
2187            self.inputs.is_twist_right,
2188        );
2189        if !vertical_jump_moa.is_finite() {
2190            return None;
2191        }
2192
2193        const MOA_PER_RAD: f64 = 3437.7467707849;
2194        Some(crate::aerodynamic_jump::AerodynamicJumpComponents {
2195            vertical_jump_moa,
2196            // Aerodynamic jump is a vertical effect; the Litz estimator has no horizontal term.
2197            horizontal_jump_moa: 0.0,
2198            jump_angle_rad: vertical_jump_moa.abs() / MOA_PER_RAD,
2199            magnus_component_moa: 0.0,
2200            yaw_component_moa: 0.0,
2201            stabilization_factor: (sg / 1.5).clamp(0.0, 1.0),
2202        })
2203    }
2204
2205    fn resolved_atmosphere(&self) -> (f64, f64, f64, f64) {
2206        // NEITHER is the old legacy-sentinel arm exactly, BOTH the old authoritative arm.
2207        let (temp_c, pressure_hpa) = crate::atmosphere::resolve_station_conditions_with_presence(
2208            self.atmosphere.temperature,
2209            self.atmosphere.pressure,
2210            self.atmosphere.altitude,
2211            PressureReferenceMode::Absolute,
2212            self.station_presence,
2213        );
2214        let (density, speed_of_sound) = crate::atmosphere::calculate_atmosphere(
2215            self.atmosphere.altitude,
2216            Some(temp_c),
2217            Some(pressure_hpa),
2218            self.atmosphere.humidity,
2219        );
2220        (density, speed_of_sound, temp_c, pressure_hpa)
2221    }
2222
2223    fn precession_nutation_params(
2224        &self,
2225        velocity_mps: f64,
2226        air_density_kg_m3: f64,
2227        speed_of_sound_mps: f64,
2228    ) -> PrecessionNutationParams {
2229        let (spin_inertia, transverse_inertia) = self.precession_nutation_inertias;
2230        let spin_rate_rad_s = if self.inputs.twist_rate > 0.0 {
2231            let velocity_fps = velocity_mps * 3.28084;
2232            let twist_rate_ft = self.inputs.twist_rate / 12.0;
2233            (velocity_fps / twist_rate_ft) * 2.0 * std::f64::consts::PI
2234        } else {
2235            0.0
2236        };
2237
2238        PrecessionNutationParams {
2239            mass_kg: self.inputs.bullet_mass,
2240            caliber_m: self.inputs.bullet_diameter,
2241            length_m: self.inputs.bullet_length,
2242            spin_rate_rad_s,
2243            spin_inertia,
2244            transverse_inertia,
2245            velocity_mps,
2246            air_density_kg_m3,
2247            mach: velocity_mps / speed_of_sound_mps,
2248            pitch_damping_coeff: PitchDampingCoefficients::default().subsonic,
2249            nutation_damping_factor: 0.05,
2250        }
2251    }
2252
2253    /// Append the exact state at the earliest boundary crossed by the final integration step.
2254    ///
2255    /// Each solver stores its pre-step state. Keeping only that point makes early ground and time
2256    /// exits indistinguishable from ordinary integration knots, and historically left the
2257    /// reported endpoint one step short. Interpolating all supported boundaries here gives every
2258    /// solver one explicit terminal point and one authoritative termination reason.
2259    /// `collapsed_steps` is the solve's running count from push_trajectory_point, so a stored
2260    /// terminal point is checked against the same budget as every integration step.
2261    fn append_terminal_endpoint(
2262        &self,
2263        points: &mut Vec<TrajectoryPoint>,
2264        post_position: Vector3<f64>,
2265        post_velocity: Vector3<f64>,
2266        post_time: f64,
2267        max_height: &mut f64,
2268        collapsed_steps: &mut usize,
2269    ) -> Result<TrajectoryTermination, BallisticsError> {
2270        let previous = points
2271            .last()
2272            .cloned()
2273            .ok_or_else(|| BallisticsError::from("No trajectory points generated"))?;
2274
2275        let mut crossings = Vec::with_capacity(3);
2276        if previous.position.x < self.max_range && post_position.x >= self.max_range {
2277            let span = post_position.x - previous.position.x;
2278            if span.is_finite() && span > 0.0 {
2279                crossings.push((
2280                    (self.max_range - previous.position.x) / span,
2281                    TrajectoryTermination::MaxRange,
2282                ));
2283            }
2284        }
2285        if self.inputs.ground_threshold.is_finite()
2286            && previous.position.y > self.inputs.ground_threshold
2287            && post_position.y <= self.inputs.ground_threshold
2288        {
2289            let span = post_position.y - previous.position.y;
2290            if span.is_finite() && span < 0.0 {
2291                crossings.push((
2292                    (self.inputs.ground_threshold - previous.position.y) / span,
2293                    TrajectoryTermination::GroundThreshold,
2294                ));
2295            }
2296        }
2297        if previous.time < TRAJECTORY_TIME_LIMIT_S && post_time >= TRAJECTORY_TIME_LIMIT_S {
2298            let span = post_time - previous.time;
2299            if span.is_finite() && span > 0.0 {
2300                crossings.push((
2301                    (TRAJECTORY_TIME_LIMIT_S - previous.time) / span,
2302                    TrajectoryTermination::TimeLimit,
2303                ));
2304            }
2305        }
2306
2307        let (fraction, termination) = crossings
2308            .into_iter()
2309            .filter(|(fraction, _)| fraction.is_finite() && (0.0..=1.0).contains(fraction))
2310            .min_by(|left, right| {
2311                let priority = |termination: TrajectoryTermination| match termination {
2312                    TrajectoryTermination::GroundThreshold => 0,
2313                    TrajectoryTermination::MaxRange => 1,
2314                    TrajectoryTermination::TimeLimit => 2,
2315                    TrajectoryTermination::VelocityFloor => 3,
2316                };
2317                left.0
2318                    .total_cmp(&right.0)
2319                    .then_with(|| priority(left.1).cmp(&priority(right.1)))
2320            })
2321            .ok_or_else(|| {
2322                BallisticsError::from(
2323                    "trajectory integration stopped without crossing a supported boundary",
2324                )
2325            })?;
2326
2327        let mut position = previous.position + (post_position - previous.position) * fraction;
2328        match termination {
2329            TrajectoryTermination::MaxRange => position.x = self.max_range,
2330            TrajectoryTermination::GroundThreshold => {
2331                position.y = self.inputs.ground_threshold;
2332            }
2333            TrajectoryTermination::TimeLimit | TrajectoryTermination::VelocityFloor => {}
2334        }
2335        let velocity_magnitude = previous.velocity_magnitude
2336            + (post_velocity.magnitude() - previous.velocity_magnitude) * fraction;
2337        let mut time = previous.time + (post_time - previous.time) * fraction;
2338        if termination == TrajectoryTermination::TimeLimit {
2339            time = TRAJECTORY_TIME_LIMIT_S;
2340        }
2341        let kinetic_energy =
2342            0.5 * self.inputs.bullet_mass * velocity_magnitude * velocity_magnitude;
2343
2344        if position.y > *max_height {
2345            *max_height = position.y;
2346        }
2347        let terminal_point = TrajectoryPoint {
2348            time,
2349            position,
2350            velocity_magnitude,
2351            kinetic_energy,
2352            drag_coefficient: None,
2353        };
2354        if terminal_point.position.x < previous.position.x {
2355            return Err(BallisticsError::from(
2356                "trajectory terminal state reversed downrange before the crossed boundary",
2357            ));
2358        }
2359        if terminal_point.position.x == previous.position.x {
2360            // A very early ground/time crossing can be distinct in time but less than one ULP
2361            // downrange. There is no representable range at which to retain both states, so make
2362            // the terminal state authoritative instead of creating a duplicate-X trajectory that
2363            // the checked observation API must reject (see push_trajectory_point).
2364            let last = points.last_mut().ok_or_else(|| {
2365                BallisticsError::from("trajectory points disappeared during terminal finalization")
2366            })?;
2367            *last = terminal_point;
2368        } else {
2369            self.push_trajectory_point(points, terminal_point, collapsed_steps)?;
2370        }
2371        Ok(termination)
2372    }
2373
2374    fn gravity_acceleration(&self) -> Vector3<f64> {
2375        self.gravity.in_frame(self.inputs.shooting_angle)
2376    }
2377
2378    /// The boundary-layer model this solver applies, or `None` when shear is off: the flag is
2379    /// clear, or the model name is `"none"`, which every kernel and front-end reads as no shear.
2380    ///
2381    /// Names match wind_shear::get_wind_at_position (MBA-965). An unrecognized name falls back
2382    /// to the power law, the historical "exponential" behaviour that the Python and Ruby
2383    /// bindings document for their dict and hash inputs; the CLI, WASM and solve-json front-ends
2384    /// parse an enum, so none of them can send one. `"none"` used to fall into that fallback
2385    /// too, so asking for no model applied the power law, while `fast_integrate` and the RK45
2386    /// kernel applied nothing.
2387    fn boundary_layer_shear_model(&self) -> Option<WindShearModel> {
2388        if !self.inputs.enable_wind_shear {
2389            return None;
2390        }
2391        match self.inputs.wind_shear_model.as_str() {
2392            "none" => None,
2393            "logarithmic" => Some(WindShearModel::Logarithmic),
2394            "power_law" | "powerlaw" | "exponential" => Some(WindShearModel::PowerLaw),
2395            "ekman_spiral" | "ekman" => Some(WindShearModel::EkmanSpiral),
2396            "custom_layers" | "custom" => Some(WindShearModel::CustomLayers),
2397            _ => Some(WindShearModel::PowerLaw),
2398        }
2399    }
2400
2401    fn get_wind_at_altitude(&self, height_agl_m: f64, model: WindShearModel) -> Vector3<f64> {
2402        // Scale the operative surface wind by the boundary-layer multiplier. `height_agl_m` is
2403        // the bullet's height above the GROUND, from wind_shear::shear_height_agl_m: its
2404        // shot-frame y (measured perpendicular to the line of sight from the level
2405        // `muzzle_height` is measured from), plus 0 when `muzzle_height` is set or the assumed
2406        // 1.5 m bore height when it is left at 0. The ground is the one the ground_threshold
2407        // check uses, parallel to the line of sight, so an inclined shot's ground follows its
2408        // slope. The multiplier is floored at 1.0, so flat-fire trajectories keep ~full wind and
2409        // only high-arcing shots see increased wind.
2410        //
2411        // We build the vector with THIS solver's non-shear sign convention (X=-cos, Z=-sin; see
2412        // the `wind_vector` used in solve_rk4/solve_euler, matching WindSock) and scale it, so that
2413        // "shear on" equals "shear off" * ratio (ratio == 1.0 for flat fire). An earlier revision
2414        // attenuated the wind near the line of sight and flipped its sign relative to the non-shear
2415        // path; this keeps them sign-consistent.
2416        let speed_ratio = crate::wind_shear::boundary_layer_speed_ratio_agl(height_agl_m, model);
2417
2418        // 0deg = headwind, 90deg = from the right (McCoy wind-FROM convention, matching
2419        // WindConditions / WindSock); wind enters drag via velocity - wind.
2420        //
2421        // MBA-728: the horizontal vector is built with vertical=0.0 and scaled by speed_ratio,
2422        // then wind.vertical_speed is added back UNSCALED — boundary-layer shear scales
2423        // horizontal wind only, vertical passes through as-is.
2424        crate::wind::wind_vector(self.wind.speed, self.wind.direction, 0.0) * speed_ratio
2425            + Vector3::new(0.0, self.wind.vertical_speed, 0.0)
2426    }
2427
2428    pub fn solve(&self) -> Result<TrajectoryResult, BallisticsError> {
2429        self.validate_for_solve()?;
2430        let mut result = if self.inputs.use_rk4 {
2431            if self.inputs.use_adaptive_rk45 {
2432                self.solve_rk45()?
2433            } else {
2434                self.solve_rk4()?
2435            }
2436        } else {
2437            self.solve_euler()?
2438        };
2439        self.apply_spin_drift(&mut result);
2440        self.validate_result_sanity(&result)?;
2441        Ok(result)
2442    }
2443
2444    /// Gyroscopic spin drift via the empirical Litz model, applied in the engine
2445    /// (not the WASM formatter) so it covers Euler/RK4/RK45 and all consumers.
2446    /// Uses the canonical SI fields and converts to grains/inches correctly,
2447    /// avoiding the kg/m-vs-grains/in unit bug in `calculate_enhanced_spin_drift`.
2448    /// Frame (McCoy): Z = lateral (windage), so drift adds to `position.z`.
2449    fn apply_spin_drift(&self, result: &mut TrajectoryResult) {
2450        if !self.inputs.use_enhanced_spin_drift {
2451            return;
2452        }
2453        let d_in = self.inputs.bullet_diameter / 0.0254; // m -> in
2454        let m_gr = self.inputs.bullet_mass / crate::constants::GRAINS_TO_KG; // kg -> grains
2455        let twist_in = self.inputs.twist_rate; // inches/turn
2456        if d_in <= 0.0 || m_gr <= 0.0 || twist_in <= 0.0 {
2457            return;
2458        }
2459
2460        // MBA-1134 (rank 31): single source of truth for the muzzle Sg —
2461        // stability::compute_stability_coefficient via spin_drift::effective_sg_from_inputs. This
2462        // ADDS the (v/2800)^(1/3) muzzle-velocity term the bare miller_stability() lacked, so the
2463        // spin-drift Sg now matches the reported SG and the aerodynamic-jump Sg. The linear Miller
2464        // density correction ((T/T0)*(P0/P), a no-op at sea-level standard) and the 4.5-caliber
2465        // length fallback are handled inside effective_sg_from_inputs.
2466        let sg = self.effective_spin_drift_sg();
2467
2468        for p in result.points.iter_mut() {
2469            if p.time <= 0.0 {
2470                continue;
2471            }
2472            // Canonical Litz drift, shared with the fast / Monte-Carlo path (spin_drift::litz_*).
2473            p.position.z +=
2474                crate::spin_drift::litz_drift_meters(sg, p.time, self.inputs.is_twist_right);
2475        }
2476
2477        // sampled_points are snapshotted from the PRE-drift trajectory inside each solver, so the
2478        // sampled wind_drift_m column would omit the spin drift that result.points carry. Apply
2479        // the same canonical Litz drift to keep the two user-facing outputs consistent.
2480        if let Some(samples) = result.sampled_points.as_mut() {
2481            for s in samples.iter_mut() {
2482                if s.time_s <= 0.0 {
2483                    continue;
2484                }
2485                s.wind_drift_m +=
2486                    crate::spin_drift::litz_drift_meters(sg, s.time_s, self.inputs.is_twist_right);
2487            }
2488        }
2489    }
2490
2491    /// Muzzle gyroscopic stability Sg used by the empirical Litz spin-drift post-process
2492    /// (MBA-1134). Extracted so the exact value is unit-testable and provably identical to the Sg
2493    /// the fast / Monte-Carlo path uses — both go through
2494    /// [`crate::spin_drift::effective_sg_from_inputs`] with the resolved muzzle atmosphere.
2495    fn effective_spin_drift_sg(&self) -> f64 {
2496        let (_, _, temp_c, press_hpa) = self.resolved_atmosphere();
2497        crate::spin_drift::effective_sg_from_inputs(&self.inputs, temp_c, press_hpa)
2498    }
2499
2500    /// Bore muzzle position at t=0 (bore-origin frame, `muzzle_height` above ground).
2501    /// With cant the rifle rotates about the LINE OF SIGHT, so the bore — sight_height
2502    /// below the sight — swings laterally by `-sight_height*sin(cant)` (left of the aim
2503    /// plane for clockwise cant) and rises by `sight_height*(1-cos(cant))` toward the
2504    /// pivot. (MBA-1286)
2505    ///
2506    /// A lateral sight-mount offset (MBA-1396) additionally displaces the bore
2507    /// `sight_offset_lateral_m` from the LOS (a sight mounted right of the bore puts the
2508    /// bore left of the sight line). Cant rotates the WHOLE bore-to-sight displacement
2509    /// vector rigidly about the LOS, so the vertical drop `-sight_height` and the lateral
2510    /// offset `-sight_offset_lateral_m` couple: rotating `(u, w) = (-sh, -off)` by the
2511    /// cant angle gives `y += sh*(1-cos) + off*sin` and `z = -sh*sin - off*cos`. Both the
2512    /// cant-only case (off = 0) and the offset-only case (cos = 1, sin = 0) collapse to
2513    /// the historical terms, so only a simultaneously canted AND offset rifle sees the
2514    /// coupling. The windage-zero convergence that makes the trajectory cross the LOS at
2515    /// the zero range lives in `windage_zero_bias_rad`, not here. Exactly-0.0 cant AND
2516    /// offset return the historical position (bit-identical).
2517    fn initial_position(&self) -> Vector3<f64> {
2518        if self.inputs.cant_angle == 0.0 && self.inputs.sight_offset_lateral_m == 0.0 {
2519            return Vector3::new(0.0, self.inputs.muzzle_height, 0.0);
2520        }
2521        let (sin_c, cos_c) = self.inputs.cant_angle.sin_cos();
2522        let sh = self.inputs.sight_height;
2523        let off = self.inputs.sight_offset_lateral_m;
2524        Vector3::new(
2525            0.0,
2526            self.inputs.muzzle_height + sh * (1.0 - cos_c) + off * sin_c,
2527            -sh * sin_c - off * cos_c,
2528        )
2529    }
2530
2531    /// Shared post-integration sampling used by all three integrators (MBA-1403).
2532    ///
2533    /// Builds the `TrajectoryData`/`TrajectoryOutputs` pair and runs
2534    /// [`sample_trajectory`] when `enable_trajectory_sampling` is set. This is the
2535    /// single place the sampler's LOS datum (`target_vertical_height_m`) is chosen,
2536    /// replacing three per-integrator copies that had to be edited in parallel.
2537    fn build_sampled_points(
2538        &self,
2539        points: &[TrajectoryPoint],
2540        max_height: f64,
2541        transonic_distances: Vec<f64>,
2542        mach_transitions: &MachTransitionTracker,
2543    ) -> Result<Option<Vec<TrajectorySample>>, BallisticsError> {
2544        if !self.inputs.enable_trajectory_sampling {
2545            return Ok(None);
2546        }
2547
2548        let last_point = points.last().ok_or("No trajectory points generated")?;
2549        let trajectory_data = TrajectoryData {
2550            times: points.iter().map(|p| p.time).collect(),
2551            positions: points.iter().map(|p| p.position).collect(),
2552            velocities: points
2553                .iter()
2554                .map(|p| {
2555                    // Reconstruct velocity vectors from magnitude (approximate)
2556                    Vector3::new(0.0, 0.0, p.velocity_magnitude)
2557                })
2558                .collect(),
2559            transonic_distances, // populated by the integrator at each Mach-threshold crossing
2560            mach_1_2_distance_m: mach_transitions.mach_1_2_distance_m,
2561            mach_1_0_distance_m: mach_transitions.mach_1_0_distance_m,
2562            mach_0_9_distance_m: mach_transitions.mach_0_9_distance_m,
2563        };
2564
2565        // For LOS calculation in ground-referenced coordinates:
2566        // sight_position_m is the sight's actual y-position above ground
2567        // (muzzle_height + sight_height, not just sight_height)
2568        // For flat shots, target is at same height as the sight (horizontal LOS)
2569        let sight_position_m = self.inputs.muzzle_height + self.inputs.sight_height;
2570        let target_reference = self.inputs.drops_reference == DropsReference::Target;
2571        // MBA-1403: only the (new, non-default) target mode reads `target_height` here,
2572        // and only when a real (non-zero) height was supplied — the sampler's LOS then
2573        // slopes toward the actual target instead of staying at the sight height. LOS
2574        // mode keeps the historical datum byte-identically, and zero-solve
2575        // `target_height` semantics (an explicit function argument, never this field)
2576        // are untouched.
2577        let target_vertical_height_m = if target_reference && self.inputs.target_height != 0.0 {
2578            self.inputs.target_height
2579        } else {
2580            sight_position_m
2581        };
2582        let outputs = TrajectoryOutputs {
2583            target_distance_horiz_m: last_point.position.x, // X is downrange
2584            target_vertical_height_m,
2585            time_of_flight_s: last_point.time,
2586            max_ord_dist_horiz_m: max_height,
2587            sight_height_m: sight_position_m,
2588        };
2589
2590        // Sample at specified intervals
2591        let mut samples = sample_trajectory(
2592            &trajectory_data,
2593            &outputs,
2594            self.inputs.sample_interval,
2595            self.inputs.bullet_mass,
2596        )?;
2597
2598        // MBA-1403 target-plane reference: scale the LOS-perpendicular drop to vertical
2599        // in the target plane. Scaling by the positive constant 1/cos preserves signs
2600        // and ordering, so the sampler's zero-crossing/apex flags are unaffected.
2601        // validate_for_solve rejects target mode at |shooting_angle| >= 90 degrees, so
2602        // cos_theta is strictly positive here. Downstream mil/moa conversions derive
2603        // from drop_m, so they inherit the transform without further edits.
2604        if target_reference {
2605            let cos_theta = self.inputs.shooting_angle.cos();
2606            for sample in &mut samples {
2607                sample.drop_m /= cos_theta;
2608            }
2609        }
2610        Ok(Some(samples))
2611    }
2612
2613    fn solve_euler(&self) -> Result<TrajectoryResult, BallisticsError> {
2614        // Simple trajectory integration using Euler method
2615        let mut time = 0.0;
2616        // Bullet starts at the BORE position, which is muzzle_height above ground
2617        // The sight is sight_height ABOVE the bore, so we don't add sight_height here
2618        // cant-adjusted via initial_position (MBA-1286)
2619        let mut position = self.initial_position();
2620        // Calculate initial velocity components with both elevation and azimuth
2621        // McCoy coordinate system: X=downrange, Y=vertical, Z=lateral (right)
2622        // Launch direction includes the aerodynamic-jump muzzle perturbation when enabled
2623        // (a no-op returning the bare muzzle/azimuth angles otherwise). MBA-959. Computed
2624        // once here and reused for the result so it isn't evaluated twice per solve.
2625        let aj_components = self.aerodynamic_jump_components();
2626        let (launch_elev, launch_azim) = self.launch_angles_from(aj_components.as_ref());
2627        let horizontal_velocity = self.inputs.muzzle_velocity * launch_elev.cos();
2628        let mut velocity = Vector3::new(
2629            horizontal_velocity * launch_azim.cos(), // X: downrange (forward)
2630            self.inputs.muzzle_velocity * launch_elev.sin(), // Y: vertical component
2631            horizontal_velocity * launch_azim.sin(), // Z: lateral (side deviation)
2632        );
2633
2634        let mut points = Vec::new();
2635        // Steps push_trajectory_point collapsed onto the previous point; they still count
2636        // against the point budget (MBA-1599).
2637        let mut collapsed_steps = 0;
2638        let mut max_height = position.y;
2639        let mut min_pitch_damping = f64::INFINITY; // Track minimum pitch damping coefficient
2640        let mut transonic_mach = None; // Track when we enter transonic
2641                                       // Downrange distances where the projectile crosses Mach 1.2 (transonic) then Mach 1.0
2642                                       // (subsonic), so the sampled trajectory output can flag those transitions
2643                                       // (trajectory_sampling::add_trajectory_flags consumes this).
2644        let mut transonic_distances: Vec<f64> = Vec::new();
2645        let mut mach_transitions = MachTransitionTracker::default();
2646
2647        // Initialize angular state for precession/nutation tracking
2648        let mut angular_state = if self.inputs.enable_precession_nutation {
2649            Some(AngularState {
2650                pitch_angle: 0.001, // Small initial disturbance
2651                yaw_angle: 0.001,
2652                pitch_rate: 0.0,
2653                yaw_rate: 0.0,
2654                precession_angle: 0.0,
2655                nutation_phase: 0.0,
2656            })
2657        } else {
2658            None
2659        };
2660        let mut max_yaw_angle = 0.0;
2661        let mut max_precession_angle = 0.0;
2662
2663        // Calculate air density
2664        let (air_density, speed_of_sound, resolved_temp_c, resolved_press_hpa) =
2665            self.resolved_atmosphere();
2666        // MBA-1136 (rank 30): base density RATIO for the local-altitude atmosphere recompute done
2667        // per-substep inside calculate_acceleration. The `air_density` / `speed_of_sound` above
2668        // stay the frozen station values, still used for the Mach-transition, pitch-damping and
2669        // precession/nutation diagnostics (which are intentionally referenced to station Mach).
2670        let base_ratio = air_density / 1.225;
2671
2672        // Wind vector (McCoy): X=downrange (head/tail wind), Y=0, Z=lateral (crosswind)
2673        // 0deg = headwind, 90deg = from the right (McCoy wind-FROM convention, matching
2674        // WindSock); wind enters drag via velocity - wind. Used when no segmented wind.
2675        // MBA-728: no shear/no segments here, so vertical_speed passes straight through
2676        // (there is no horizontal-only scaling step on this path).
2677        let wind_vector = crate::wind::wind_vector(
2678            self.wind.speed,
2679            self.wind.direction,
2680            self.wind.vertical_speed,
2681        );
2682
2683        // Pitch-damping coefficients depend only on the (constant) bullet_model; compute once
2684        // instead of re-deriving them (with a to_lowercase alloc) every integration step.
2685        let pitch_coeffs = PitchDampingCoefficients::from_bullet_type(
2686            self.inputs.bullet_model.as_deref().unwrap_or("default"),
2687        );
2688
2689        // Main integration loop (X is downrange)
2690        while position.x < self.max_range
2691            && position.y > self.inputs.ground_threshold
2692            && time < TRAJECTORY_TIME_LIMIT_S
2693        {
2694            // Store trajectory point
2695            let velocity_magnitude = velocity.magnitude();
2696            let kinetic_energy =
2697                0.5 * self.inputs.bullet_mass * velocity_magnitude * velocity_magnitude;
2698
2699            self.push_trajectory_point(
2700                &mut points,
2701                TrajectoryPoint {
2702                    time,
2703                    position,
2704                    velocity_magnitude,
2705                    kinetic_energy,
2706                    drag_coefficient: None,
2707                },
2708                &mut collapsed_steps,
2709            )?;
2710
2711            // Record Mach-transition distances (constant sea-level speed of sound, matching the
2712            // transonic_mach tracking). Each threshold is recorded once, in descending order.
2713            {
2714                let mach_here = if speed_of_sound > 0.0 {
2715                    velocity_magnitude / speed_of_sound
2716                } else {
2717                    0.0
2718                };
2719                mach_transitions.record_downward_crossings(
2720                    mach_here,
2721                    position.x,
2722                    &mut transonic_distances,
2723                );
2724            }
2725
2726            // Track max height
2727            if position.y > max_height {
2728                max_height = position.y;
2729            }
2730
2731            // Calculate pitch damping if enabled
2732            if self.inputs.enable_pitch_damping {
2733                let mach = velocity_magnitude / speed_of_sound;
2734
2735                // Track when we enter transonic
2736                if transonic_mach.is_none() && mach < 1.2 && mach > 0.8 {
2737                    transonic_mach = Some(mach);
2738                }
2739
2740                // Calculate pitch damping coefficient
2741                let pitch_damping = calculate_pitch_damping_coefficient(mach, &pitch_coeffs);
2742
2743                // Track minimum (most critical for stability)
2744                if pitch_damping < min_pitch_damping {
2745                    min_pitch_damping = pitch_damping;
2746                }
2747            }
2748
2749            // Calculate precession/nutation if enabled
2750            if self.inputs.enable_precession_nutation {
2751                if let Some(ref mut state) = angular_state {
2752                    let velocity_magnitude = velocity.magnitude();
2753                    let params = self.precession_nutation_params(
2754                        velocity_magnitude,
2755                        air_density,
2756                        speed_of_sound,
2757                    );
2758
2759                    // Update angular state
2760                    *state = calculate_combined_angular_motion(
2761                        &params,
2762                        state,
2763                        time,
2764                        self.time_step,
2765                        0.001, // Initial disturbance
2766                    );
2767
2768                    // Track maximums
2769                    if state.yaw_angle.abs() > max_yaw_angle {
2770                        max_yaw_angle = state.yaw_angle.abs();
2771                    }
2772                    if state.precession_angle.abs() > max_precession_angle {
2773                        max_precession_angle = state.precession_angle.abs();
2774                    }
2775                }
2776            }
2777
2778            // Use the same acceleration kernel as RK4/RK45 so all three solvers share ONE drag
2779            // model. solve_euler previously used a bespoke frontal-area drag (0.5*rho*Cd*A*v^2/m)
2780            // that IGNORED the ballistic coefficient entirely (diverging up to ~2.3x from the
2781            // BC-retardation RK4/RK45 path), and also omitted the Magnus/Coriolis terms.
2782            // calculate_acceleration applies BC-retardation drag, gravity, Coriolis, Magnus, wind
2783            // shear, and the zero-relative-velocity gravity-only guard.
2784            let acceleration = self.calculate_acceleration(
2785                &position,
2786                &velocity,
2787                &wind_vector,
2788                (resolved_temp_c, resolved_press_hpa, base_ratio),
2789            );
2790
2791            // Update state
2792            velocity += acceleration * self.time_step;
2793            position += velocity * self.time_step;
2794            time += self.time_step;
2795            self.validate_integration_state(&position, &velocity, time)?;
2796        }
2797
2798        let termination = self.append_terminal_endpoint(
2799            &mut points,
2800            position,
2801            velocity,
2802            time,
2803            &mut max_height,
2804            &mut collapsed_steps,
2805        )?;
2806
2807        // Get final values
2808        // MBA-1423: fill the per-point Cd here, once, rather than inside each integrator loop.
2809        // Every solver family reaches this line, so none can leave the field empty while the
2810        // others populate it. Must precede the `last_point` borrow below.
2811        self.annotate_drag_coefficients(&mut points, speed_of_sound);
2812
2813        let last_point = points.last().ok_or("No trajectory points generated")?;
2814
2815        // Create trajectory sampling data if enabled (shared helper, MBA-1403)
2816        let sampled_points =
2817            self.build_sampled_points(&points, max_height, transonic_distances, &mach_transitions)?;
2818
2819        Ok(TrajectoryResult {
2820            max_range: last_point.position.x, // X is downrange
2821            max_height,
2822            time_of_flight: last_point.time,
2823            impact_velocity: last_point.velocity_magnitude,
2824            impact_energy: last_point.kinetic_energy,
2825            projectile_mass_kg: self.inputs.bullet_mass,
2826            line_of_sight_height_m: self.inputs.muzzle_height + self.inputs.sight_height,
2827            station_speed_of_sound_mps: speed_of_sound,
2828            termination,
2829            points,
2830            sampled_points,
2831            min_pitch_damping: if self.inputs.enable_pitch_damping {
2832                Some(min_pitch_damping)
2833            } else {
2834                None
2835            },
2836            transonic_mach,
2837            angular_state,
2838            max_yaw_angle: if self.inputs.enable_precession_nutation {
2839                Some(max_yaw_angle)
2840            } else {
2841                None
2842            },
2843            max_precession_angle: if self.inputs.enable_precession_nutation {
2844                Some(max_precession_angle)
2845            } else {
2846                None
2847            },
2848            aerodynamic_jump: aj_components,
2849            mach_1_2_distance_m: mach_transitions.mach_1_2_distance_m,
2850            mach_1_0_distance_m: mach_transitions.mach_1_0_distance_m,
2851            mach_0_9_distance_m: mach_transitions.mach_0_9_distance_m,
2852        })
2853    }
2854
2855    fn solve_rk4(&self) -> Result<TrajectoryResult, BallisticsError> {
2856        // RK4 trajectory integration for better accuracy
2857        let mut time = 0.0;
2858        // Bullet starts at the BORE position, which is muzzle_height above ground
2859        // The sight is sight_height ABOVE the bore, so we don't add sight_height here
2860        // The sight_height affects the LOS calculation and zero angle, not the starting position
2861        // cant-adjusted via initial_position (MBA-1286)
2862        let mut position = self.initial_position();
2863
2864        // Calculate initial velocity components with both elevation and azimuth
2865        // McCoy coordinate system: X=downrange, Y=vertical, Z=lateral (right)
2866        // Launch direction includes the aerodynamic-jump muzzle perturbation when enabled
2867        // (a no-op returning the bare muzzle/azimuth angles otherwise). MBA-959. Computed
2868        // once here and reused for the result so it isn't evaluated twice per solve.
2869        let aj_components = self.aerodynamic_jump_components();
2870        let (launch_elev, launch_azim) = self.launch_angles_from(aj_components.as_ref());
2871        let horizontal_velocity = self.inputs.muzzle_velocity * launch_elev.cos();
2872        let mut velocity = Vector3::new(
2873            horizontal_velocity * launch_azim.cos(), // X: downrange (forward)
2874            self.inputs.muzzle_velocity * launch_elev.sin(), // Y: vertical component
2875            horizontal_velocity * launch_azim.sin(), // Z: lateral (side deviation)
2876        );
2877
2878        let mut points = Vec::new();
2879        // Steps push_trajectory_point collapsed onto the previous point; they still count
2880        // against the point budget (MBA-1599).
2881        let mut collapsed_steps = 0;
2882        let mut max_height = position.y;
2883        let mut min_pitch_damping = f64::INFINITY; // Track minimum pitch damping coefficient
2884        let mut transonic_mach = None; // Track when we enter transonic
2885                                       // Downrange distances where the projectile crosses Mach 1.2 (transonic) then Mach 1.0
2886                                       // (subsonic), so the sampled trajectory output can flag those transitions
2887                                       // (trajectory_sampling::add_trajectory_flags consumes this).
2888        let mut transonic_distances: Vec<f64> = Vec::new();
2889        let mut mach_transitions = MachTransitionTracker::default();
2890
2891        // Initialize angular state for precession/nutation tracking
2892        let mut angular_state = if self.inputs.enable_precession_nutation {
2893            Some(AngularState {
2894                pitch_angle: 0.001, // Small initial disturbance
2895                yaw_angle: 0.001,
2896                pitch_rate: 0.0,
2897                yaw_rate: 0.0,
2898                precession_angle: 0.0,
2899                nutation_phase: 0.0,
2900            })
2901        } else {
2902            None
2903        };
2904        let mut max_yaw_angle = 0.0;
2905        let mut max_precession_angle = 0.0;
2906
2907        // Calculate air density
2908        let (air_density, speed_of_sound, resolved_temp_c, resolved_press_hpa) =
2909            self.resolved_atmosphere();
2910        // MBA-1136 (rank 30): base density RATIO for the local-altitude atmosphere recompute done
2911        // per-substep inside calculate_acceleration. The `air_density` / `speed_of_sound` above
2912        // stay the frozen station values, still used for the Mach-transition, pitch-damping and
2913        // precession/nutation diagnostics (which are intentionally referenced to station Mach).
2914        let base_ratio = air_density / 1.225;
2915
2916        // Wind vector (McCoy): X=downrange (head/tail wind), Y=0, Z=lateral (crosswind)
2917        // 0deg = headwind, 90deg = from the right (McCoy wind-FROM convention, matching
2918        // WindSock); wind enters drag via velocity - wind. Used when no segmented wind.
2919        // MBA-728: no shear/no segments here, so vertical_speed passes straight through
2920        // (there is no horizontal-only scaling step on this path).
2921        let wind_vector = crate::wind::wind_vector(
2922            self.wind.speed,
2923            self.wind.direction,
2924            self.wind.vertical_speed,
2925        );
2926
2927        // Pitch-damping coefficients depend only on the (constant) bullet_model; compute once
2928        // instead of re-deriving them (with a to_lowercase alloc) every integration step.
2929        let pitch_coeffs = PitchDampingCoefficients::from_bullet_type(
2930            self.inputs.bullet_model.as_deref().unwrap_or("default"),
2931        );
2932
2933        // Main RK4 integration loop (X is downrange)
2934        while position.x < self.max_range
2935            && position.y > self.inputs.ground_threshold
2936            && time < TRAJECTORY_TIME_LIMIT_S
2937        {
2938            // Store trajectory point
2939            let velocity_magnitude = velocity.magnitude();
2940            let kinetic_energy =
2941                0.5 * self.inputs.bullet_mass * velocity_magnitude * velocity_magnitude;
2942
2943            self.push_trajectory_point(
2944                &mut points,
2945                TrajectoryPoint {
2946                    time,
2947                    position,
2948                    velocity_magnitude,
2949                    kinetic_energy,
2950                    drag_coefficient: None,
2951                },
2952                &mut collapsed_steps,
2953            )?;
2954
2955            // Record Mach-transition distances (constant sea-level speed of sound, matching the
2956            // transonic_mach tracking). Each threshold is recorded once, in descending order.
2957            {
2958                let mach_here = if speed_of_sound > 0.0 {
2959                    velocity_magnitude / speed_of_sound
2960                } else {
2961                    0.0
2962                };
2963                mach_transitions.record_downward_crossings(
2964                    mach_here,
2965                    position.x,
2966                    &mut transonic_distances,
2967                );
2968            }
2969
2970            if position.y > max_height {
2971                max_height = position.y;
2972            }
2973
2974            // Calculate pitch damping if enabled (RK4 solver)
2975            if self.inputs.enable_pitch_damping {
2976                let mach = velocity_magnitude / speed_of_sound;
2977
2978                // Track when we enter transonic
2979                if transonic_mach.is_none() && mach < 1.2 && mach > 0.8 {
2980                    transonic_mach = Some(mach);
2981                }
2982
2983                // Calculate pitch damping coefficient
2984                let pitch_damping = calculate_pitch_damping_coefficient(mach, &pitch_coeffs);
2985
2986                // Track minimum (most critical for stability)
2987                if pitch_damping < min_pitch_damping {
2988                    min_pitch_damping = pitch_damping;
2989                }
2990            }
2991
2992            // Calculate precession/nutation if enabled (RK4 solver)
2993            if self.inputs.enable_precession_nutation {
2994                if let Some(ref mut state) = angular_state {
2995                    let velocity_magnitude = velocity.magnitude();
2996                    let params = self.precession_nutation_params(
2997                        velocity_magnitude,
2998                        air_density,
2999                        speed_of_sound,
3000                    );
3001
3002                    // Update angular state
3003                    *state = calculate_combined_angular_motion(
3004                        &params,
3005                        state,
3006                        time,
3007                        self.time_step,
3008                        0.001, // Initial disturbance
3009                    );
3010
3011                    // Track maximums
3012                    if state.yaw_angle.abs() > max_yaw_angle {
3013                        max_yaw_angle = state.yaw_angle.abs();
3014                    }
3015                    if state.precession_angle.abs() > max_precession_angle {
3016                        max_precession_angle = state.precession_angle.abs();
3017                    }
3018                }
3019            }
3020
3021            // RK4 method
3022            let dt = self.time_step;
3023
3024            // k1
3025            let acc1 = self.calculate_acceleration(
3026                &position,
3027                &velocity,
3028                &wind_vector,
3029                (resolved_temp_c, resolved_press_hpa, base_ratio),
3030            );
3031
3032            // k2
3033            let pos2 = position + velocity * (dt * 0.5);
3034            let vel2 = velocity + acc1 * (dt * 0.5);
3035            let acc2 = self.calculate_acceleration(
3036                &pos2,
3037                &vel2,
3038                &wind_vector,
3039                (resolved_temp_c, resolved_press_hpa, base_ratio),
3040            );
3041
3042            // k3
3043            let pos3 = position + vel2 * (dt * 0.5);
3044            let vel3 = velocity + acc2 * (dt * 0.5);
3045            let acc3 = self.calculate_acceleration(
3046                &pos3,
3047                &vel3,
3048                &wind_vector,
3049                (resolved_temp_c, resolved_press_hpa, base_ratio),
3050            );
3051
3052            // k4
3053            let pos4 = position + vel3 * dt;
3054            let vel4 = velocity + acc3 * dt;
3055            let acc4 = self.calculate_acceleration(
3056                &pos4,
3057                &vel4,
3058                &wind_vector,
3059                (resolved_temp_c, resolved_press_hpa, base_ratio),
3060            );
3061
3062            // Update position and velocity
3063            position += (velocity + vel2 * 2.0 + vel3 * 2.0 + vel4) * (dt / 6.0);
3064            velocity += (acc1 + acc2 * 2.0 + acc3 * 2.0 + acc4) * (dt / 6.0);
3065            time += dt;
3066            self.validate_integration_state(&position, &velocity, time)?;
3067        }
3068
3069        let termination = self.append_terminal_endpoint(
3070            &mut points,
3071            position,
3072            velocity,
3073            time,
3074            &mut max_height,
3075            &mut collapsed_steps,
3076        )?;
3077
3078        // Get final values
3079        // MBA-1423: fill the per-point Cd here, once, rather than inside each integrator loop.
3080        // Every solver family reaches this line, so none can leave the field empty while the
3081        // others populate it. Must precede the `last_point` borrow below.
3082        self.annotate_drag_coefficients(&mut points, speed_of_sound);
3083
3084        let last_point = points.last().ok_or("No trajectory points generated")?;
3085
3086        // Create trajectory sampling data if enabled (shared helper, MBA-1403)
3087        let sampled_points =
3088            self.build_sampled_points(&points, max_height, transonic_distances, &mach_transitions)?;
3089
3090        Ok(TrajectoryResult {
3091            max_range: last_point.position.x, // X is downrange
3092            max_height,
3093            time_of_flight: last_point.time,
3094            impact_velocity: last_point.velocity_magnitude,
3095            impact_energy: last_point.kinetic_energy,
3096            projectile_mass_kg: self.inputs.bullet_mass,
3097            line_of_sight_height_m: self.inputs.muzzle_height + self.inputs.sight_height,
3098            station_speed_of_sound_mps: speed_of_sound,
3099            termination,
3100            points,
3101            sampled_points,
3102            min_pitch_damping: if self.inputs.enable_pitch_damping {
3103                Some(min_pitch_damping)
3104            } else {
3105                None
3106            },
3107            transonic_mach,
3108            angular_state,
3109            max_yaw_angle: if self.inputs.enable_precession_nutation {
3110                Some(max_yaw_angle)
3111            } else {
3112                None
3113            },
3114            max_precession_angle: if self.inputs.enable_precession_nutation {
3115                Some(max_precession_angle)
3116            } else {
3117                None
3118            },
3119            aerodynamic_jump: aj_components,
3120            mach_1_2_distance_m: mach_transitions.mach_1_2_distance_m,
3121            mach_1_0_distance_m: mach_transitions.mach_1_0_distance_m,
3122            mach_0_9_distance_m: mach_transitions.mach_0_9_distance_m,
3123        })
3124    }
3125
3126    fn solve_rk45(&self) -> Result<TrajectoryResult, BallisticsError> {
3127        // RK45 adaptive step size integration (Dormand-Prince method)
3128        let mut time = 0.0;
3129        // Bullet starts at the BORE position, which is muzzle_height above ground
3130        // The sight is sight_height ABOVE the bore, so we don't add sight_height here
3131        // cant-adjusted via initial_position (MBA-1286)
3132        let mut position = self.initial_position();
3133
3134        // Calculate initial velocity components
3135        // McCoy coordinate system: X=downrange, Y=vertical, Z=lateral (right)
3136        // Launch direction includes the aerodynamic-jump muzzle perturbation when enabled
3137        // (a no-op returning the bare muzzle/azimuth angles otherwise). MBA-959. Computed
3138        // once here and reused for the result so it isn't evaluated twice per solve.
3139        let aj_components = self.aerodynamic_jump_components();
3140        let (launch_elev, launch_azim) = self.launch_angles_from(aj_components.as_ref());
3141        let horizontal_velocity = self.inputs.muzzle_velocity * launch_elev.cos();
3142        let mut velocity = Vector3::new(
3143            horizontal_velocity * launch_azim.cos(), // X: downrange (forward)
3144            self.inputs.muzzle_velocity * launch_elev.sin(), // Y: vertical component
3145            horizontal_velocity * launch_azim.sin(), // Z: lateral (side deviation)
3146        );
3147
3148        let mut points = Vec::new();
3149        // Steps push_trajectory_point collapsed onto the previous point; they still count
3150        // against the point budget (MBA-1599).
3151        let mut collapsed_steps = 0;
3152        let mut max_height = position.y;
3153        let mut dt = 0.001; // Initial step size
3154
3155        // Air density and wind are constant for the whole solve (self.atmosphere / self.wind
3156        // are immutable); compute once instead of every iteration (mirrors solve_rk4).
3157        let (air_density, speed_of_sound, resolved_temp_c, resolved_press_hpa) =
3158            self.resolved_atmosphere();
3159        // MBA-1136 (rank 30): base density RATIO for the local-altitude atmosphere recompute done
3160        // per-substep inside calculate_acceleration. The `air_density` / `speed_of_sound` above
3161        // stay the frozen station values, still used for the Mach-transition, pitch-damping and
3162        // precession/nutation diagnostics (which are intentionally referenced to station Mach).
3163        let base_ratio = air_density / 1.225;
3164        // 0deg = headwind, 90deg = from the right (McCoy wind-FROM convention, matching
3165        // WindSock); wind enters drag via velocity - wind. Used when no segmented wind.
3166        // MBA-728: no shear/no segments here, so vertical_speed passes straight through
3167        // (there is no horizontal-only scaling step on this path).
3168        let wind_vector = crate::wind::wind_vector(
3169            self.wind.speed,
3170            self.wind.direction,
3171            self.wind.vertical_speed,
3172        );
3173
3174        // Mach-transition distances for the sampled-output flags (see solve_euler/solve_rk4).
3175        let mut transonic_distances: Vec<f64> = Vec::new();
3176        let mut mach_transitions = MachTransitionTracker::default();
3177
3178        // Pitch-damping / precession diagnostics (MBA-966). Previously only the
3179        // Euler and fixed-RK4 solvers tracked these, so the default adaptive
3180        // RK45 path always reported null even with --enable-pitch-damping /
3181        // --enable-precession set. Mirror the RK4 tracking here.
3182        let mut min_pitch_damping = f64::INFINITY;
3183        let mut transonic_mach: Option<f64> = None;
3184        let pitch_coeffs = PitchDampingCoefficients::from_bullet_type(
3185            self.inputs.bullet_model.as_deref().unwrap_or("default"),
3186        );
3187        let mut angular_state = if self.inputs.enable_precession_nutation {
3188            Some(AngularState {
3189                pitch_angle: 0.001,
3190                yaw_angle: 0.001,
3191                pitch_rate: 0.0,
3192                yaw_rate: 0.0,
3193                precession_angle: 0.0,
3194                nutation_phase: 0.0,
3195            })
3196        } else {
3197            None
3198        };
3199        let mut max_yaw_angle = 0.0;
3200        let mut max_precession_angle = 0.0;
3201
3202        while position.x < self.max_range
3203            && position.y > self.inputs.ground_threshold
3204            && time < TRAJECTORY_TIME_LIMIT_S
3205        {
3206            // Store current point
3207            let velocity_magnitude = velocity.magnitude();
3208            let kinetic_energy = 0.5 * self.inputs.bullet_mass * velocity_magnitude.powi(2);
3209
3210            self.push_trajectory_point(
3211                &mut points,
3212                TrajectoryPoint {
3213                    time,
3214                    position,
3215                    velocity_magnitude,
3216                    kinetic_energy,
3217                    drag_coefficient: None,
3218                },
3219                &mut collapsed_steps,
3220            )?;
3221
3222            // Record Mach-transition distances (constant sea-level speed of sound, matching the
3223            // transonic_mach tracking). Each threshold is recorded once, in descending order.
3224            {
3225                let mach_here = if speed_of_sound > 0.0 {
3226                    velocity_magnitude / speed_of_sound
3227                } else {
3228                    0.0
3229                };
3230                mach_transitions.record_downward_crossings(
3231                    mach_here,
3232                    position.x,
3233                    &mut transonic_distances,
3234                );
3235            }
3236
3237            if position.y > max_height {
3238                max_height = position.y;
3239            }
3240
3241            // Pitch damping (RK45 solver) — track the minimum coefficient and the
3242            // Mach at which the projectile enters the transonic band (MBA-966).
3243            if self.inputs.enable_pitch_damping {
3244                let mach = velocity_magnitude / speed_of_sound;
3245                if transonic_mach.is_none() && mach < 1.2 && mach > 0.8 {
3246                    transonic_mach = Some(mach);
3247                }
3248                let pitch_damping = calculate_pitch_damping_coefficient(mach, &pitch_coeffs);
3249                if pitch_damping < min_pitch_damping {
3250                    min_pitch_damping = pitch_damping;
3251                }
3252            }
3253
3254            // Retry the same state until the embedded error estimate accepts the
3255            // candidate. No trajectory or angular state advances on rejection.
3256            let accepted_step = self.adaptive_rk45_step(
3257                &position,
3258                &velocity,
3259                dt,
3260                &wind_vector,
3261                (resolved_temp_c, resolved_press_hpa, base_ratio),
3262            );
3263            debug_assert!(
3264                accepted_step.error <= RK45_TOLERANCE || accepted_step.used_dt <= RK45_MIN_DT
3265            );
3266
3267            // Precession / nutation advances only after the translational step
3268            // is accepted, using that accepted interval rather than a rejected
3269            // trial's dt.
3270            if self.inputs.enable_precession_nutation {
3271                if let Some(ref mut state) = angular_state {
3272                    let params = self.precession_nutation_params(
3273                        velocity_magnitude,
3274                        air_density,
3275                        speed_of_sound,
3276                    );
3277
3278                    *state = calculate_combined_angular_motion(
3279                        &params,
3280                        state,
3281                        time,
3282                        accepted_step.used_dt,
3283                        0.001,
3284                    );
3285
3286                    if state.yaw_angle.abs() > max_yaw_angle {
3287                        max_yaw_angle = state.yaw_angle.abs();
3288                    }
3289                    if state.precession_angle.abs() > max_precession_angle {
3290                        max_precession_angle = state.precession_angle.abs();
3291                    }
3292                }
3293            }
3294
3295            position = accepted_step.position;
3296            velocity = accepted_step.velocity;
3297            time += accepted_step.used_dt;
3298            self.validate_integration_state(&position, &velocity, time)?;
3299
3300            // Adapt the step size for the NEXT iteration.
3301            dt = accepted_step.next_dt;
3302        }
3303
3304        // Ensure we have at least one point
3305        if points.is_empty() {
3306            return Err(BallisticsError::from("No trajectory points calculated"));
3307        }
3308
3309        // Shared MBA-968/MBA-1218 range-crossing interpolation for all solver modes.
3310        let termination = self.append_terminal_endpoint(
3311            &mut points,
3312            position,
3313            velocity,
3314            time,
3315            &mut max_height,
3316            &mut collapsed_steps,
3317        )?;
3318
3319        // MBA-1423: see the sibling solvers — fill the per-point Cd before the borrow below.
3320        self.annotate_drag_coefficients(&mut points, speed_of_sound);
3321
3322        let last_point = points.last().unwrap();
3323
3324        // Generate sampled trajectory points if enabled (shared helper, MBA-1403)
3325        let sampled_points =
3326            self.build_sampled_points(&points, max_height, transonic_distances, &mach_transitions)?;
3327
3328        Ok(TrajectoryResult {
3329            max_range: last_point.position.x, // X is downrange
3330            max_height,
3331            time_of_flight: last_point.time,
3332            impact_velocity: last_point.velocity_magnitude,
3333            impact_energy: last_point.kinetic_energy,
3334            projectile_mass_kg: self.inputs.bullet_mass,
3335            line_of_sight_height_m: self.inputs.muzzle_height + self.inputs.sight_height,
3336            station_speed_of_sound_mps: speed_of_sound,
3337            termination,
3338            points,
3339            sampled_points,
3340            min_pitch_damping: if self.inputs.enable_pitch_damping {
3341                Some(min_pitch_damping)
3342            } else {
3343                None
3344            },
3345            transonic_mach,
3346            angular_state,
3347            max_yaw_angle: if self.inputs.enable_precession_nutation {
3348                Some(max_yaw_angle)
3349            } else {
3350                None
3351            },
3352            max_precession_angle: if self.inputs.enable_precession_nutation {
3353                Some(max_precession_angle)
3354            } else {
3355                None
3356            },
3357            aerodynamic_jump: aj_components,
3358            mach_1_2_distance_m: mach_transitions.mach_1_2_distance_m,
3359            mach_1_0_distance_m: mach_transitions.mach_1_0_distance_m,
3360            mach_0_9_distance_m: mach_transitions.mach_0_9_distance_m,
3361        })
3362    }
3363
3364    fn adaptive_rk45_step(
3365        &self,
3366        position: &Vector3<f64>,
3367        velocity: &Vector3<f64>,
3368        initial_dt: f64,
3369        wind_vector: &Vector3<f64>,
3370        resolved_atmo: (f64, f64, f64),
3371    ) -> Rk45AcceptedStep {
3372        let mut trial_dt = initial_dt;
3373
3374        loop {
3375            let trial = self.rk45_step(
3376                position,
3377                velocity,
3378                trial_dt,
3379                wind_vector,
3380                RK45_TOLERANCE,
3381                resolved_atmo,
3382            );
3383            // A finite-but-extreme input or malformed optional curve can overflow an embedded
3384            // trial. Do not let a NaN suggested step poison `trial_dt` and retry forever: shrink
3385            // to the minimum step, return the non-finite trial there, and let the immediate
3386            // integration-state check turn it into a clean Err.
3387            let next_dt = if trial.suggested_dt.is_finite() {
3388                (RK45_SAFETY_FACTOR * trial.suggested_dt).clamp(RK45_MIN_DT, RK45_MAX_DT)
3389            } else {
3390                RK45_MIN_DT
3391            };
3392
3393            if trial.error <= RK45_TOLERANCE || trial_dt <= RK45_MIN_DT {
3394                return Rk45AcceptedStep {
3395                    position: trial.position,
3396                    velocity: trial.velocity,
3397                    used_dt: trial_dt,
3398                    next_dt,
3399                    error: trial.error,
3400                };
3401            }
3402
3403            trial_dt = next_dt;
3404        }
3405    }
3406
3407    fn rk45_step(
3408        &self,
3409        position: &Vector3<f64>,
3410        velocity: &Vector3<f64>,
3411        dt: f64,
3412        wind_vector: &Vector3<f64>,
3413        tolerance: f64,
3414        resolved_atmo: (f64, f64, f64), // (base_temp_c, base_press_hpa, base_ratio)
3415    ) -> Rk45Trial {
3416        // Dormand-Prince coefficients
3417        const A21: f64 = 1.0 / 5.0;
3418        const A31: f64 = 3.0 / 40.0;
3419        const A32: f64 = 9.0 / 40.0;
3420        const A41: f64 = 44.0 / 45.0;
3421        const A42: f64 = -56.0 / 15.0;
3422        const A43: f64 = 32.0 / 9.0;
3423        const A51: f64 = 19372.0 / 6561.0;
3424        const A52: f64 = -25360.0 / 2187.0;
3425        const A53: f64 = 64448.0 / 6561.0;
3426        const A54: f64 = -212.0 / 729.0;
3427        const A61: f64 = 9017.0 / 3168.0;
3428        const A62: f64 = -355.0 / 33.0;
3429        const A63: f64 = 46732.0 / 5247.0;
3430        const A64: f64 = 49.0 / 176.0;
3431        const A65: f64 = -5103.0 / 18656.0;
3432        const A71: f64 = 35.0 / 384.0;
3433        const A73: f64 = 500.0 / 1113.0;
3434        const A74: f64 = 125.0 / 192.0;
3435        const A75: f64 = -2187.0 / 6784.0;
3436        const A76: f64 = 11.0 / 84.0;
3437
3438        // 5th order coefficients
3439        const B1: f64 = 35.0 / 384.0;
3440        const B3: f64 = 500.0 / 1113.0;
3441        const B4: f64 = 125.0 / 192.0;
3442        const B5: f64 = -2187.0 / 6784.0;
3443        const B6: f64 = 11.0 / 84.0;
3444
3445        // 4th order coefficients for error estimation
3446        const B1_ERR: f64 = 5179.0 / 57600.0;
3447        const B3_ERR: f64 = 7571.0 / 16695.0;
3448        const B4_ERR: f64 = 393.0 / 640.0;
3449        const B5_ERR: f64 = -92097.0 / 339200.0;
3450        const B6_ERR: f64 = 187.0 / 2100.0;
3451        const B7_ERR: f64 = 1.0 / 40.0;
3452
3453        // Compute RK45 stages
3454        let k1_v = self.calculate_acceleration(position, velocity, wind_vector, resolved_atmo);
3455        let k1_p = *velocity;
3456
3457        let p2 = position + dt * A21 * k1_p;
3458        let v2 = velocity + dt * A21 * k1_v;
3459        let k2_v = self.calculate_acceleration(&p2, &v2, wind_vector, resolved_atmo);
3460        let k2_p = v2;
3461
3462        let p3 = position + dt * (A31 * k1_p + A32 * k2_p);
3463        let v3 = velocity + dt * (A31 * k1_v + A32 * k2_v);
3464        let k3_v = self.calculate_acceleration(&p3, &v3, wind_vector, resolved_atmo);
3465        let k3_p = v3;
3466
3467        let p4 = position + dt * (A41 * k1_p + A42 * k2_p + A43 * k3_p);
3468        let v4 = velocity + dt * (A41 * k1_v + A42 * k2_v + A43 * k3_v);
3469        let k4_v = self.calculate_acceleration(&p4, &v4, wind_vector, resolved_atmo);
3470        let k4_p = v4;
3471
3472        let p5 = position + dt * (A51 * k1_p + A52 * k2_p + A53 * k3_p + A54 * k4_p);
3473        let v5 = velocity + dt * (A51 * k1_v + A52 * k2_v + A53 * k3_v + A54 * k4_v);
3474        let k5_v = self.calculate_acceleration(&p5, &v5, wind_vector, resolved_atmo);
3475        let k5_p = v5;
3476
3477        let p6 = position + dt * (A61 * k1_p + A62 * k2_p + A63 * k3_p + A64 * k4_p + A65 * k5_p);
3478        let v6 = velocity + dt * (A61 * k1_v + A62 * k2_v + A63 * k3_v + A64 * k4_v + A65 * k5_v);
3479        let k6_v = self.calculate_acceleration(&p6, &v6, wind_vector, resolved_atmo);
3480        let k6_p = v6;
3481
3482        let p7 = position + dt * (A71 * k1_p + A73 * k3_p + A74 * k4_p + A75 * k5_p + A76 * k6_p);
3483        let v7 = velocity + dt * (A71 * k1_v + A73 * k3_v + A74 * k4_v + A75 * k5_v + A76 * k6_v);
3484        let k7_v = self.calculate_acceleration(&p7, &v7, wind_vector, resolved_atmo);
3485        let k7_p = v7;
3486
3487        // 5th order solution
3488        let new_pos = position + dt * (B1 * k1_p + B3 * k3_p + B4 * k4_p + B5 * k5_p + B6 * k6_p);
3489        let new_vel = velocity + dt * (B1 * k1_v + B3 * k3_v + B4 * k4_v + B5 * k5_v + B6 * k6_v);
3490
3491        // 4th order solution for error estimate
3492        let pos_err = position
3493            + dt * (B1_ERR * k1_p
3494                + B3_ERR * k3_p
3495                + B4_ERR * k4_p
3496                + B5_ERR * k5_p
3497                + B6_ERR * k6_p
3498                + B7_ERR * k7_p);
3499        let vel_err = velocity
3500            + dt * (B1_ERR * k1_v
3501                + B3_ERR * k3_v
3502                + B4_ERR * k4_v
3503                + B5_ERR * k5_v
3504                + B6_ERR * k6_v
3505                + B7_ERR * k7_v);
3506
3507        // Estimate error
3508        let error = cli_rk45_error_norm(position, velocity, &new_pos, &new_vel, &pos_err, &vel_err);
3509
3510        // Calculate new step size
3511        let dt_new = if error < tolerance {
3512            dt * (tolerance / error).powf(0.2).min(2.0)
3513        } else {
3514            dt * (tolerance / error).powf(0.25).max(0.1)
3515        };
3516
3517        Rk45Trial {
3518            position: new_pos,
3519            velocity: new_vel,
3520            suggested_dt: dt_new,
3521            error,
3522        }
3523    }
3524
3525    fn apply_cluster_bc_correction(&self, base_bc: f64, velocity_fps: f64) -> f64 {
3526        if let Some(ref cluster_bc) = self.cluster_bc {
3527            cluster_bc.apply_correction_for_drag_model(
3528                base_bc,
3529                self.inputs.caliber_inches,
3530                self.inputs.weight_grains,
3531                velocity_fps,
3532                self.inputs.bc_type,
3533            )
3534        } else {
3535            base_bc
3536        }
3537    }
3538
3539    fn calculate_acceleration(
3540        &self,
3541        position: &Vector3<f64>,
3542        velocity: &Vector3<f64>,
3543        wind_vector: &Vector3<f64>,
3544        resolved_atmo: (f64, f64, f64), // (base_temp_c, base_press_hpa, base_ratio) hoisted per-solve
3545    ) -> Vector3<f64> {
3546        // Resolve the wind at this point. Downrange-segmented wind (when supplied)
3547        // takes precedence and is sampled by downrange distance (position.x) per
3548        // step; otherwise altitude-dependent shear (if enabled); otherwise the
3549        // constant `wind_vector`. Segmented wind is not combined with shear (the
3550        // CLI/WASM front-ends reject that combination), so the order is safe.
3551        let actual_wind = if let Some(ref sock) = self.wind_sock {
3552            sock.vector_for_range_stateless(position.x)
3553        } else if let Some(model) = self.boundary_layer_shear_model() {
3554            self.get_wind_at_altitude(
3555                crate::wind_shear::shear_height_agl_m(position.y, self.inputs.muzzle_height),
3556                model,
3557            )
3558        } else {
3559            *wind_vector
3560        };
3561        let actual_wind =
3562            crate::derivatives::level_vector_to_shot_frame(actual_wind, self.inputs.shooting_angle);
3563
3564        let relative_velocity = velocity - actual_wind;
3565        let velocity_magnitude = relative_velocity.magnitude();
3566
3567        if velocity_magnitude < 0.001 {
3568            return self.gravity_acceleration();
3569        }
3570
3571        // MBA-1136 (rank 30): recompute the atmosphere at the LOCAL substep altitude instead of
3572        // holding the frozen station scalars for the whole flight. This mirrors what
3573        // derivatives.rs / fast_trajectory.rs already do, so all three solver families vary air
3574        // density AND speed of sound with altitude (matters on elevated / long-range shots; a
3575        // no-op at the shooter altitude, where the ratio-based density recovers the station value
3576        // exactly). base_* were resolved once per solve via resolved_atmosphere().
3577        //
3578        // `base_temp_c` / `base_press_hpa` are the STATION conditions that seed the local
3579        // atmosphere calculation below. Magnus dynamic stability consumes the resulting local
3580        // density rather than freezing a launch-density correction.
3581        let (base_temp_c, base_press_hpa, station_ratio) = resolved_atmo;
3582
3583        // MBA-1137: downrange-segmented atmosphere. When an AtmoSock is present, swap the BASE
3584        // (station-referenced) T/P/H tuple for the active zone selected by downrange distance
3585        // (position.x), recomputing the per-zone base density ratio via CIPM. That swapped base
3586        // then flows through the SAME altitude-lapse pipeline, so downrange zone selection and the
3587        // world-vertical altitude lapse compose — the zone sets the base density/humidity, and the
3588        // lapse multiplies on top of it (no double-count). When None, this is the resolved
3589        // single-station base.
3590        let (drag_base_temp_c, drag_base_press_hpa, drag_base_ratio, drag_humidity_percent) =
3591            if let Some(ref sock) = self.atmo_sock {
3592                let (zone_temp_c, zone_press_hpa, zone_humidity) = sock.atmo_for_range(position.x);
3593                let zone_base_ratio = crate::atmosphere::calculate_air_density_cimp(
3594                    zone_temp_c,
3595                    zone_press_hpa,
3596                    zone_humidity,
3597                ) / 1.225;
3598                (zone_temp_c, zone_press_hpa, zone_base_ratio, zone_humidity)
3599            } else {
3600                (
3601                    base_temp_c,
3602                    base_press_hpa,
3603                    station_ratio,
3604                    self.atmosphere.humidity,
3605                )
3606            };
3607        let local_alt = crate::atmosphere::shot_frame_altitude(
3608            self.atmosphere.altitude,
3609            position.x,
3610            position.y,
3611            self.inputs.shooting_angle,
3612        );
3613        let (air_density, speed_of_sound) =
3614            crate::atmosphere::get_local_atmosphere_humid_with_gravity(
3615                local_alt,
3616                self.atmosphere.altitude,
3617                drag_base_temp_c,
3618                drag_base_press_hpa,
3619                drag_base_ratio,
3620                drag_humidity_percent,
3621                self.gravity,
3622            );
3623
3624        // Resolve the Cd and the retardation denominator through the shared resolver. MBA-1423
3625        // reports a per-point Cd derived from these same two values, so the number a consumer
3626        // charts cannot drift from the number actually flown here.
3627        let (cd, retard_denom) = self.drag_terms(velocity_magnitude, speed_of_sound);
3628
3629        // Convert velocity to fps (still used below outside the BC lookup).
3630        let velocity_fps = velocity_magnitude * 3.28084;
3631
3632        // Use proper ballistics retardation formula
3633        // This matches the proven formula from fast_trajectory.rs
3634        // The standard retardation factor converts Cd to drag deceleration
3635        // Note: velocity_fps already calculated above for BC segment lookup
3636        let cd_to_retard = crate::constants::CD_TO_RETARD;
3637        let standard_factor = cd * cd_to_retard;
3638        let density_scale = air_density / 1.225; // Scale relative to standard air (1.225 kg/m³)
3639
3640        // Drag acceleration in ft/s² then convert to m/s²
3641        let a_drag_ft_s2 =
3642            (velocity_fps * velocity_fps) * standard_factor * density_scale / retard_denom;
3643        let a_drag_m_s2 = a_drag_ft_s2 * 0.3048; // ft/s² to m/s²
3644
3645        // Apply drag opposite to velocity direction
3646        let drag_acceleration = -a_drag_m_s2 * (relative_velocity / velocity_magnitude);
3647
3648        // Total acceleration = drag + gravity. `shooting_angle` rotates gravity into the shot
3649        // frame for inclined fire; at 0 deg this is the normal vertical-only gravity vector.
3650        let mut accel = drag_acceleration + self.gravity_acceleration();
3651
3652        // Coriolis (Earth rotation). McCoy frame: X=downrange, Y=vertical, Z=lateral,
3653        // azimuth 0 = North. McCoy frame: X=downrange, Y=vertical, Z=lateral.
3654        if self.inputs.enable_coriolis {
3655            if let Some(lat_deg) = self.inputs.latitude {
3656                let omega_earth = 7.2921159e-5_f64; // rad/s
3657                let lat = lat_deg.to_radians();
3658                let az = self.inputs.shot_azimuth; // compass bearing (0=N), NOT the aiming offset
3659                                                   // Earth's angular velocity in level downrange/up/lateral axes.
3660                                                   // Projecting Omega=(0, Ω cosφ, Ω sinφ) [local E,N,U] by azimuth gives
3661                                                   // a NEGATIVE lateral component:
3662                                                   // lateral = downrange × up points East for a North shot, and
3663                                                   // Omega·East = -Ω cosφ sin(az). The previous code dropped that sign.
3664                let omega = Vector3::new(
3665                    omega_earth * lat.cos() * az.cos(),  // X: downrange
3666                    omega_earth * lat.sin(),             // Y: vertical
3667                    -omega_earth * lat.cos() * az.sin(), // Z: lateral (MBA-938: corrected sign)
3668                );
3669                let omega = crate::derivatives::level_vector_to_shot_frame(
3670                    omega,
3671                    self.inputs.shooting_angle,
3672                );
3673                // Coriolis acceleration is the physical -2 Ω×v (MBA-938). The old +2 with
3674                // an "output-preserving relabel" justification produced left-ward drift for
3675                // a North shot in the Northern hemisphere; first principles (and the +Eötvös
3676                // lift for East shots) require -2 with the corrected omega above.
3677                accel += -2.0 * omega.cross(velocity);
3678            }
3679        }
3680
3681        // Magnus force (spinning projectile). SI units in this solver.
3682        // MBA-1134 (rank 35): the canonical empirical Litz spin-drift post-process
3683        // (apply_spin_drift) already captures the gyroscopic yaw-of-repose lateral, so the
3684        // explicit Magnus side force must NOT be added on top of it — otherwise the two lateral
3685        // models stack and double-count the drift. Suppress Magnus whenever Litz spin drift is
3686        // active. (The inverse is intentionally NOT done: Litz is not suppressed when Magnus is on.)
3687        if self.inputs.enable_magnus
3688            && !self.inputs.use_enhanced_spin_drift
3689            && self.inputs.bullet_diameter > 0.0
3690            && self.inputs.twist_rate > 0.0
3691        {
3692            let diameter_m = self.inputs.bullet_diameter;
3693            let (spin_rad_s, spin_param) = crate::spin_drift::calculate_magnus_spin_state(
3694                self.inputs.muzzle_velocity,
3695                velocity_magnitude,
3696                self.inputs.twist_rate,
3697                diameter_m,
3698            );
3699            // Mach and dynamic stability both use the LOCAL atmosphere recomputed above.
3700            let mach = velocity_magnitude / speed_of_sound;
3701
3702            // Imperial conversions for the stability / yaw-of-repose helpers.
3703            let d_in = self.inputs.bullet_diameter / 0.0254;
3704            let m_gr = self.inputs.bullet_mass / crate::constants::GRAINS_TO_KG;
3705            let l_in = if self.inputs.bullet_length > 0.0 {
3706                self.inputs.bullet_length / 0.0254
3707            } else {
3708                // MBA-1135: mass-based length estimate (was a mass-blind 4.5-caliber default).
3709                let est_m = crate::stability::estimate_bullet_length_m(
3710                    self.inputs.bullet_diameter,
3711                    self.inputs.bullet_mass,
3712                );
3713                if est_m > 0.0 {
3714                    est_m / 0.0254
3715                } else {
3716                    4.5 * d_in
3717                }
3718            };
3719            // Use current-flight Sg with the muzzle-set spin. The helper back-calculates the
3720            // effective twist from fixed spin and current airspeed, so Sg and yaw of repose grow
3721            // downrange instead of remaining tied to launch conditions.
3722            let sg = crate::spin_drift::calculate_dynamic_stability(
3723                m_gr,
3724                velocity_magnitude,
3725                spin_rad_s,
3726                d_in,
3727                l_in,
3728                air_density,
3729            );
3730
3731            // Yaw of repose (radians); zero for unstable bullets (Sg <= 1).
3732            let (yaw_rad, _) = crate::spin_drift::calculate_yaw_of_repose_with_gravity(
3733                sg,
3734                velocity_magnitude,
3735                spin_rad_s,
3736                0.0, // crosswind handled elsewhere
3737                0.0, // pitch rate not tracked
3738                air_density,
3739                d_in,
3740                l_in,
3741                m_gr,
3742                mach,
3743                "match",
3744                false,
3745                self.gravity,
3746            );
3747
3748            // Proper McCoy Magnus FORCE: F = q S C_Npa (pd/2V) sin(alpha_R).
3749            let c_np = crate::derivatives::calculate_magnus_moment_coefficient(mach);
3750            let area = std::f64::consts::PI * (diameter_m / 2.0).powi(2);
3751            let magnus_force = 0.5
3752                * air_density
3753                * velocity_magnitude.powi(2)
3754                * area
3755                * c_np
3756                * spin_param
3757                * yaw_rad.sin();
3758
3759            // The yaw of repose is lateral, so its Magnus force follows gravity projected normal
3760            // to flight (down for right-hand twist). Lateral yaw lift belongs to the separate Litz
3761            // spin-drift model and must not be synthesized from this Magnus magnitude.
3762            if magnus_force.abs() > 1e-12 {
3763                if let Some(dir) = crate::derivatives::yaw_of_repose_magnus_direction(
3764                    relative_velocity,
3765                    self.gravity_acceleration(),
3766                    self.inputs.is_twist_right,
3767                ) {
3768                    accel += (magnus_force / self.inputs.bullet_mass) * dir;
3769                }
3770            }
3771        }
3772
3773        accel
3774    }
3775
3776    /// The reference drag coefficient and the retardation denominator this solver divides by,
3777    /// at one speed.
3778    ///
3779    /// This is the single place the two are resolved. `calculate_acceleration` divides one by
3780    /// the other to produce drag; [`Self::effective_drag_coefficient`] recombines them into the
3781    /// projectile's own Cd for reporting (MBA-1423). Keeping both callers on one resolver is
3782    /// deliberate: a second copy of the BC-precedence ladder below is exactly the kind of
3783    /// duplicate that drifts silently once someone edits one of them.
3784    ///
3785    /// The denominator is a BC (lb/in²) for a G-model and for a custom table flown as a
3786    /// reference curve (`custom_drag_reference`), and the sectional density for a custom table
3787    /// flown as the projectile's own curve, because that table already supplies the
3788    /// projectile's actual Cd (see [`BallisticInputs::custom_drag_denominator`]).
3789    fn drag_terms(&self, velocity_magnitude: f64, speed_of_sound: f64) -> (f64, f64) {
3790        let cd = self.calculate_drag_coefficient(velocity_magnitude, speed_of_sound);
3791
3792        let velocity_fps = velocity_magnitude * 3.28084;
3793
3794        // Match the other solver families' BC precedence: enabled velocity-keyed segments first,
3795        // then legacy Mach-keyed segments, then the scalar BC. `use_bc_segments` gates velocity
3796        // tables, while explicit Mach segments remain active when it is false; derivatives.rs and
3797        // the fast solver preserve that legacy contract for callers that provide a Mach table.
3798        let (base_bc, bc_from_segments) = if let Some(segments) = self
3799            .inputs
3800            .bc_segments_data
3801            .as_ref()
3802            .filter(|segments| self.inputs.use_bc_segments && !segments.is_empty())
3803        {
3804            // Find matching segment for current velocity.
3805            (
3806                crate::bc_estimation::velocity_segment_bc(
3807                    velocity_fps,
3808                    segments,
3809                    self.inputs.bc_value,
3810                ),
3811                true,
3812            )
3813        } else if let Some(segments) = self
3814            .inputs
3815            .bc_segments
3816            .as_ref()
3817            .filter(|segments| !segments.is_empty())
3818        {
3819            (
3820                crate::derivatives::interpolated_bc(
3821                    velocity_magnitude / speed_of_sound,
3822                    segments,
3823                    Some(&self.inputs),
3824                ),
3825                true,
3826            )
3827        } else {
3828            (self.inputs.bc_value, false)
3829        };
3830
3831        // Segment tables already own the velocity-dependent BC shape. Stacking the empirical
3832        // cluster ladder on top would apply that shape twice (MBA-1175). Cluster correction is
3833        // therefore only a fallback for a scalar BC, regardless of which explicit segment
3834        // representation supplied the active value.
3835        let effective_bc = if bc_from_segments {
3836            base_bc
3837        } else {
3838            self.apply_cluster_bc_correction(base_bc, velocity_fps)
3839        };
3840        // The scalar BC is validated at the solve boundary. Retain a small denominator floor for
3841        // explicit segment tables, whose interpolated values are independent caller data.
3842        let effective_bc = effective_bc.max(1e-6);
3843
3844        // When a custom drag table is active, calculate_drag_coefficient returned the table's
3845        // Cd. For the projectile's own curve the retardation denominator must be the sectional
3846        // density (lb/in²), not a BC: Cd_own / SD == Cd_ref / BC. A reference curve divides by
3847        // the BC resolved above instead, band and all, exactly as a G-model does
3848        // (see BallisticInputs::custom_drag_denominator).
3849        let retard_denom = if self.inputs.custom_drag_table.is_some() {
3850            self.inputs.custom_drag_denominator(effective_bc)
3851        } else {
3852            effective_bc
3853        };
3854
3855        (cd, retard_denom)
3856    }
3857
3858    /// The projectile's own drag coefficient at one speed — the curve a shooter would compare
3859    /// against a measured CDM trace, not the reference-table Cd (MBA-1423).
3860    ///
3861    /// A G-model Cd describes the *standard* projectile, so reporting it directly would hand a
3862    /// consumer the same G7 curve no matter which bullet was flown. Scaling by the form factor
3863    /// `SD / BC` converts it to this projectile's actual Cd, from the identity that both
3864    /// descriptions must produce the same retardation:
3865    ///
3866    /// ```text
3867    /// Cd_own / SD == Cd_ref / BC   =>   Cd_own == Cd_ref * SD / BC
3868    /// ```
3869    ///
3870    /// Because the denominator comes from the same internal resolver the integrator divides by,
3871    /// a velocity- or Mach-segmented BC
3872    /// carries into the result and its band steps appear in the reported curve. The same
3873    /// expression is correct for both readings of a custom drag table: for the projectile's own
3874    /// curve the denominator *is* the sectional density and the scale factor collapses to 1,
3875    /// and for a reference curve (`custom_drag_reference`) the denominator is the resolved BC,
3876    /// exactly as for a G-model.
3877    ///
3878    /// Returns `None` when the projectile's mass or diameter is unavailable, since sectional
3879    /// density — and therefore the projectile's own Cd — is undefined without both.
3880    pub fn effective_drag_coefficient(
3881        &self,
3882        velocity_magnitude: f64,
3883        speed_of_sound: f64,
3884    ) -> Option<f64> {
3885        if !velocity_magnitude.is_finite() || speed_of_sound <= 1e-9 {
3886            return None;
3887        }
3888        let sectional_density = self.inputs.sectional_density_lb_in2()?;
3889        let (cd, retard_denom) = self.drag_terms(velocity_magnitude, speed_of_sound);
3890        if retard_denom <= 0.0 {
3891            return None;
3892        }
3893        let effective = cd * sectional_density / retard_denom;
3894        effective.is_finite().then_some(effective)
3895    }
3896
3897    /// Fill every point's [`TrajectoryPoint::drag_coefficient`] in one pass (MBA-1423).
3898    ///
3899    /// Deliberately a pass over the finished vector rather than a value set where points are
3900    /// built: this solver constructs them in several places — terminal interpolation plus one
3901    /// loop per integrator family — and a pass covers a site added later for free.
3902    ///
3903    /// `speed_of_sound` must be the station value the result reports Mach against, so that a
3904    /// consumer plotting Cd against Mach gets a self-consistent pair. Feeding each step's local
3905    /// speed of sound instead would attribute a Cd to a Mach the document never shows.
3906    fn annotate_drag_coefficients(&self, points: &mut [TrajectoryPoint], speed_of_sound: f64) {
3907        for point in points.iter_mut() {
3908            point.drag_coefficient =
3909                self.effective_drag_coefficient(point.velocity_magnitude, speed_of_sound);
3910        }
3911    }
3912
3913    fn calculate_drag_coefficient(&self, velocity: f64, speed_of_sound: f64) -> f64 {
3914        let mach = velocity / speed_of_sound;
3915
3916        // MBA-940: a user-supplied custom drag table is the final Cd, used as-is — no G-model
3917        // lookup, no transonic shape correction, no form factor. The supplied curve already
3918        // encodes the projectile's true drag, so applying those would distort/double-count it.
3919        if let Some(ref table) = self.inputs.custom_drag_table {
3920            // MBA-1357: cd_scale is a single whole-curve drag multiplier applied here, at the
3921            // Cd lookup site. The Mach-keyed DSF table (truing_dsf.rs) is a SEPARATE, drop-only
3922            // post-processing correction applied to a solved TrajectoryResult's points after
3923            // integration finishes — it never touches this Cd/drag-force computation.
3924            return table.interpolate(mach) * self.inputs.cd_scale;
3925        }
3926
3927        // A published/measured BC already contains the projectile form factor (BC = SD / i).
3928        // Multiplying reference Cd by a second name-derived factor double-counts shape.
3929        crate::drag::get_drag_coefficient(mach, &self.inputs.bc_type)
3930    }
3931}
3932
3933// Monte Carlo parameters
3934#[derive(Debug, Clone)]
3935pub struct MonteCarloParams {
3936    pub num_simulations: usize,
3937    pub velocity_std_dev: f64,
3938    pub angle_std_dev: f64,
3939    pub bc_std_dev: f64,
3940    pub wind_speed_std_dev: f64,
3941    pub target_distance: Option<f64>,
3942    pub base_wind_speed: f64,
3943    pub base_wind_direction: f64,
3944    pub azimuth_std_dev: f64, // Horizontal aiming variation in radians
3945}
3946
3947impl Default for MonteCarloParams {
3948    fn default() -> Self {
3949        Self {
3950            num_simulations: 1000,
3951            velocity_std_dev: 1.0,
3952            angle_std_dev: 0.001,
3953            bc_std_dev: 0.01,
3954            wind_speed_std_dev: 1.0,
3955            target_distance: None,
3956            base_wind_speed: 0.0,
3957            base_wind_direction: 0.0,
3958            azimuth_std_dev: 0.001, // Default horizontal spread ~0.057 degrees
3959        }
3960    }
3961}
3962
3963// Monte Carlo results
3964#[derive(Debug, Clone)]
3965pub struct MonteCarloResults {
3966    pub ranges: Vec<f64>,
3967    pub impact_velocities: Vec<f64>,
3968    /// Deviations from the baseline point of aim at the target plane.
3969    ///
3970    /// A sample that falls short of the plane is encoded as
3971    /// `(0, TARGET_NOT_REACHED_SENTINEL_M, 0)` so it remains aligned with
3972    /// `ranges` and `impact_velocities` and still counts as a miss.
3973    pub impact_positions: Vec<Vector3<f64>>,
3974}
3975
3976/// Default hit-zone radius (meters) around the point of aim at the target plane — a 30 cm
3977/// circle. Shared by the CLI, FFI, and WASM so "hit probability" means the same thing everywhere.
3978pub const DEFAULT_HIT_RADIUS_M: f64 = 0.3;
3979
3980/// Vertical-position marker for a Monte Carlo sample that never reached the target plane.
3981///
3982/// The marker preserves the equal-length result-vector and C-ABI contract. Exclude marked
3983/// positions from target-plane dispersion statistics, but keep them in the denominator for hit
3984/// probability because they are definite misses.
3985pub const TARGET_NOT_REACHED_SENTINEL_M: f64 = -1.0e9;
3986
3987impl MonteCarloResults {
3988    /// Whether an encoded impact position represents a finite arrival at the target plane.
3989    pub fn position_reached_target(position: &Vector3<f64>) -> bool {
3990        position.iter().all(|component| component.is_finite())
3991            && position.y != TARGET_NOT_REACHED_SENTINEL_M
3992    }
3993
3994    /// Number of recorded simulations that reached the target plane.
3995    pub fn target_arrival_count(&self) -> usize {
3996        self.impact_positions
3997            .iter()
3998            .filter(|position| Self::position_reached_target(position))
3999            .count()
4000    }
4001
4002    /// Fraction of recorded simulations that fell short of (or otherwise failed to produce a
4003    /// finite position at) the target plane.
4004    pub fn target_shortfall_fraction(&self) -> f64 {
4005        if self.impact_positions.is_empty() {
4006            return 0.0;
4007        }
4008        (self.impact_positions.len() - self.target_arrival_count()) as f64
4009            / self.impact_positions.len() as f64
4010    }
4011
4012    /// Upper-median radial miss among samples that reached the target plane.
4013    ///
4014    /// This preserves the CLI's historical radial-to-baseline "CEP (approx)" convention while
4015    /// preventing the finite target-shortfall marker from becoming the median (MBA-1159).
4016    /// Returns `None` when no recorded simulation reached the target plane.
4017    pub fn target_plane_cep(&self) -> Option<f64> {
4018        let mut radial_misses: Vec<f64> = self
4019            .impact_positions
4020            .iter()
4021            .filter(|position| Self::position_reached_target(position))
4022            .map(Vector3::norm)
4023            .filter(|miss| miss.is_finite())
4024            .collect();
4025        radial_misses.sort_by(f64::total_cmp);
4026        if radial_misses.is_empty() {
4027            None
4028        } else {
4029            Some(radial_misses[radial_misses.len() / 2])
4030        }
4031    }
4032
4033    /// Fraction of simulations whose impact at the target plane lands within `hit_radius_m`
4034    /// of the point of aim. `impact_positions` are deviations from the baseline at the target
4035    /// plane (the downrange component is 0), so the vector norm is the radial miss distance.
4036    /// Samples that fall short of the target remain in the denominator and count as misses.
4037    /// Returns 0.0 when there are no samples.
4038    ///
4039    /// Single source of truth for hit probability — previously the CLI used a range-precision
4040    /// notion and the FFI a position notion with a redundant clause, so they disagreed.
4041    pub fn hit_probability(&self, hit_radius_m: f64) -> f64 {
4042        if self.impact_positions.is_empty() {
4043            return 0.0;
4044        }
4045        let hits = self
4046            .impact_positions
4047            .iter()
4048            .filter(|position| Self::position_is_hit(position, hit_radius_m))
4049            .count();
4050        hits as f64 / self.impact_positions.len() as f64
4051    }
4052
4053    /// Whether one encoded impact position is a hit inside `hit_radius_m` of the point of aim.
4054    ///
4055    /// The single source of truth for the hit predicate itself, as
4056    /// [`hit_probability`](Self::hit_probability) is for the ratio. Both that method and the
4057    /// adaptive driver ([`run_monte_carlo_adaptive_seeded`]) call this, so "hit" means the
4058    /// same thing whether it was counted over a retained result vector or streamed one trial
4059    /// at a time and then discarded.
4060    ///
4061    /// A sample that never reached the target plane is a definite miss, never a hit: the
4062    /// shortfall marker's norm is enormous, but the marker is excluded explicitly rather than
4063    /// left to depend on that.
4064    pub fn position_is_hit(position: &Vector3<f64>, hit_radius_m: f64) -> bool {
4065        Self::position_reached_target(position) && position.norm() < hit_radius_m
4066    }
4067
4068    /// Fixed-`n` Wilson companion to [`hit_probability`](Self::hit_probability): the same point
4069    /// estimate, plus a confidence interval for it and the `n` behind it.
4070    ///
4071    /// Returns `(p_hat, (ci_low, ci_high), n)`. This is a *composition* of two existing
4072    /// functions, not a second derivation of either: `p_hat` is
4073    /// [`hit_probability`](Self::hit_probability) called directly, and the bounds are
4074    /// [`wilson_interval`] over the hit count and `impact_positions.len()`. The denominator is
4075    /// therefore identical -- samples that fell short of the target plane are counted as
4076    /// misses in `n`, exactly as `hit_probability` counts them -- and the two can never drift
4077    /// apart under maintenance.
4078    ///
4079    /// # When this is the wrong interval
4080    ///
4081    /// A fixed-`n` interval is only valid if `n` was fixed *before* the data was seen. Use
4082    /// this for a run of a pre-declared `num_simulations`. If the sample count was instead
4083    /// chosen by watching the results -- stopping once the interval looked tight enough --
4084    /// this interval has no coverage guarantee at all; use [`run_monte_carlo_adaptive_seeded`],
4085    /// whose interval is anytime-valid, for that.
4086    ///
4087    /// With no samples the result is `(0.0, (0.0, 1.0), 0)`: the `0.0` that `hit_probability`
4088    /// reports, alongside the total-ignorance interval `wilson_interval` reports at `n == 0`.
4089    pub fn hit_probability_wilson(
4090        &self,
4091        hit_radius_m: f64,
4092        level: ConfidenceLevel,
4093    ) -> (f64, (f64, f64), u64) {
4094        let trials = self.impact_positions.len() as u64;
4095        let hits = self
4096            .impact_positions
4097            .iter()
4098            .filter(|position| Self::position_is_hit(position, hit_radius_m))
4099            .count() as u64;
4100        (
4101            self.hit_probability(hit_radius_m),
4102            wilson_interval(hits, trials, level),
4103            trials,
4104        )
4105    }
4106
4107    /// Fraction of simulations whose impact at the target plane lands within the axis-aligned
4108    /// rectangle `width_m` (lateral, Z) x `height_m` (vertical, Y) centered on the point of aim
4109    /// — i.e. within `width_m / 2` of center laterally AND `height_m / 2` of center vertically.
4110    ///
4111    /// This is the WEZ (Weapon Employment Zone, MBA-1317) counterpart of [`hit_probability`]'s
4112    /// circular radius for rectangular target sizes (e.g. an 18"x30" steel plate). Samples that
4113    /// fall short of the target plane remain in the denominator and count as misses, matching
4114    /// `hit_probability`'s convention. Returns 0.0 when there are no samples or when either
4115    /// dimension is non-positive.
4116    ///
4117    /// [`hit_probability`]: Self::hit_probability
4118    pub fn rect_hit_probability(&self, width_m: f64, height_m: f64) -> f64 {
4119        let dimensions_invalid =
4120            width_m.is_nan() || width_m <= 0.0 || height_m.is_nan() || height_m <= 0.0;
4121        if self.impact_positions.is_empty() || dimensions_invalid {
4122            return 0.0;
4123        }
4124        let half_width = width_m / 2.0;
4125        let half_height = height_m / 2.0;
4126        let hits = self
4127            .impact_positions
4128            .iter()
4129            .filter(|position| {
4130                Self::position_reached_target(position)
4131                    && position.z.abs() <= half_width
4132                    && position.y.abs() <= half_height
4133            })
4134            .count();
4135        hits as f64 / self.impact_positions.len() as f64
4136    }
4137}
4138
4139fn wind_from_signed_speed_sample(
4140    signed_speed: f64,
4141    sampled_direction: f64,
4142    vertical_speed: f64,
4143) -> WindConditions {
4144    // The base wind's vertical component rides along un-dispersed: vertical wind is a
4145    // systematic input (MBA-728), not a sampled dispersion source. Dropping it here
4146    // would make every per-sample solve disagree with the baseline solve by the whole
4147    // vertical deflection — a phantom bias in the MC statistics.
4148    if signed_speed < 0.0 {
4149        WindConditions {
4150            speed: -signed_speed,
4151            direction: sampled_direction + std::f64::consts::PI,
4152            vertical_speed,
4153        }
4154    } else {
4155        WindConditions {
4156            speed: signed_speed,
4157            direction: sampled_direction,
4158            vertical_speed,
4159        }
4160    }
4161}
4162
4163struct MonteCarloWindSampler {
4164    speed: rand_distr::Normal<f64>,
4165    direction: rand_distr::Normal<f64>,
4166    /// Base wind's vertical component, carried into every sample un-dispersed.
4167    vertical_speed: f64,
4168}
4169
4170impl MonteCarloWindSampler {
4171    fn new(
4172        base_wind: &WindConditions,
4173        wind_speed_std_dev: f64,
4174        wind_direction_std_dev: f64,
4175    ) -> Result<Self, BallisticsError> {
4176        use rand_distr::Normal;
4177
4178        if !wind_direction_std_dev.is_finite() || wind_direction_std_dev < 0.0 {
4179            return Err("Wind direction standard deviation must be finite and non-negative".into());
4180        }
4181
4182        let speed = Normal::new(base_wind.speed, wind_speed_std_dev)
4183            .map_err(|e| format!("Invalid wind speed distribution: {e}"))?;
4184        let direction = Normal::new(base_wind.direction, wind_direction_std_dev)
4185            .map_err(|e| format!("Invalid wind direction distribution: {e}"))?;
4186        Ok(Self {
4187            speed,
4188            direction,
4189            vertical_speed: base_wind.vertical_speed,
4190        })
4191    }
4192
4193    fn sample<R: rand::Rng + ?Sized>(&self, rng: &mut R) -> WindConditions {
4194        use rand_distr::Distribution;
4195
4196        wind_from_signed_speed_sample(
4197            self.speed.sample(rng),
4198            self.direction.sample(rng),
4199            self.vertical_speed,
4200        )
4201    }
4202}
4203
4204/// One Monte Carlo trial's recorded outcome: the three quantities the legacy result vectors
4205/// hold, for one sample.
4206#[derive(Debug, Clone, Copy)]
4207struct TrialOutcome {
4208    /// Ground-impact range (m), recorded for every sample regardless of the target plane
4209    /// (MBA-967).
4210    range: f64,
4211    /// Ground-impact velocity (m/s), likewise recorded for every sample.
4212    impact_velocity: f64,
4213    /// Deviation from the baseline point of aim at the target plane, or
4214    /// `(0, TARGET_NOT_REACHED_SENTINEL_M, 0)` when this sample fell short of it.
4215    impact_position: Vector3<f64>,
4216}
4217
4218/// The per-trial context of a Monte Carlo run: the baseline solve, the resolved target plane
4219/// and the five input distributions, all built once and reused by every trial.
4220///
4221/// Extracted verbatim from the body of
4222/// [`run_monte_carlo_with_wind_and_direction_std_dev_using_rng`] so the adaptive driver
4223/// ([`run_monte_carlo_adaptive_seeded`]) runs the *identical* trial rather than a second copy
4224/// of it that could drift out of agreement.
4225///
4226/// # The draw order is a compatibility contract
4227///
4228/// [`Self::sample_one_trial`] consumes exactly six draws from the caller's RNG, in this order:
4229/// muzzle-velocity delta, muzzle angle, ballistic coefficient, azimuth, wind speed, wind
4230/// direction. Changing the count or the order shifts every subsequent sample of an
4231/// already-seeded run, so `monte_carlo_seeded_tests::legacy_seeded_estimates_are_pinned_bit_for_bit`
4232/// pins a committed seed's output bit-for-bit as the check on it.
4233///
4234/// The distributions are also *constructed* in the order the original inline code constructed
4235/// them. That is not cosmetic: construction is where an invalid standard deviation is
4236/// rejected, so the order decides which malformed parameter a caller is told about first.
4237struct MonteCarloTrialSampler {
4238    base_inputs: BallisticInputs,
4239    atmosphere: AtmosphericConditions,
4240    /// Which of `atmosphere`'s temperature and pressure were supplied; every solve uses it.
4241    station_presence: StationAtmospherePresence,
4242    solver_max_range: f64,
4243    /// Resolved target plane: `params.target_distance` when the caller gave one, else the
4244    /// baseline solve's own max range.
4245    target_distance: f64,
4246    /// Baseline (undispersed) position at `target_distance`. Every trial's impact position is
4247    /// a deviation from this point.
4248    baseline_at_target: Vector3<f64>,
4249    velocity_delta_dist: rand_distr::Normal<f64>,
4250    angle_dist: rand_distr::Normal<f64>,
4251    bc_dist: rand_distr::Normal<f64>,
4252    wind_sampler: MonteCarloWindSampler,
4253    azimuth_dist: rand_distr::Normal<f64>,
4254}
4255
4256impl MonteCarloTrialSampler {
4257    /// Solves the baseline trajectory and builds the input distributions.
4258    ///
4259    /// Consumes no randomness -- every RNG draw happens in [`Self::sample_one_trial`].
4260    fn new(
4261        base_inputs: BallisticInputs,
4262        base_wind: &WindConditions,
4263        params: &MonteCarloParams,
4264        wind_direction_std_dev: f64,
4265        station_presence: StationAtmospherePresence,
4266    ) -> Result<Self, BallisticsError> {
4267        use rand_distr::Normal;
4268
4269        let atmosphere = AtmosphericConditions {
4270            temperature: base_inputs.temperature,
4271            pressure: base_inputs.pressure,
4272            humidity: base_inputs.humidity_percent(),
4273            altitude: base_inputs.altitude,
4274        };
4275        let target_hint = params
4276            .target_distance
4277            .unwrap_or(base_inputs.target_distance);
4278        let solver_max_range = target_hint.max(1000.0) * 2.0;
4279
4280        // First, calculate baseline trajectory with no variations
4281        let mut baseline_solver = TrajectorySolver::new_with_station_presence(
4282            base_inputs.clone(),
4283            base_wind.clone(),
4284            atmosphere.clone(),
4285            station_presence,
4286        );
4287        baseline_solver.set_max_range(solver_max_range);
4288        let baseline_result = baseline_solver.solve()?;
4289
4290        // Determine target distance: use explicit target or baseline max range
4291        let target_distance = params.target_distance.unwrap_or(baseline_result.max_range);
4292
4293        // Get baseline position at target distance (interpolated)
4294        let baseline_at_target = baseline_result
4295            .position_at_range(target_distance)
4296            .ok_or("Could not interpolate baseline at target distance")?;
4297
4298        // Create normal distributions for variations
4299        // Sample muzzle velocity as a DELTA and apply it after TrajectorySolver::new resolves the
4300        // powder-temperature model. Sampling an absolute value here let a powder curve overwrite
4301        // every draw in the constructor, collapsing the requested dispersion to zero (MBA-1176).
4302        let velocity_delta_dist = Normal::new(0.0, params.velocity_std_dev)
4303            .map_err(|e| format!("Invalid velocity distribution: {}", e))?;
4304        let angle_dist = Normal::new(base_inputs.muzzle_angle, params.angle_std_dev)
4305            .map_err(|e| format!("Invalid angle distribution: {}", e))?;
4306        let bc_dist = Normal::new(base_inputs.bc_value, params.bc_std_dev)
4307            .map_err(|e| format!("Invalid BC distribution: {}", e))?;
4308        // Direction uncertainty is an independent angular quantity in radians. Do not derive it from
4309        // wind-speed uncertainty: meters/second cannot supply an angular standard deviation.
4310        let wind_sampler = MonteCarloWindSampler::new(
4311            base_wind,
4312            params.wind_speed_std_dev,
4313            wind_direction_std_dev,
4314        )?;
4315        let azimuth_dist = Normal::new(base_inputs.azimuth_angle, params.azimuth_std_dev)
4316            .map_err(|e| format!("Invalid azimuth distribution: {}", e))?;
4317
4318        Ok(Self {
4319            base_inputs,
4320            atmosphere,
4321            station_presence,
4322            solver_max_range,
4323            target_distance,
4324            baseline_at_target,
4325            velocity_delta_dist,
4326            angle_dist,
4327            bc_dist,
4328            wind_sampler,
4329            azimuth_dist,
4330        })
4331    }
4332
4333    /// Draws one trial: six RNG draws, one trajectory solve, one recorded outcome.
4334    ///
4335    /// `None` means the trial produced nothing to record -- either the solve failed, or it
4336    /// succeeded but the target plane could not be interpolated despite being within range (a
4337    /// defensive branch). Both were a bare `continue` in the original loop, i.e. the trial was
4338    /// dropped rather than counted as a miss, and callers must preserve that.
4339    ///
4340    /// All six draws happen *before* the solve, so a dropped trial still consumes exactly six
4341    /// draws and the RNG stream stays aligned with a run in which it succeeded.
4342    fn sample_one_trial<R: rand::Rng + ?Sized>(&self, rng: &mut R) -> Option<TrialOutcome> {
4343        use rand_distr::Distribution;
4344
4345        // Create varied inputs
4346        let mut inputs = self.base_inputs.clone();
4347        let muzzle_velocity_delta = self.velocity_delta_dist.sample(&mut *rng);
4348        inputs.muzzle_angle = self.angle_dist.sample(&mut *rng);
4349        inputs.bc_value = self.bc_dist.sample(&mut *rng).max(0.01);
4350        inputs.azimuth_angle = self.azimuth_dist.sample(&mut *rng); // Add horizontal variation
4351
4352        // Create varied wind (now based on base wind conditions)
4353        let wind = self.wind_sampler.sample(&mut *rng);
4354
4355        // Run trajectory. The sampled velocity delta is applied to the SOLVER's inputs, after
4356        // TrajectorySolver::new has resolved any powder-temperature model -- see the MBA-1176
4357        // note on `velocity_delta_dist` above.
4358        let mut solver = TrajectorySolver::new_with_station_presence(
4359            inputs,
4360            wind,
4361            self.atmosphere.clone(),
4362            self.station_presence,
4363        );
4364        solver.inputs.muzzle_velocity =
4365            (solver.inputs.muzzle_velocity + muzzle_velocity_delta).max(0.0);
4366        solver.set_max_range(self.solver_max_range);
4367        // Skip failed simulations
4368        let result = solver.solve().ok()?;
4369
4370        // MBA-967: do NOT skip samples that fall short of the target. range/velocity are
4371        // recorded at GROUND IMPACT for EVERY sample, so "Mean Range" is the ground-impact
4372        // distribution — independent of target_distance and consistent with `trajectory`.
4373        // All three result vectors still grow together per sample, so the equal-length FFI
4374        // ABI (exposed under one count) is preserved.
4375        let impact_position = if result.max_range < self.target_distance {
4376            // This sample never reached the target plane -> definite miss. Keep the
4377            // encoded miss finite but far outside any practical target radius.
4378            Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0)
4379        } else {
4380            // defensive: drop the whole sample (keeps the result vectors aligned)
4381            let pos_at_target = result.position_at_range(self.target_distance)?;
4382            // Deviation from baseline at the SAME target distance (McCoy): X = downrange
4383            // (0 here), Y = vertical (elevation), Z = lateral (windage). Muzzle-angle
4384            // sampling already models vertical pointing dispersion, so do not add a
4385            // second independent vertical pointing draw here.
4386            Vector3::new(
4387                0.0,
4388                pos_at_target.y - self.baseline_at_target.y,
4389                pos_at_target.z - self.baseline_at_target.z,
4390            )
4391        };
4392
4393        Some(TrialOutcome {
4394            range: result.max_range,
4395            impact_velocity: result.impact_velocity,
4396            impact_position,
4397        })
4398    }
4399}
4400
4401/// Run a Monte Carlo simulation (backwards compatibility).
4402///
4403/// Presence-blind: `base_inputs`' temperature and pressure go through the legacy
4404/// default-sentinel rule (see [`TrajectorySolver::new`]). See
4405/// [`run_monte_carlo_with_station_presence`].
4406pub fn run_monte_carlo(
4407    base_inputs: BallisticInputs,
4408    params: MonteCarloParams,
4409) -> Result<MonteCarloResults, BallisticsError> {
4410    run_monte_carlo_with_direction_std_dev(base_inputs, params, 0.0)
4411}
4412
4413/// Run Monte Carlo with an independent wind-direction standard deviation in radians.
4414///
4415/// The older [`run_monte_carlo`] entry point remains source compatible and delegates here with
4416/// zero direction uncertainty.
4417///
4418/// Presence-blind; see [`run_monte_carlo_with_station_presence`].
4419pub fn run_monte_carlo_with_direction_std_dev(
4420    base_inputs: BallisticInputs,
4421    params: MonteCarloParams,
4422    wind_direction_std_dev: f64,
4423) -> Result<MonteCarloResults, BallisticsError> {
4424    let base_wind = WindConditions {
4425        speed: params.base_wind_speed,
4426        direction: params.base_wind_direction,
4427        vertical_speed: 0.0,
4428    };
4429    run_monte_carlo_with_wind_and_direction_std_dev(
4430        base_inputs,
4431        base_wind,
4432        params,
4433        wind_direction_std_dev,
4434    )
4435}
4436
4437/// Run a Monte Carlo simulation with an explicit base wind.
4438///
4439/// Presence-blind; see [`run_monte_carlo_with_station_presence`].
4440pub fn run_monte_carlo_with_wind(
4441    base_inputs: BallisticInputs,
4442    base_wind: WindConditions,
4443    params: MonteCarloParams,
4444) -> Result<MonteCarloResults, BallisticsError> {
4445    run_monte_carlo_with_wind_and_direction_std_dev(base_inputs, base_wind, params, 0.0)
4446}
4447
4448/// Run Monte Carlo with explicit base wind and independent direction uncertainty in radians.
4449///
4450/// The older [`run_monte_carlo_with_wind`] entry point delegates here with zero direction
4451/// uncertainty, preserving its API while removing the former speed-to-angle unit conflation.
4452///
4453/// Presence-blind; see [`run_monte_carlo_with_station_presence`].
4454pub fn run_monte_carlo_with_wind_and_direction_std_dev(
4455    base_inputs: BallisticInputs,
4456    base_wind: WindConditions,
4457    params: MonteCarloParams,
4458    wind_direction_std_dev: f64,
4459) -> Result<MonteCarloResults, BallisticsError> {
4460    run_monte_carlo_with_station_presence(
4461        base_inputs,
4462        base_wind,
4463        params,
4464        wind_direction_std_dev,
4465        None,
4466        StationAtmospherePresence::NEITHER,
4467    )
4468}
4469
4470/// Run Monte Carlo knowing which of `base_inputs`' temperature and pressure the caller
4471/// supplied (0.49.0).
4472///
4473/// `seed: None` draws from the process-global RNG, as
4474/// [`run_monte_carlo_with_wind_and_direction_std_dev`] does; `Some(seed)` seeds a `StdRng`,
4475/// as [`run_monte_carlo_with_wind_and_direction_std_dev_seeded`] does. Every trial and the
4476/// baseline solve resolve the station air with `station_presence` (see
4477/// [`TrajectorySolver::new_with_station_presence`]): a supplied temperature or pressure flies
4478/// as given, an omitted one keeps the legacy default-sentinel rule. With
4479/// [`StationAtmospherePresence::NEITHER`] this is bit-identical to those two functions.
4480pub fn run_monte_carlo_with_station_presence(
4481    base_inputs: BallisticInputs,
4482    base_wind: WindConditions,
4483    params: MonteCarloParams,
4484    wind_direction_std_dev: f64,
4485    seed: Option<u64>,
4486    station_presence: StationAtmospherePresence,
4487) -> Result<MonteCarloResults, BallisticsError> {
4488    match seed {
4489        None => {
4490            let mut rng = rand::rng();
4491            run_monte_carlo_with_wind_and_direction_std_dev_using_rng(
4492                base_inputs,
4493                base_wind,
4494                params,
4495                wind_direction_std_dev,
4496                station_presence,
4497                &mut rng,
4498            )
4499        }
4500        Some(seed) => {
4501            use rand::{rngs::StdRng, SeedableRng};
4502            let mut rng = StdRng::seed_from_u64(seed);
4503            run_monte_carlo_with_wind_and_direction_std_dev_using_rng(
4504                base_inputs,
4505                base_wind,
4506                params,
4507                wind_direction_std_dev,
4508                station_presence,
4509                &mut rng,
4510            )
4511        }
4512    }
4513}
4514
4515/// Run Monte Carlo with an explicit PRNG seed, for deterministic/reproducible output.
4516///
4517/// Otherwise identical to [`run_monte_carlo_with_wind_and_direction_std_dev`], which draws
4518/// from the process-global RNG instead. Intended for callers that need repeatable results
4519/// across runs — e.g. tests, or a WEZ (Weapon Employment Zone, MBA-1317) sweep that a caller
4520/// wants to reproduce exactly while tuning target size or wind-call error.
4521///
4522/// Presence-blind; see [`run_monte_carlo_with_station_presence`].
4523pub fn run_monte_carlo_with_wind_and_direction_std_dev_seeded(
4524    base_inputs: BallisticInputs,
4525    base_wind: WindConditions,
4526    params: MonteCarloParams,
4527    wind_direction_std_dev: f64,
4528    seed: u64,
4529) -> Result<MonteCarloResults, BallisticsError> {
4530    run_monte_carlo_with_station_presence(
4531        base_inputs,
4532        base_wind,
4533        params,
4534        wind_direction_std_dev,
4535        Some(seed),
4536        StationAtmospherePresence::NEITHER,
4537    )
4538}
4539
4540fn run_monte_carlo_with_wind_and_direction_std_dev_using_rng<R: rand::Rng + ?Sized>(
4541    base_inputs: BallisticInputs,
4542    base_wind: WindConditions,
4543    params: MonteCarloParams,
4544    wind_direction_std_dev: f64,
4545    station_presence: StationAtmospherePresence,
4546    rng: &mut R,
4547) -> Result<MonteCarloResults, BallisticsError> {
4548    let mut ranges = Vec::new();
4549    let mut impact_velocities = Vec::new();
4550    let mut impact_positions = Vec::new();
4551
4552    let sampler = MonteCarloTrialSampler::new(
4553        base_inputs,
4554        &base_wind,
4555        &params,
4556        wind_direction_std_dev,
4557        station_presence,
4558    )?;
4559
4560    for _ in 0..params.num_simulations {
4561        // A dropped trial pushes nothing at all, exactly as the original `continue` arms did,
4562        // so all three vectors stay equal-length (the FFI exposes them under one count) --
4563        // they are simply shorter than `num_simulations`.
4564        if let Some(outcome) = sampler.sample_one_trial(rng) {
4565            ranges.push(outcome.range);
4566            impact_velocities.push(outcome.impact_velocity);
4567            impact_positions.push(outcome.impact_position);
4568        }
4569    }
4570
4571    if ranges.is_empty() {
4572        return Err("No successful simulations".into());
4573    }
4574
4575    Ok(MonteCarloResults {
4576        ranges,
4577        impact_velocities,
4578        impact_positions,
4579    })
4580}
4581
4582/// Schema version of [`AdaptiveMcReportV1`]'s JSON wire form.
4583pub const MC_ADAPTIVE_SCHEMA_VERSION_V1: u32 = 1;
4584
4585/// The value [`AdaptiveMcReportV1::method`] always carries: Robbins' beta-binomial mixture
4586/// confidence sequence, revision 1.
4587///
4588/// Named so a consumer can branch on the estimator rather than on the schema version -- the
4589/// schema can gain fields without the statistics changing, and the statistics could be
4590/// replaced without the field set changing.
4591pub const MC_ADAPTIVE_METHOD_V1: &str = "anytime_beta_binomial_mixture_cs_v1";
4592
4593/// The four disclosures every [`AdaptiveMcReportV1`] carries, in order.
4594///
4595/// These are stated in the payload itself rather than only in this crate's documentation,
4596/// because the payload is what travels: a consumer that renders a hit probability and an
4597/// interval has everything it needs to over-claim, and these four sentences are what stop it.
4598/// In order they disclose (0) that the interval covers sampling error only, not model error;
4599/// (1) that stopping early is legitimate here specifically because the sequence is
4600/// anytime-valid; (2) that the input dispersions are independent normals with no modeled
4601/// correlation; and (3) how the continuous statistics were accumulated and which denominator
4602/// the hit probability uses.
4603///
4604/// Pinned per-index, full-string, by
4605/// `monte_carlo_seeded_tests::adaptive_report_carries_schema_method_and_all_four_assumptions`.
4606pub const MC_ADAPTIVE_ASSUMPTIONS_V1: [&str; 4] = [
4607    "Sampling uncertainty only: intervals cover Monte Carlo sampling error, not model error in the trajectory solver or its inputs.",
4608    "Anytime-valid stopping: the beta-binomial mixture confidence sequence keeps its coverage guarantee despite stopping the moment the target half-width is met.",
4609    "Input dispersions are the independent normal distributions declared in MonteCarloParams; correlations between inputs are not modeled.",
4610    "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.",
4611];
4612
4613/// How precise the hit probability has to get, and how much sampling may be spent getting
4614/// there.
4615///
4616/// [`Default`] reproduces the legacy fixed-count run's sample size as the *floor*
4617/// (`min_samples: 1_000`, which is [`MonteCarloParams::default`]'s `num_simulations`) and then
4618/// keeps going, in batches, until the interval is tight enough or the ceiling is hit.
4619#[derive(Debug, Clone)]
4620pub struct McConvergence {
4621    /// Confidence level of the reported interval.
4622    pub level: ConfidenceLevel,
4623    /// Stop once the confidence sequence's half-width is at or below this, in probability
4624    /// units. `0.02` means "the hit probability is known to about +-2 percentage points".
4625    pub target_half_width: f64,
4626    /// Never stop before this many trials, even if the interval is already tight. Guards
4627    /// against an early run of all-hits or all-misses producing a narrow interval off a
4628    /// handful of trials.
4629    pub min_samples: u64,
4630    /// Never run more than this many trials, even if the interval is still wide. This is what
4631    /// bounds the wall time of an unreachable `target_half_width`.
4632    pub max_samples: u64,
4633    /// How many trials to run between convergence checks. Larger batches check less often (a
4634    /// `bounds()` call is two 200-step bisections); smaller batches can stop sooner.
4635    pub batch_size: u64,
4636}
4637
4638impl Default for McConvergence {
4639    fn default() -> Self {
4640        Self {
4641            level: ConfidenceLevel::P95,
4642            target_half_width: 0.02,
4643            min_samples: 1_000,
4644            max_samples: 100_000,
4645            batch_size: 500,
4646        }
4647    }
4648}
4649
4650impl McConvergence {
4651    /// Rejects a configuration that cannot produce a meaningful run, naming the offending
4652    /// field.
4653    ///
4654    /// Checked once up front rather than defended against inside the loop: a zero
4655    /// `batch_size` would spin forever making no progress, and a non-positive or `NaN`
4656    /// `target_half_width` can never be met, so both would surface as a hang or a silent
4657    /// max-samples cap instead of an error the caller can read. `NaN` is caught by the
4658    /// `is_finite` test -- `NaN <= 0.0` is `false`, so a bare comparison would let it through.
4659    pub fn validate(&self) -> Result<(), String> {
4660        if !self.target_half_width.is_finite() || self.target_half_width <= 0.0 {
4661            return Err(format!(
4662                "McConvergence.target_half_width must be a finite value greater than zero (got {})",
4663                self.target_half_width
4664            ));
4665        }
4666        if self.batch_size == 0 {
4667            return Err("McConvergence.batch_size must be greater than zero".to_string());
4668        }
4669        if self.max_samples == 0 {
4670            return Err("McConvergence.max_samples must be greater than zero".to_string());
4671        }
4672        if self.max_samples < self.min_samples {
4673            return Err(format!(
4674                "McConvergence.max_samples ({}) must be at least McConvergence.min_samples ({})",
4675                self.max_samples, self.min_samples
4676            ));
4677        }
4678        Ok(())
4679    }
4680}
4681
4682/// Why an adaptive run stopped.
4683#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
4684#[serde(rename_all = "snake_case")]
4685pub enum McStopReason {
4686    /// The confidence sequence reached [`McConvergence::target_half_width`]. The reported
4687    /// precision is the requested precision.
4688    TargetHalfWidthMet,
4689    /// [`McConvergence::max_samples`] was exhausted first. The interval is whatever that many
4690    /// trials bought -- read `ci_low`/`ci_high`, not just `hit_probability`.
4691    MaxSamplesReached,
4692}
4693
4694/// The result of a confidence-controlled Monte Carlo run.
4695///
4696/// Produced by [`run_monte_carlo_adaptive_seeded`]. Unlike [`MonteCarloResults`] this retains
4697/// no per-trial data at all: the continuous quantities arrive as streaming [`Welford`] moments
4698/// and the hit/miss counts as a running confidence sequence, so a 100,000-trial run costs the
4699/// same memory as a 1,000-trial one.
4700#[derive(Debug, Clone, serde::Serialize)]
4701pub struct AdaptiveMcReportV1 {
4702    /// Always [`MC_ADAPTIVE_SCHEMA_VERSION_V1`].
4703    pub schema_version: u32,
4704    /// Always [`MC_ADAPTIVE_METHOD_V1`].
4705    pub method: String,
4706    /// Always the four sentences of [`MC_ADAPTIVE_ASSUMPTIONS_V1`], in order.
4707    pub assumptions: Vec<String>,
4708    /// Confidence level of `ci_low`/`ci_high`, as a whole-number percentage.
4709    pub confidence_percent: u32,
4710    /// Point estimate `S / n`: hits over all trials, including trials that never reached the
4711    /// target plane.
4712    pub hit_probability: f64,
4713    /// Lower bound of the anytime-valid interval.
4714    pub ci_low: f64,
4715    /// Upper bound of the anytime-valid interval.
4716    pub ci_high: f64,
4717    /// Trials actually folded into the statistics (`n`).
4718    pub samples: u64,
4719    /// Trials *drawn*, including any the solver could not complete.
4720    ///
4721    /// `attempts - samples` is the drop count. It is normally zero, and equals `samples`
4722    /// whenever it is -- but without this field a run with a 40% solver-failure rate is
4723    /// indistinguishable in the payload from a clean one at the same `samples`, which is the
4724    /// one thing `samples` alone cannot tell you. `attempts` is also what
4725    /// [`McConvergence::max_samples`] caps, so [`McStopReason::MaxSamplesReached`] always has
4726    /// `attempts == max_samples` -- but the reverse does not hold: a
4727    /// [`McStopReason::TargetHalfWidthMet`] stop on the very last batch can reach the same
4728    /// equality, so `attempts == max_samples` alone does not identify the stop reason.
4729    pub attempts: u64,
4730    /// Trials that reached the target plane -- the population behind the three at-target
4731    /// statistics below, and the `n` in their `n-1` standard deviations.
4732    ///
4733    /// This is the magnitude behind the asymmetry `assumptions[3]` discloses qualitatively:
4734    /// `mean_drop_at_target_m`, `mean_wind_drift_at_target_m` and `mean_impact_velocity_mps`
4735    /// describe these `arrivals` trials, while `hit_probability` is over all `samples` (a
4736    /// trial that fell short is a definite miss, so it is in that denominator but has no
4737    /// measured position to contribute). With `arrivals` well below `samples`, the at-target
4738    /// statistics describe only part of the run and this field is what says so.
4739    ///
4740    /// The fixed-count path exposes the same information as
4741    /// [`MonteCarloResults::target_arrival_count`] and
4742    /// [`MonteCarloResults::target_shortfall_fraction`]; `samples - arrivals` is the shortfall
4743    /// count and `1 - arrivals / samples` the shortfall fraction.
4744    pub arrivals: u64,
4745    /// Why the run stopped. Serializes as `"target_half_width_met"` / `"max_samples_reached"`.
4746    pub stop_reason: McStopReason,
4747    /// Hit-zone radius the probability was computed against.
4748    pub hit_radius_m: f64,
4749    /// Resolved target plane: `params.target_distance` if given, else the baseline's max range.
4750    pub target_distance_m: f64,
4751    /// Mean ground-impact velocity over trials that reached the target plane.
4752    ///
4753    /// The velocity is the one the legacy path records: speed at *ground impact*, not at the
4754    /// target plane (MBA-967). Only the conditioning is on reaching the plane.
4755    pub mean_impact_velocity_mps: f64,
4756    /// Sample (`n-1`) standard deviation of the above. `0.0` for fewer than two such trials.
4757    pub std_impact_velocity_mps: f64,
4758    /// Mean *vertical deviation from the baseline point of aim* at the target plane, in
4759    /// meters, positive high.
4760    ///
4761    /// Named "drop" for continuity with the rest of the API, but it is a deviation from the
4762    /// undispersed baseline, not a drop from the bore line: with symmetric input dispersions
4763    /// its expectation is near zero and the informative number is
4764    /// [`std_drop_at_target_m`](Self::std_drop_at_target_m).
4765    pub mean_drop_at_target_m: f64,
4766    /// Sample (`n-1`) standard deviation of the vertical deviation -- the run's vertical
4767    /// dispersion at the target.
4768    pub std_drop_at_target_m: f64,
4769    /// Mean lateral deviation from the baseline point of aim at the target plane, in meters.
4770    /// The same "deviation, not absolute" caveat as `mean_drop_at_target_m` applies.
4771    pub mean_wind_drift_at_target_m: f64,
4772    /// Sample (`n-1`) standard deviation of the lateral deviation -- the run's horizontal
4773    /// dispersion at the target.
4774    pub std_wind_drift_at_target_m: f64,
4775}
4776
4777/// Runs a Monte Carlo hit-probability estimate that decides its own sample size, from an
4778/// explicit seed.
4779///
4780/// # What this buys over the fixed-count path
4781///
4782/// [`run_monte_carlo_with_wind_and_direction_std_dev_seeded`] runs exactly
4783/// `params.num_simulations` trials and reports a point estimate; whether that count was enough
4784/// is left to the caller to guess. This instead runs until the answer is as precise as the
4785/// caller asked for, and reports the achieved precision either way. **`params.num_simulations`
4786/// is ignored here** -- the sample count comes from `convergence`, and
4787/// [`McConvergence::min_samples`] defaults to the same `1_000` so the floor matches the legacy
4788/// default.
4789///
4790/// # Why the interval is anytime-valid
4791///
4792/// Stopping when the interval looks tight enough is optional stopping, and a fixed-`n`
4793/// interval (Wilson, Wald, anything) checked repeatedly that way has no coverage guarantee:
4794/// the error rate grows with the number of peeks. [`BernoulliConfidenceSequence`] is instead
4795/// valid at every `n` simultaneously, so a data-dependent stopping rule is legitimate. The
4796/// price is a strictly wider interval at any given `n` than the fixed-`n`
4797/// [`MonteCarloResults::hit_probability_wilson`] would report on the same counts. Paying it is
4798/// the point.
4799///
4800/// # The trial is the same trial
4801///
4802/// Each trial is `MonteCarloTrialSampler::sample_one_trial` (crate-internal), the same body
4803/// and the same six-draw sequence the legacy loop runs, and the hit test is
4804/// [`MonteCarloResults::position_is_hit`], the same predicate
4805/// [`MonteCarloResults::hit_probability`] counts with. A trial that never reached the target
4806/// plane is a definite miss and stays in the denominator, exactly as it does there. Wind
4807/// direction is not dispersed: [`MonteCarloParams`] has no direction-sigma field, so this
4808/// passes `0.0`, matching [`run_monte_carlo_with_wind`].
4809///
4810/// # Sample accounting
4811///
4812/// Trials are run in batches of [`McConvergence::batch_size`] (the last batch truncated so the
4813/// ceiling is never overshot), and the stopping rule is evaluated after each batch: stop with
4814/// [`McStopReason::TargetHalfWidthMet`] once at least `min_samples` trials are in *and* the
4815/// half-width is at or below `target_half_width`; stop with
4816/// [`McStopReason::MaxSamplesReached`] once `max_samples` trials have been attempted.
4817///
4818/// The report carries three cardinalities, and they are three different numbers:
4819///
4820/// * `attempts` -- trials drawn. This is what `max_samples` caps.
4821/// * `samples` -- trials that produced an outcome, i.e. the `n` behind `hit_probability` and
4822///   the confidence interval.
4823/// * `arrivals` -- trials that reached the target plane, i.e. the `n` behind the three
4824///   at-target statistics.
4825///
4826/// `attempts >= samples >= arrivals` always. A trial whose solve fails is dropped rather than
4827/// counted as a miss -- the legacy loop's behaviour, preserved so the two paths cannot
4828/// disagree about what a solver failure means -- but it still consumes an attempt, so it
4829/// shows up as `attempts > samples`. With no dropped trials the two are equal, which is the
4830/// normal case; when they differ, `samples` can finish below `min_samples`, and the honest
4831/// report of that is `MaxSamplesReached` with the smaller `n` and the correspondingly wider
4832/// interval. A trial that solved but fell short of the target plane is a definite miss: it is
4833/// a sample (in `hit_probability`'s denominator) but not an arrival, so it shows up as
4834/// `samples > arrivals`. A run in which *every* trial was dropped is an error, not a report.
4835///
4836/// # `hit_radius_m` is not validated
4837///
4838/// Every [`McConvergence`] field is `Err`-checked up front, but `hit_radius_m` itself is not:
4839/// a `NaN` or negative radius makes [`MonteCarloResults::position_is_hit`] false for every
4840/// trial, yielding `p = 0.0` with a tight interval and (usually) a
4841/// [`McStopReason::TargetHalfWidthMet`] stop rather than an error. This is deliberate, not an
4842/// oversight -- it matches [`MonteCarloResults::hit_probability`]'s equally lenient legacy
4843/// posture on the same input, so this path cannot diverge from the fixed-count one over how a
4844/// bad radius is treated.
4845///
4846/// # Errors
4847///
4848/// Returns `Err` if `convergence` is not usable ([`McConvergence::validate`], which names the
4849/// offending field), if the baseline solve or an input distribution is invalid, or if no trial
4850/// at all produced an outcome.
4851///
4852/// Presence-blind: `base_inputs`' temperature and pressure go through the legacy
4853/// default-sentinel rule. See [`run_monte_carlo_adaptive_seeded_with_station_presence`].
4854pub fn run_monte_carlo_adaptive_seeded(
4855    base_inputs: &BallisticInputs,
4856    base_wind: &WindConditions,
4857    params: &MonteCarloParams,
4858    convergence: &McConvergence,
4859    hit_radius_m: f64,
4860    seed: u64,
4861) -> Result<AdaptiveMcReportV1, String> {
4862    run_monte_carlo_adaptive_seeded_with_station_presence(
4863        base_inputs,
4864        base_wind,
4865        params,
4866        convergence,
4867        hit_radius_m,
4868        seed,
4869        StationAtmospherePresence::NEITHER,
4870    )
4871}
4872
4873/// [`run_monte_carlo_adaptive_seeded`] knowing which of `base_inputs`' temperature and
4874/// pressure the caller supplied (0.49.0). A supplied value flies as given, an omitted one keeps
4875/// the legacy default-sentinel rule; [`StationAtmospherePresence::NEITHER`] is bit-identical to
4876/// [`run_monte_carlo_adaptive_seeded`].
4877pub fn run_monte_carlo_adaptive_seeded_with_station_presence(
4878    base_inputs: &BallisticInputs,
4879    base_wind: &WindConditions,
4880    params: &MonteCarloParams,
4881    convergence: &McConvergence,
4882    hit_radius_m: f64,
4883    seed: u64,
4884    station_presence: StationAtmospherePresence,
4885) -> Result<AdaptiveMcReportV1, String> {
4886    use rand::{rngs::StdRng, SeedableRng};
4887
4888    convergence.validate()?;
4889
4890    // `wind_direction_std_dev = 0.0`: MonteCarloParams carries no direction sigma, so this
4891    // matches `run_monte_carlo_with_wind`'s treatment rather than inventing one.
4892    let sampler = MonteCarloTrialSampler::new(
4893        base_inputs.clone(),
4894        base_wind,
4895        params,
4896        0.0,
4897        station_presence,
4898    )
4899    .map_err(|e| e.to_string())?;
4900
4901    let mut rng = StdRng::seed_from_u64(seed);
4902    let mut hits_cs = BernoulliConfidenceSequence::new(convergence.level);
4903    let mut impact_velocity = Welford::new();
4904    let mut drop_at_target = Welford::new();
4905    let mut drift_at_target = Welford::new();
4906
4907    let mut attempts: u64 = 0;
4908    let mut stop_reason = McStopReason::MaxSamplesReached;
4909
4910    while attempts < convergence.max_samples {
4911        // Truncate the final batch so `max_samples` is a ceiling on attempts, not a threshold
4912        // the last batch may overshoot.
4913        let batch = convergence
4914            .batch_size
4915            .min(convergence.max_samples - attempts);
4916        let mut batch_hits: u64 = 0;
4917        let mut batch_trials: u64 = 0;
4918
4919        for _ in 0..batch {
4920            attempts += 1;
4921            let Some(outcome) = sampler.sample_one_trial(&mut rng) else {
4922                continue; // dropped trial: not a miss, not a sample (see the legacy loop)
4923            };
4924            batch_trials += 1;
4925            if MonteCarloResults::position_is_hit(&outcome.impact_position, hit_radius_m) {
4926                batch_hits += 1;
4927            }
4928            // Continuous statistics are conditioned on arrival: a shortfall marker is a
4929            // placeholder, not a measured position, and folding it in would drag the moments
4930            // toward TARGET_NOT_REACHED_SENTINEL_M. It still counts as a miss above.
4931            if MonteCarloResults::position_reached_target(&outcome.impact_position) {
4932                impact_velocity.push(outcome.impact_velocity);
4933                drop_at_target.push(outcome.impact_position.y);
4934                drift_at_target.push(outcome.impact_position.z);
4935            }
4936        }
4937
4938        hits_cs.update_batch(batch_hits, batch_trials);
4939
4940        if hits_cs.trials() >= convergence.min_samples
4941            && hits_cs.half_width() <= convergence.target_half_width
4942        {
4943            stop_reason = McStopReason::TargetHalfWidthMet;
4944            break;
4945        }
4946    }
4947
4948    let samples = hits_cs.trials();
4949    if samples == 0 {
4950        return Err("No successful simulations".to_string());
4951    }
4952    let (ci_low, ci_high) = hits_cs.bounds();
4953
4954    Ok(AdaptiveMcReportV1 {
4955        schema_version: MC_ADAPTIVE_SCHEMA_VERSION_V1,
4956        method: MC_ADAPTIVE_METHOD_V1.to_string(),
4957        assumptions: MC_ADAPTIVE_ASSUMPTIONS_V1
4958            .iter()
4959            .map(|s| s.to_string())
4960            .collect(),
4961        confidence_percent: convergence.level.as_percent(),
4962        hit_probability: hits_cs.successes() as f64 / samples as f64,
4963        ci_low,
4964        ci_high,
4965        samples,
4966        attempts,
4967        // All three at-target accumulators are fed under one guard, in one block, so their
4968        // counts are equal by construction; reading the population off one of them binds this
4969        // field to the actual Welford `n` rather than to a parallel counter that could drift.
4970        arrivals: drop_at_target.count(),
4971        stop_reason,
4972        hit_radius_m,
4973        target_distance_m: sampler.target_distance,
4974        mean_impact_velocity_mps: impact_velocity.mean(),
4975        std_impact_velocity_mps: impact_velocity.sample_std(),
4976        mean_drop_at_target_m: drop_at_target.mean(),
4977        std_drop_at_target_m: drop_at_target.sample_std(),
4978        mean_wind_drift_at_target_m: drift_at_target.mean(),
4979        std_wind_drift_at_target_m: drift_at_target.sample_std(),
4980    })
4981}
4982
4983// Calculate zero angle for a target
4984pub fn calculate_zero_angle(
4985    inputs: BallisticInputs,
4986    target_distance: f64,
4987    target_height: f64,
4988) -> Result<f64, BallisticsError> {
4989    calculate_zero_angle_with_conditions(
4990        inputs,
4991        target_distance,
4992        target_height,
4993        WindConditions::default(),
4994        AtmosphericConditions::default(),
4995    )
4996}
4997
4998/// Solve the bore angle that crosses the line of sight at `target_distance`.
4999///
5000/// Presence-blind: `atmosphere` goes through the legacy default-sentinel rule (see
5001/// [`TrajectorySolver::new`]). See [`calculate_zero_angle_with_station_presence`].
5002pub fn calculate_zero_angle_with_conditions(
5003    inputs: BallisticInputs,
5004    target_distance: f64,
5005    target_height: f64,
5006    wind: WindConditions,
5007    atmosphere: AtmosphericConditions,
5008) -> Result<f64, BallisticsError> {
5009    calculate_zero_angle_with_station_presence(
5010        inputs,
5011        target_distance,
5012        target_height,
5013        wind,
5014        atmosphere,
5015        StationAtmospherePresence::NEITHER,
5016    )
5017}
5018
5019/// [`calculate_zero_angle_with_conditions`] knowing which of `atmosphere`'s temperature and
5020/// pressure the caller supplied (0.49.0); see [`TrajectorySolver::new_with_station_presence`].
5021/// [`StationAtmospherePresence::NEITHER`] is bit-identical to the presence-blind function, and
5022/// [`StationAtmospherePresence::BOTH`] to [`calculate_zero_angle_with_resolved_conditions`].
5023pub fn calculate_zero_angle_with_station_presence(
5024    inputs: BallisticInputs,
5025    target_distance: f64,
5026    target_height: f64,
5027    wind: WindConditions,
5028    atmosphere: AtmosphericConditions,
5029    station_presence: StationAtmospherePresence,
5030) -> Result<f64, BallisticsError> {
5031    let mut solver =
5032        TrajectorySolver::new_with_station_presence(inputs, wind, atmosphere, station_presence);
5033    solver.calculate_and_set_zero_angle(target_distance, target_height, ZeroTargetFrame::SightLine)
5034}
5035
5036/// [`calculate_zero_angle_with_conditions`] for a presence-aware caller that has already
5037/// resolved station temperature/pressure (MBA-1397; e.g. reduced a declared QNH via
5038/// [`crate::atmosphere::resolve_station_conditions_with_presence`]). Unlike the base
5039/// function, `atmosphere`'s temperature/pressure are trusted as-is and never re-interpreted
5040/// through the legacy default-sentinel heuristic.
5041pub fn calculate_zero_angle_with_resolved_conditions(
5042    inputs: BallisticInputs,
5043    target_distance: f64,
5044    target_height: f64,
5045    wind: WindConditions,
5046    atmosphere: AtmosphericConditions,
5047) -> Result<f64, BallisticsError> {
5048    let mut solver =
5049        TrajectorySolver::new_with_resolved_station_atmosphere(inputs, wind, atmosphere);
5050    solver.calculate_and_set_zero_angle(target_distance, target_height, ZeroTargetFrame::SightLine)
5051}
5052
5053/// Generous solve envelope for [`calculate_zero_range_from_angle_with_conditions`] /
5054/// [`calculate_zero_range_from_angle_with_resolved_conditions`] (MBA-1402): a "zero" bore angle
5055/// is a near-horizontal shot, so its far line-of-sight crossing sits well inside this even at
5056/// extended small-arms ranges. Double the engine's own default 1000 m solve envelope
5057/// ([`TrajectorySolver::new`]) rather than something scaled to an (unknown, being solved for)
5058/// target distance; `solve()` still stops at ground impact well before this in the typical
5059/// case, so the larger envelope costs effectively nothing.
5060pub const ZERO_RANGE_FROM_ANGLE_MAX_RANGE_M: f64 = 2000.0;
5061
5062/// Solve the zero RANGE(S) that a fixed bore angle produces. Runs the trajectory at
5063/// `zero_angle_rad` and returns BOTH line-of-sight crossings it finds, as
5064/// [`ZeroCrossings`] — a rifle sighted above the bore generally crosses a level line of
5065/// sight twice on a rising shot (near, ascending, close to the muzzle; far, descending past
5066/// the apex).
5067///
5068/// **This is NOT the exact inverse of [`calculate_zero_angle_with_conditions`].** A single
5069/// bore angle generally implies two valid zero distances (the classic 25/300-yard
5070/// battle-zero relationship is exactly this: one angle, two zeros), and
5071/// [`calculate_zero_angle_with_conditions`]'s own choice of root depends on which target
5072/// distance it was asked to solve for. Round-tripping a solved angle back through this
5073/// function recovers the ORIGINAL distance as one of the two returned crossings, not
5074/// necessarily as `far_m` specifically — for short/flat zeros the forward solver's answer is
5075/// often the NEAR crossing.
5076///
5077/// Presence-blind: `atmosphere` goes through the legacy default-sentinel rule. See
5078/// [`calculate_zero_range_from_angle_with_station_presence`].
5079pub fn calculate_zero_range_from_angle_with_conditions(
5080    inputs: BallisticInputs,
5081    zero_angle_rad: f64,
5082    target_height: f64,
5083    wind: WindConditions,
5084    atmosphere: AtmosphericConditions,
5085) -> Result<ZeroCrossings, BallisticsError> {
5086    calculate_zero_range_from_angle_with_station_presence(
5087        inputs,
5088        zero_angle_rad,
5089        target_height,
5090        wind,
5091        atmosphere,
5092        StationAtmospherePresence::NEITHER,
5093    )
5094}
5095
5096/// [`calculate_zero_range_from_angle_with_conditions`] knowing which of `atmosphere`'s
5097/// temperature and pressure the caller supplied (0.49.0); see
5098/// [`TrajectorySolver::new_with_station_presence`]. [`StationAtmospherePresence::NEITHER`] is
5099/// bit-identical to the presence-blind function.
5100pub fn calculate_zero_range_from_angle_with_station_presence(
5101    inputs: BallisticInputs,
5102    zero_angle_rad: f64,
5103    target_height: f64,
5104    wind: WindConditions,
5105    atmosphere: AtmosphericConditions,
5106    station_presence: StationAtmospherePresence,
5107) -> Result<ZeroCrossings, BallisticsError> {
5108    let mut solver =
5109        TrajectorySolver::new_with_station_presence(inputs, wind, atmosphere, station_presence);
5110    solver.set_max_range(ZERO_RANGE_FROM_ANGLE_MAX_RANGE_M);
5111    solver.find_zero_range(zero_angle_rad, target_height, ZeroTargetFrame::SightLine)
5112}
5113
5114/// [`calculate_zero_range_from_angle_with_conditions`] for a presence-aware caller that has
5115/// already resolved station temperature/pressure — same relationship as
5116/// [`calculate_zero_angle_with_resolved_conditions`] has to
5117/// [`calculate_zero_angle_with_conditions`] (MBA-1397 pattern). See that function's doc
5118/// comment for why this returns both crossings rather than a single "the" zero range.
5119pub fn calculate_zero_range_from_angle_with_resolved_conditions(
5120    inputs: BallisticInputs,
5121    zero_angle_rad: f64,
5122    target_height: f64,
5123    wind: WindConditions,
5124    atmosphere: AtmosphericConditions,
5125) -> Result<ZeroCrossings, BallisticsError> {
5126    let mut solver =
5127        TrajectorySolver::new_with_resolved_station_atmosphere(inputs, wind, atmosphere);
5128    solver.set_max_range(ZERO_RANGE_FROM_ANGLE_MAX_RANGE_M);
5129    solver.find_zero_range(zero_angle_rad, target_height, ZeroTargetFrame::SightLine)
5130}
5131
5132/// What a BC estimate is fit against.
5133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5134pub enum BcFitMode {
5135    /// Data points are `(distance_m, drop_m)` — the classic drop-curve fit.
5136    Drop,
5137    /// Data points are `(distance_m, velocity_mps)` — a velocity-retention fit,
5138    /// which is immune to zero / sight-height / launch-angle error.
5139    Velocity,
5140}
5141
5142/// The result of a single BC fit (one drag model, one fit basis).
5143#[derive(Debug, Clone, Copy)]
5144pub struct BcEstimate {
5145    /// The estimated ballistic coefficient.
5146    pub bc: f64,
5147    /// RMS residual across the data points, in fit units (meters of drop, or m/s of speed).
5148    pub rms_error: f64,
5149    /// Which standard drag model this BC is referenced to.
5150    pub drag_model: DragModel,
5151    /// Whether the fit was against drop or velocity data.
5152    pub mode: BcFitMode,
5153    /// True if the best fit landed at the edge of the physical BC search range — i.e. the
5154    /// data did not pin down an interior optimum (too sparse/short-range, or wrong units /
5155    /// atmosphere / zero). The reported `bc` is then a floor/ceiling, not a real estimate.
5156    pub at_bound: bool,
5157}
5158
5159/// Interpolate the fitted quantity (drop in meters, or speed in m/s) at a downrange
5160/// distance from a solved trajectory. `None` if the trajectory never reaches `target_dist`.
5161///
5162/// `drop_offset` is subtracted-from convention: for `Drop` the returned value is
5163/// `drop_offset - y`. With `drop_offset = 0` this is bore-referenced drop (flat fire);
5164/// with `drop_offset = sight_height` and a zeroed trajectory it is drop below the
5165/// (horizontal) line of sight — i.e. dope-card drop.
5166fn fit_value_at(
5167    points: &[TrajectoryPoint],
5168    target_dist: f64,
5169    mode: BcFitMode,
5170    drop_offset: f64,
5171) -> Option<f64> {
5172    let val = |p: &TrajectoryPoint| match mode {
5173        BcFitMode::Drop => drop_offset - p.position.y,
5174        BcFitMode::Velocity => p.velocity_magnitude,
5175    };
5176    for i in 0..points.len() {
5177        if points[i].position.x >= target_dist {
5178            if i == 0 {
5179                return Some(val(&points[0]));
5180            }
5181            let p1 = &points[i - 1];
5182            let p2 = &points[i];
5183            let dx = p2.position.x - p1.position.x;
5184            if dx.abs() < 1e-9 {
5185                return Some(val(p2));
5186            }
5187            let t = (target_dist - p1.position.x) / dx;
5188            return Some(val(p1) + t * (val(p2) - val(p1)));
5189        }
5190    }
5191    None
5192}
5193
5194fn fit_residual_sse(
5195    trajectory: &[TrajectoryPoint],
5196    observations: &[(f64, f64)],
5197    mode: BcFitMode,
5198    drop_offset: f64,
5199) -> Option<f64> {
5200    if observations.is_empty() {
5201        return None;
5202    }
5203    let mut total = 0.0;
5204    for (target_dist, target_val) in observations {
5205        // Scores are comparable only when every candidate contains every residual term.
5206        // Reject a trajectory that terminates before even one observation (MBA-1178).
5207        let value = fit_value_at(trajectory, *target_dist, mode, drop_offset)?;
5208        let error = value - target_val;
5209        total += error * error;
5210    }
5211    Some(total)
5212}
5213
5214/// Estimate a BC by fitting a simulated trajectory to measured data, for a chosen drag
5215/// model (G1, G7, …) and fit basis (drop or velocity). Uses a coarse 0.01 sweep over
5216/// plausible BCs followed by a 0.001 local refine around the coarse best.
5217///
5218/// `points` are `(distance_m, value_m_or_mps)` where the second element is drop in meters
5219/// (`BcFitMode::Drop`) or remaining speed in m/s (`BcFitMode::Velocity`).
5220///
5221/// The fit runs under `atmosphere` — BC is only meaningful relative to the air density the
5222/// data was measured at, so this must match the conditions the drop/velocity came from
5223/// (pass ICAO standard for a standard-atmosphere dope card).
5224///
5225/// `zero_range` selects the drop reference frame (ignored for velocity fits):
5226/// - `None` → **bore-referenced**: flat 0° fire, drop below the extended bore axis.
5227/// - `Some(range_m)` → **sight/dope-card-referenced**: the trajectory is zeroed at
5228///   `range_m` (using `sight_height`), and drop is measured below the horizontal line of
5229///   sight — i.e. exactly what a dope card zeroed at that range prints.
5230///
5231/// Presence-blind: `atmosphere` goes through the legacy default-sentinel rule. See
5232/// [`estimate_bc_fit_with_station_presence`].
5233#[allow(clippy::too_many_arguments)] // Public compatibility API; grouping would be breaking.
5234pub fn estimate_bc_fit(
5235    velocity: f64,
5236    mass: f64,
5237    diameter: f64,
5238    points: &[(f64, f64)],
5239    drag_model: DragModel,
5240    mode: BcFitMode,
5241    atmosphere: AtmosphericConditions,
5242    zero_range: Option<f64>,
5243    sight_height: f64,
5244) -> Result<BcEstimate, BallisticsError> {
5245    estimate_bc_fit_with_station_presence(
5246        velocity,
5247        mass,
5248        diameter,
5249        points,
5250        drag_model,
5251        mode,
5252        atmosphere,
5253        zero_range,
5254        sight_height,
5255        StationAtmospherePresence::NEITHER,
5256    )
5257}
5258
5259/// [`estimate_bc_fit`] knowing which of `atmosphere`'s temperature and pressure the caller
5260/// supplied (0.49.0): a supplied value is the air the data was shot in, whatever its value.
5261/// [`StationAtmospherePresence::NEITHER`] is bit-identical to [`estimate_bc_fit`].
5262#[allow(clippy::too_many_arguments)] // Mirrors estimate_bc_fit plus the presence.
5263pub fn estimate_bc_fit_with_station_presence(
5264    velocity: f64,
5265    mass: f64,
5266    diameter: f64,
5267    points: &[(f64, f64)],
5268    drag_model: DragModel,
5269    mode: BcFitMode,
5270    atmosphere: AtmosphericConditions,
5271    zero_range: Option<f64>,
5272    sight_height: f64,
5273    station_presence: StationAtmospherePresence,
5274) -> Result<BcEstimate, BallisticsError> {
5275    if points.is_empty() {
5276        return Err(BallisticsError::from(
5277            "No data points provided for BC estimation.".to_string(),
5278        ));
5279    }
5280    let max_dist = points.iter().map(|(d, _)| *d).fold(0.0_f64, f64::max);
5281    // For a zeroed drop fit, drop is below the horizontal LOS which sits `sight_height`
5282    // above the bore at the muzzle: drop = sight_height - y. Bore-referenced fits use 0.
5283    let drop_offset = if zero_range.is_some() {
5284        sight_height
5285    } else {
5286        0.0
5287    };
5288
5289    // Sum of squared residuals for a trial BC; None unless the solve reaches ALL data points.
5290    let sse = |bc_value: f64| -> Option<f64> {
5291        let mut inputs = BallisticInputs {
5292            muzzle_velocity: velocity,
5293            bc_value,
5294            bc_type: drag_model,
5295            bullet_mass: mass,
5296            bullet_diameter: diameter,
5297            sight_height,
5298            ..Default::default()
5299        };
5300        // Zeroed fit: tilt the bore so the bullet crosses LOS at the zero range, so the
5301        // downrange drops match a dope card zeroed there. Bore fit leaves muzzle_angle = 0.
5302        if let Some(zr) = zero_range {
5303            // MBA-1130: zero to the LINE OF SIGHT (y = sight_height) at the zero range,
5304            // not the bore line (y = 0). Drop is measured as `drop_offset - y` with
5305            // drop_offset = sight_height, so a bore-referenced zero left drop != 0 at the
5306            // zero range and the drop-fit no longer round-tripped to the true BC. This
5307            // matches how range-table / come-up / dope-card generation zero.
5308            let za = calculate_zero_angle_with_station_presence(
5309                inputs.clone(),
5310                zr,
5311                sight_height,
5312                WindConditions::default(),
5313                atmosphere.clone(),
5314                station_presence,
5315            )
5316            .ok()?;
5317            inputs.muzzle_angle = za;
5318        }
5319        let mut solver = TrajectorySolver::new_with_station_presence(
5320            inputs,
5321            WindConditions::default(),
5322            atmosphere.clone(),
5323            station_presence,
5324        );
5325        solver.set_max_range(max_dist * 1.5);
5326        let result = solver.solve().ok()?;
5327        fit_residual_sse(&result.points, points, mode, drop_offset)
5328    };
5329
5330    // Physical BC search range, per drag model. Real G7 BCs top out well under 0.5 (0.7 is
5331    // a generous ceiling); G1 BCs run higher. Keeping G7 out of G1 territory means a fit
5332    // that runs to the ceiling reports a sane bound, not a nonsensical 1.2.
5333    let (bc_min, bc_max) = match drag_model {
5334        DragModel::G7 => (0.05, 0.70),
5335        _ => (0.10, 1.20),
5336    };
5337
5338    // Coarse sweep across the physical range.
5339    let mut best_bc = f64::NAN;
5340    let mut best_sse = f64::MAX;
5341    let mut bc = bc_min;
5342    while bc <= bc_max + 1e-9 {
5343        if let Some(s) = sse(bc) {
5344            if s < best_sse {
5345                best_sse = s;
5346                best_bc = bc;
5347            }
5348        }
5349        bc += 0.01;
5350    }
5351    if !best_bc.is_finite() {
5352        return Err(BallisticsError::from(
5353            "Unable to estimate BC from provided data. Check that the values and units are correct."
5354                .to_string(),
5355        ));
5356    }
5357
5358    // Local refine at 0.001 resolution around the coarse best (kept within the range).
5359    let lo = (best_bc - 0.01).max(bc_min);
5360    let hi = (best_bc + 0.01).min(bc_max);
5361    let mut bc = lo;
5362    while bc <= hi + 1e-9 {
5363        if let Some(s) = sse(bc) {
5364            if s < best_sse {
5365                best_sse = s;
5366                best_bc = bc;
5367            }
5368        }
5369        bc += 0.001;
5370    }
5371
5372    // A solution sitting on the search boundary means the data didn't determine an interior
5373    // optimum — the fit ran to the floor/ceiling. Flag it so callers don't trust the number.
5374    let at_bound = best_bc <= bc_min + 0.011 || best_bc >= bc_max - 0.011;
5375    // fit_residual_sse rejects partial trajectories, so best_sse contains exactly one residual
5376    // per input point and this denominator is also the honest matched-point count.
5377    let rms_error = (best_sse / points.len() as f64).sqrt();
5378    Ok(BcEstimate {
5379        bc: best_bc,
5380        rms_error,
5381        drag_model,
5382        mode,
5383        at_bound,
5384    })
5385}
5386
5387/// Estimate a G1 BC from a drop curve. Back-compatible wrapper over [`estimate_bc_fit`];
5388/// `points` are `(distance_m, drop_m)`.
5389pub fn estimate_bc_from_trajectory(
5390    velocity: f64,
5391    mass: f64,
5392    diameter: f64,
5393    points: &[(f64, f64)], // (distance, drop) pairs
5394) -> Result<f64, BallisticsError> {
5395    estimate_bc_fit(
5396        velocity,
5397        mass,
5398        diameter,
5399        points,
5400        DragModel::G1,
5401        BcFitMode::Drop,
5402        AtmosphericConditions::default(),
5403        None,
5404        0.05,
5405    )
5406    .map(|e| e.bc)
5407}
5408
5409// Add rand dependencies for Monte Carlo
5410use rand;
5411use rand_distr;
5412
5413#[cfg(test)]
5414mod mba737_powder_resolution_tests {
5415    use super::*;
5416
5417    #[test]
5418    fn linear_model_cold_powder_subtracts() {
5419        // 0.5486 m/s per degC (1 fps/degF), shot day 10 C below the 21.1 C reference.
5420        let v = resolve_powder_adjusted_velocity(823.0, 11.1, true, 0.5486, 21.1, None, None);
5421        assert!((v - (823.0 + 0.5486 * (11.1 - 21.1))).abs() < 1e-12);
5422        assert!(v < 823.0);
5423    }
5424
5425    #[test]
5426    fn linear_model_hot_powder_adds() {
5427        let v = resolve_powder_adjusted_velocity(823.0, 31.1, true, 0.5486, 21.1, None, None);
5428        assert!((v - (823.0 + 0.5486 * 10.0)).abs() < 1e-12);
5429    }
5430
5431    #[test]
5432    fn disabled_flag_is_passthrough() {
5433        let v = resolve_powder_adjusted_velocity(823.0, 40.0, false, 0.5486, 21.1, None, None);
5434        assert_eq!(v, 823.0);
5435    }
5436
5437    #[test]
5438    fn curve_overrides_linear_and_interpolates_at_powder_temp() {
5439        let curve = [(4.4, 798.6), (21.1, 823.0), (37.8, 841.2)];
5440        // Explicit powder temp decouples from ambient: interpolate at 4.4 C, not 30 C.
5441        let v = resolve_powder_adjusted_velocity(
5442            823.0,
5443            30.0,
5444            true,
5445            99.0,
5446            21.1,
5447            Some(&curve),
5448            Some(4.4),
5449        );
5450        assert!((v - 798.6).abs() < 1e-9);
5451    }
5452
5453    #[test]
5454    fn curve_falls_back_to_ambient_and_clamps() {
5455        let curve = [(4.4, 798.6), (37.8, 841.2)];
5456        // Ambient far below the coldest measured point: clamp, no extrapolation.
5457        let v = resolve_powder_adjusted_velocity(823.0, -40.0, true, 1.0, 21.1, Some(&curve), None);
5458        assert!((v - 798.6).abs() < 1e-9);
5459        let v_hot =
5460            resolve_powder_adjusted_velocity(823.0, 60.0, true, 1.0, 21.1, Some(&curve), None);
5461        assert!((v_hot - 841.2).abs() < 1e-9);
5462    }
5463
5464    #[test]
5465    fn empty_curve_suppresses_linear_fallback() {
5466        // Historical `else if` semantics: Some-but-empty curve means NO adjustment.
5467        let v = resolve_powder_adjusted_velocity(823.0, 40.0, true, 0.5486, 21.1, Some(&[]), None);
5468        assert_eq!(v, 823.0);
5469    }
5470
5471    #[test]
5472    fn sweep_huge_range_errors_instead_of_overflowing() {
5473        // Review finding: the old count math saturated the usize cast and the +1
5474        // panicked (debug) or wrapped to zero rows (release), bypassing the cap.
5475        assert!(parse_powder_sweep("0:1e20:1").is_err());
5476        assert!(parse_powder_sweep("0:1e308:1e-3").is_err());
5477    }
5478
5479    #[test]
5480    fn sweep_fractional_step_keeps_end_row() {
5481        // 0.3/0.1 floats to 2.9999999999999996; the END row must survive the floor.
5482        let rows = parse_powder_sweep("0:0.3:0.1").unwrap();
5483        assert_eq!(rows.len(), 4);
5484        assert!((rows[3] - 0.3).abs() < 1e-9);
5485    }
5486
5487    #[test]
5488    fn solver_and_helper_agree_on_linear_model() {
5489        // The solve() seam must fly exactly what the helper reports (MBA-737 contract).
5490        let inputs = BallisticInputs {
5491            use_powder_sensitivity: true,
5492            powder_temp_sensitivity: 0.5486,
5493            powder_temp: 21.1,
5494            temperature: 4.4,
5495            ..Default::default()
5496        };
5497        let expected = resolve_powder_adjusted_velocity(
5498            inputs.muzzle_velocity,
5499            inputs.temperature,
5500            true,
5501            0.5486,
5502            21.1,
5503            None,
5504            None,
5505        );
5506        let solver = TrajectorySolver::new(
5507            inputs,
5508            WindConditions::default(),
5509            AtmosphericConditions::default(),
5510        );
5511        assert!((solver.inputs.muzzle_velocity - expected).abs() < 1e-12);
5512    }
5513}
5514
5515#[cfg(test)]
5516mod mba1302_solver_seam_tests {
5517    use super::*;
5518    use crate::wind::WindSegment;
5519
5520    #[test]
5521    fn authoritative_station_atmosphere_preserves_explicit_standard_values_at_altitude() {
5522        let atmosphere = AtmosphericConditions {
5523            temperature: 15.0,
5524            pressure: 1013.25,
5525            humidity: 50.0,
5526            altitude: 2_000.0,
5527        };
5528        let legacy = TrajectorySolver::new(
5529            BallisticInputs::default(),
5530            WindConditions::default(),
5531            atmosphere.clone(),
5532        );
5533        let authoritative = TrajectorySolver::new_with_resolved_station_atmosphere(
5534            BallisticInputs::default(),
5535            WindConditions::default(),
5536            atmosphere,
5537        );
5538
5539        let (legacy_density, _, legacy_temp_c, legacy_pressure_hpa) = legacy.resolved_atmosphere();
5540        let (authoritative_density, _, authoritative_temp_c, authoritative_pressure_hpa) =
5541            authoritative.resolved_atmosphere();
5542        let (icao_temp_k, icao_pressure_pa) =
5543            crate::atmosphere::calculate_icao_standard_atmosphere(2_000.0);
5544        let (expected_authoritative_density, _) =
5545            crate::atmosphere::calculate_atmosphere(2_000.0, Some(15.0), Some(1013.25), 50.0);
5546
5547        assert!((legacy_temp_c - (icao_temp_k - 273.15)).abs() < 1e-12);
5548        assert!((legacy_pressure_hpa - icao_pressure_pa / 100.0).abs() < 1e-12);
5549        assert_eq!(authoritative_temp_c.to_bits(), 15.0_f64.to_bits());
5550        assert_eq!(authoritative_pressure_hpa.to_bits(), 1013.25_f64.to_bits());
5551        assert_eq!(
5552            authoritative_density.to_bits(),
5553            expected_authoritative_density.to_bits()
5554        );
5555        assert!(
5556            (authoritative_density - legacy_density).abs() > 0.1,
5557            "explicit standard values at altitude must differ from ICAO-at-altitude: explicit={authoritative_density}, ICAO={legacy_density}"
5558        );
5559    }
5560
5561    /// MBA-1397: the CLI/WASM `--pressure-type` mechanism precomputes station conditions via
5562    /// `atmosphere::resolve_station_conditions_with_pressure_mode` and constructs the solver
5563    /// with `new_with_resolved_station_atmosphere` UNCONDITIONALLY (not only for `Qnh`), to
5564    /// avoid re-deriving through the legacy sentinel a second time. This must be bit-for-bit
5565    /// equivalent to the historical `TrajectorySolver::new` (LegacyDefaultSentinels) path for
5566    /// every NON-ambiguous input -- i.e. for `PressureReferenceMode::Absolute`, precomputing
5567    /// and using the Authoritative constructor must reproduce `TrajectorySolver::new` exactly,
5568    /// which is the bit-level guarantee the CLI/WASM byte-identical-output tests rely on.
5569    #[test]
5570    fn precomputed_absolute_resolution_via_authoritative_matches_legacy_new() {
5571        for (temperature, pressure, altitude) in [
5572            (15.0, 1013.25, 0.0),    // sea-level default
5573            (15.0, 1013.25, 2000.0), // sentinel: omitted-pressure-at-altitude
5574            (-5.0, 850.0, 2000.0),   // explicit non-default station values
5575            (22.0, 950.0, 500.0),
5576        ] {
5577            let atmosphere = AtmosphericConditions {
5578                temperature,
5579                pressure,
5580                humidity: 50.0,
5581                altitude,
5582            };
5583            let legacy = TrajectorySolver::new(
5584                BallisticInputs::default(),
5585                WindConditions::default(),
5586                atmosphere.clone(),
5587            );
5588
5589            let (resolved_temp_c, resolved_pressure_hpa) =
5590                crate::atmosphere::resolve_station_conditions_with_pressure_mode(
5591                    temperature,
5592                    pressure,
5593                    altitude,
5594                    crate::atmosphere::PressureReferenceMode::Absolute,
5595                );
5596            let precomputed_atmosphere = AtmosphericConditions {
5597                temperature: resolved_temp_c,
5598                pressure: resolved_pressure_hpa,
5599                humidity: 50.0,
5600                altitude,
5601            };
5602            let precomputed = TrajectorySolver::new_with_resolved_station_atmosphere(
5603                BallisticInputs::default(),
5604                WindConditions::default(),
5605                precomputed_atmosphere,
5606            );
5607
5608            let (legacy_density, legacy_sos, legacy_temp_c, legacy_pressure_hpa) =
5609                legacy.resolved_atmosphere();
5610            let (pre_density, pre_sos, pre_temp_c, pre_pressure_hpa) =
5611                precomputed.resolved_atmosphere();
5612
5613            assert_eq!(
5614                legacy_temp_c.to_bits(),
5615                pre_temp_c.to_bits(),
5616                "temperature=({temperature}, {pressure}, {altitude})"
5617            );
5618            assert_eq!(
5619                legacy_pressure_hpa.to_bits(),
5620                pre_pressure_hpa.to_bits(),
5621                "pressure=({temperature}, {pressure}, {altitude})"
5622            );
5623            assert_eq!(legacy_density.to_bits(), pre_density.to_bits());
5624            assert_eq!(legacy_sos.to_bits(), pre_sos.to_bits());
5625        }
5626
5627        // In-band rows (0.49.0): 15.05 C / 1013.0 hPa sit inside both sentinel bands at a real
5628        // altitude. The presence-blind constructor and NEITHER must reproduce what ab4eb0c
5629        // resolved (ICAO air at the altitude), and BOTH must fly the pair as given, exactly
5630        // like the authoritative constructor.
5631        for (altitude, pinned) in [
5632            (
5633                150.0,
5634                // captured at ab4eb0c: (density, speed of sound, temperature, pressure)
5635                [
5636                    f64::from_bits(0x3ff344d9d9fd1ea1),
5637                    f64::from_bits(0x40754269eee53ba5),
5638                    f64::from_bits(0x402c0ccfd0c49b20),
5639                    f64::from_bits(0x408f1ae13f434f1f),
5640                ],
5641            ),
5642            (
5643                1500.0,
5644                // captured at ab4eb0c
5645                [
5646                    f64::from_bits(0x3ff0e75c08c70bc0),
5647                    f64::from_bits(0x4074ec457a661d16),
5648                    f64::from_bits(0x4015025af8d136c0),
5649                    f64::from_bits(0x408a6cc6358cefbd),
5650                ],
5651            ),
5652        ] {
5653            let atmosphere = AtmosphericConditions {
5654                temperature: 15.05,
5655                pressure: 1013.0,
5656                humidity: 50.0,
5657                altitude,
5658            };
5659            let bits = |r: (f64, f64, f64, f64)| [r.0, r.1, r.2, r.3].map(f64::to_bits);
5660            let pinned = pinned.map(f64::to_bits);
5661            let legacy = TrajectorySolver::new(
5662                BallisticInputs::default(),
5663                WindConditions::default(),
5664                atmosphere.clone(),
5665            );
5666            assert_eq!(
5667                bits(legacy.resolved_atmosphere()),
5668                pinned,
5669                "new at {altitude} m"
5670            );
5671            let neither = TrajectorySolver::new_with_station_presence(
5672                BallisticInputs::default(),
5673                WindConditions::default(),
5674                atmosphere.clone(),
5675                StationAtmospherePresence::NEITHER,
5676            );
5677            assert_eq!(
5678                bits(neither.resolved_atmosphere()),
5679                pinned,
5680                "NEITHER at {altitude} m"
5681            );
5682
5683            let both = TrajectorySolver::new_with_station_presence(
5684                BallisticInputs::default(),
5685                WindConditions::default(),
5686                atmosphere.clone(),
5687                StationAtmospherePresence::BOTH,
5688            );
5689            let authoritative = TrajectorySolver::new_with_resolved_station_atmosphere(
5690                BallisticInputs::default(),
5691                WindConditions::default(),
5692                atmosphere,
5693            );
5694            let both_resolved = both.resolved_atmosphere();
5695            assert_eq!(
5696                bits(both_resolved),
5697                bits(authoritative.resolved_atmosphere())
5698            );
5699            assert_eq!(both_resolved.2.to_bits(), 15.05_f64.to_bits());
5700            assert_eq!(both_resolved.3.to_bits(), 1013.0_f64.to_bits());
5701            assert_ne!(
5702                bits(both_resolved),
5703                pinned,
5704                "BOTH must not swap at {altitude} m"
5705            );
5706        }
5707    }
5708
5709    fn configured_euler_zero(vertical_wind_mps: f64, time_step_s: f64) -> TrajectorySolver {
5710        let inputs = BallisticInputs {
5711            muzzle_velocity: 800.0,
5712            bc_value: 0.5,
5713            bc_type: DragModel::G7,
5714            bullet_mass: 0.0109,
5715            bullet_diameter: 0.00782,
5716            bullet_length: 0.0309,
5717            sight_height: 0.05,
5718            ground_threshold: -100.0,
5719            use_rk4: false,
5720            use_adaptive_rk45: false,
5721            ..BallisticInputs::default()
5722        };
5723        let mut solver = TrajectorySolver::new_with_resolved_station_atmosphere(
5724            inputs,
5725            WindConditions::default(),
5726            AtmosphericConditions::default(),
5727        );
5728        solver.set_max_range(300.0);
5729        solver.set_time_step(time_step_s);
5730        if vertical_wind_mps != 0.0 {
5731            solver.set_wind_segments(vec![WindSegment {
5732                speed_kmh: 0.0,
5733                angle_deg: 0.0,
5734                until_m: 400.0,
5735                vertical_mps: vertical_wind_mps,
5736            }]);
5737        }
5738        solver
5739    }
5740
5741    #[test]
5742    fn inclined_shot_zeroes_like_a_level_rifle() {
5743        // MBA-1412: since MBA-1302, zero_trial_height_at converted trial heights into the
5744        // gravity frame (shot_frame_altitude adds d*sin(shooting_angle) ~ 9 m at 5.71deg /
5745        // 100 yd) while find_zero_angle compared against a sight-frame target height, so ANY
5746        // inclined shot failed to bracket. A zero is a property of a level rifle's sight
5747        // geometry (same doctrine as MBA-1286's cant handling): the trial must solve level.
5748        const ZERO_DISTANCE_M: f64 = 91.44; // 100 yd
5749        const SIGHT_HEIGHT_M: f64 = 0.0381; // 1.5 in
5750
5751        let inputs = BallisticInputs {
5752            bc_value: 0.5,
5753            bullet_mass: 150.0 * 0.06479891 / 1000.0,
5754            muzzle_velocity: 2700.0 * 0.3048,
5755            sight_height: SIGHT_HEIGHT_M,
5756            ..Default::default()
5757        };
5758
5759        let mut level = inputs.clone();
5760        level.shooting_angle = 0.0;
5761        let level_angle = TrajectorySolver::new(level, Default::default(), Default::default())
5762            .find_zero_angle(ZERO_DISTANCE_M, SIGHT_HEIGHT_M, ZeroTargetFrame::SightLine)
5763            .expect("level zero must solve");
5764
5765        let mut inclined = inputs;
5766        inclined.shooting_angle = 5.71_f64.to_radians();
5767        let inclined_angle =
5768            TrajectorySolver::new(inclined, Default::default(), Default::default())
5769                .find_zero_angle(ZERO_DISTANCE_M, SIGHT_HEIGHT_M, ZeroTargetFrame::SightLine)
5770                .expect("MBA-1412: a 5.71 deg incline at a 100 yd zero must be solvable");
5771
5772        assert!(
5773            (inclined_angle - level_angle).abs() < 1e-9,
5774            "zeroing is level-rifle sight geometry; incline must not move the solved zero: \
5775             level={level_angle}, inclined={inclined_angle}"
5776        );
5777    }
5778
5779    #[test]
5780    fn target_below_the_level_bore_impact_zeroes_with_a_downward_bore() {
5781        // A target lower than where a level bore lands needs the bore pointed below
5782        // horizontal. The search used to start at 0 rad, so every such target collapsed onto
5783        // 0 rad and failed with "residual height error too large".
5784        const TARGET_DISTANCE_M: f64 = 457.2; // 500 yd
5785        let inputs = BallisticInputs {
5786            bc_value: 0.462,
5787            bullet_mass: 168.0 * 0.06479891 / 1000.0,
5788            bullet_diameter: 0.308 * 0.0254,
5789            muzzle_velocity: 2700.0 * 0.3048,
5790            sight_height: 0.05,
5791            ..Default::default()
5792        };
5793        let solver = TrajectorySolver::new(inputs, Default::default(), Default::default());
5794        let level_impact_m = solver
5795            .zero_trial_height_at(0.0, TARGET_DISTANCE_M, ZeroTargetFrame::SightLine)
5796            .expect("level trial")
5797            .expect("level trial reaches the target distance");
5798
5799        // From just below the level impact down to a 30-degree depression (still above the
5800        // default -100 m ground threshold).
5801        for depth_m in [0.005, 1.0, 4.572, 91.44] {
5802            let target_m = level_impact_m - depth_m;
5803            let angle = solver
5804                .find_zero_angle(TARGET_DISTANCE_M, target_m, ZeroTargetFrame::SightLine)
5805                .unwrap_or_else(|e| panic!("target {depth_m} m below the level impact: {e}"));
5806            assert!(
5807                angle < 0.0,
5808                "a target below the level impact needs a downward bore: depth={depth_m} m angle={angle}"
5809            );
5810            let hit_m = solver
5811                .zero_trial_height_at(angle, TARGET_DISTANCE_M, ZeroTargetFrame::SightLine)
5812                .expect("verify trial")
5813                .expect("zeroed trial reaches the target distance");
5814            assert!(
5815                (hit_m - target_m).abs() < 0.0001,
5816                "zero missed a target {depth_m} m below the level impact: hit={hit_m} target={target_m}"
5817            );
5818        }
5819    }
5820
5821    #[test]
5822    fn configured_zero_keeps_segments_method_and_time_step_then_sets_base_angle() {
5823        const TARGET_DISTANCE_M: f64 = 150.0;
5824        const TARGET_HEIGHT_M: f64 = 0.05;
5825
5826        // A deliberately coarse Euler step makes an accidental reset to the historical 1 ms
5827        // zeroing step observable, while remaining stable and physically meaningful.
5828        let mut segmented = configured_euler_zero(-10.0, 0.02);
5829        let coarse_height = segmented
5830            .zero_trial_height_at(0.0, TARGET_DISTANCE_M, ZeroTargetFrame::SightLine)
5831            .expect("coarse configured trial")
5832            .expect("coarse trial reaches target");
5833        let mut fine = segmented.clone();
5834        fine.set_time_step(0.001);
5835        let fine_height = fine
5836            .zero_trial_height_at(0.0, TARGET_DISTANCE_M, ZeroTargetFrame::SightLine)
5837            .expect("fine configured trial")
5838            .expect("fine trial reaches target");
5839        assert!(
5840            (coarse_height - fine_height).abs() > 1e-5,
5841            "configured Euler step must affect zero trials: coarse={coarse_height}, fine={fine_height}"
5842        );
5843
5844        let segmented_angle = segmented
5845            .calculate_and_set_zero_angle(
5846                TARGET_DISTANCE_M,
5847                TARGET_HEIGHT_M,
5848                ZeroTargetFrame::SightLine,
5849            )
5850            .expect("segmented zero");
5851        assert_eq!(
5852            segmented.inputs.muzzle_angle.to_bits(),
5853            segmented_angle.to_bits(),
5854            "successful zero must install its angle on the configured solver"
5855        );
5856        assert_eq!(segmented.time_step.to_bits(), 0.02_f64.to_bits());
5857        assert_eq!(segmented.max_range.to_bits(), 300.0_f64.to_bits());
5858        assert!(segmented.wind_sock.is_some());
5859        assert_eq!(segmented.station_presence, StationAtmospherePresence::BOTH);
5860        let zero_height = segmented
5861            .zero_trial_height_at(
5862                segmented_angle,
5863                TARGET_DISTANCE_M,
5864                ZeroTargetFrame::SightLine,
5865            )
5866            .expect("verify segmented zero")
5867            .expect("zeroed trial reaches target");
5868        assert!(
5869            (zero_height - TARGET_HEIGHT_M).abs() < 0.0001,
5870            "configured zero missed target: height={zero_height}"
5871        );
5872
5873        let mut calm = configured_euler_zero(0.0, 0.02);
5874        let calm_angle = calm
5875            .calculate_and_set_zero_angle(
5876                TARGET_DISTANCE_M,
5877                TARGET_HEIGHT_M,
5878                ZeroTargetFrame::SightLine,
5879            )
5880            .expect("calm zero");
5881        assert!(
5882            (segmented_angle - calm_angle).abs() > 1e-5,
5883            "segmented vertical wind must participate in zero trials: segmented={segmented_angle}, calm={calm_angle}"
5884        );
5885    }
5886}
5887
5888#[cfg(test)]
5889mod uniform_gravity_tests {
5890    use super::*;
5891
5892    #[test]
5893    fn uniform_gravity_is_per_solver_and_survives_cloning() {
5894        for (use_rk4, use_adaptive_rk45) in [(false, false), (true, false), (true, true)] {
5895            // A ball with negligible drag provides an independent constant-acceleration oracle.
5896            let inputs = BallisticInputs {
5897                bullet_mass: 0.0459,
5898                bullet_diameter: 0.0427,
5899                bullet_length: 0.0427,
5900                muzzle_velocity: 10.0,
5901                muzzle_height: 10.0,
5902                muzzle_angle: 0.0,
5903                bc_type: DragModel::GS,
5904                custom_drag_table: Some(crate::drag::DragTable::new(
5905                    vec![0.0, 5.0],
5906                    vec![1e-20, 1e-20],
5907                )),
5908                use_rk4,
5909                use_adaptive_rk45,
5910                ..BallisticInputs::default()
5911            };
5912            let public = TrajectorySolver::new(
5913                inputs.clone(),
5914                WindConditions::default(),
5915                AtmosphericConditions::default(),
5916            );
5917            for magnitude in [0.0, 1.62, 9.80665, 20.0] {
5918                let mut solver = TrajectorySolver::new_with_station_presence_and_gravity(
5919                    inputs.clone(),
5920                    WindConditions::default(),
5921                    AtmosphericConditions::default(),
5922                    StationAtmospherePresence::NEITHER,
5923                    Gravity::new(magnitude).unwrap(),
5924                )
5925                .clone();
5926                solver.set_max_range(10.0);
5927                let result = solver.solve().unwrap();
5928                let end = result.points.last().unwrap();
5929                assert!((end.time - 1.0).abs() < 1e-8);
5930                // The endpoint is linearly interpolated across the final step: a parabola's
5931                // interpolation error is bounded by g*dt²/8. Euler also has O(dt) error.
5932                let step = if use_adaptive_rk45 {
5933                    RK45_MAX_DT
5934                } else {
5935                    solver.time_step
5936                };
5937                let tolerance = magnitude * step * step / 8.0
5938                    + if use_rk4 { 0.0 } else { magnitude * step / 2.0 }
5939                    + 1e-8;
5940                assert!(
5941                    (end.position.y - (10.0 - 0.5 * magnitude)).abs() < tolerance,
5942                    "g={magnitude}, rk4={use_rk4}, adaptive={use_adaptive_rk45}, y={}, time={}",
5943                    end.position.y,
5944                    end.time
5945                );
5946                assert!(
5947                    (solver.speed_budget(1.0) - solver.speed_budget(0.0) - magnitude).abs() < 1e-12
5948                );
5949                assert_eq!(public.gravity, Gravity::EARTH);
5950                if magnitude == 9.80665 {
5951                    let mut earth = public.clone();
5952                    earth.set_max_range(10.0);
5953                    let reference = earth.solve().unwrap();
5954                    assert_eq!(result.points.len(), reference.points.len());
5955                    for (actual, expected) in result.points.iter().zip(&reference.points) {
5956                        assert_eq!(actual.time.to_bits(), expected.time.to_bits());
5957                        assert_eq!(actual.position, expected.position);
5958                        assert_eq!(
5959                            actual.velocity_magnitude.to_bits(),
5960                            expected.velocity_magnitude.to_bits()
5961                        );
5962                    }
5963                }
5964            }
5965        }
5966    }
5967
5968    fn match_308_inputs() -> BallisticInputs {
5969        BallisticInputs {
5970            muzzle_velocity: 800.0,
5971            bullet_mass: 168.0 * crate::constants::GRAINS_TO_KG,
5972            bullet_diameter: 0.308 * 0.0254,
5973            bullet_length: 1.215 * 0.0254,
5974            twist_rate: 10.0,
5975            ..BallisticInputs::default()
5976        }
5977    }
5978
5979    fn station_1000_m() -> AtmosphericConditions {
5980        AtmosphericConditions {
5981            temperature: 15.0,
5982            pressure: 1013.25,
5983            humidity: 0.0,
5984            altitude: 1000.0,
5985        }
5986    }
5987
5988    /// The solver's acceleration under `gravity`, minus the shot-frame gravity it adds: what is
5989    /// left is drag and Magnus, the parts that read gravity through the atmosphere and the yaw
5990    /// of repose.
5991    fn aerodynamic_acceleration_with_gravity(
5992        inputs: &BallisticInputs,
5993        gravity: Gravity,
5994        position: Vector3<f64>,
5995        velocity: Vector3<f64>,
5996    ) -> Vector3<f64> {
5997        let solver = TrajectorySolver::new_with_station_presence_and_gravity(
5998            inputs.clone(),
5999            WindConditions::default(),
6000            station_1000_m(),
6001            StationAtmospherePresence::BOTH,
6002            gravity,
6003        );
6004        let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
6005        solver.calculate_acceleration(
6006            &position,
6007            &velocity,
6008            &Vector3::zeros(),
6009            (temp_c, pressure_hpa, density / 1.225),
6010        ) - solver.gravity_acceleration()
6011    }
6012
6013    #[test]
6014    fn local_atmosphere_uses_the_simulation_gravity() {
6015        let inputs = match_308_inputs(); // Magnus off
6016        let velocity = Vector3::new(600.0, 0.0, 0.0);
6017        let low_gravity = Gravity::new(3.71).unwrap();
6018        let aerodynamic = |position, gravity| {
6019            aerodynamic_acceleration_with_gravity(&inputs, gravity, position, velocity)
6020        };
6021
6022        // Control: at the station there is no air column to integrate, so gravity cannot reach
6023        // the density.
6024        let earth = aerodynamic(Vector3::zeros(), Gravity::EARTH);
6025        let low = aerodynamic(Vector3::zeros(), low_gravity);
6026        assert!(
6027            (earth - low).norm() < 1e-12,
6028            "station-altitude drag must not depend on gravity: earth={earth:?}, low={low:?}"
6029        );
6030
6031        // 2000 m above the station, pressure falls more slowly under weaker gravity, so the air is
6032        // denser and drag is stronger.
6033        let aloft = Vector3::new(0.0, 2_000.0, 0.0);
6034        let earth = aerodynamic(aloft, Gravity::EARTH);
6035        let low = aerodynamic(aloft, low_gravity);
6036        let ratio = low.x / earth.x;
6037        assert!(earth.x < 0.0, "drag must oppose flight: {earth:?}");
6038        assert!(
6039            (ratio - 1.162_702).abs() < 1e-4,
6040            "drag aloft must follow the simulation's hydrostatic density: low/earth={ratio}, \
6041             expected 1.162702 (standard lapse rate, 1000 m station, 2000 m above it)"
6042        );
6043    }
6044
6045    #[test]
6046    fn magnus_yaw_of_repose_uses_the_simulation_gravity() {
6047        let velocity = Vector3::new(800.0, 0.0, 0.0);
6048        let low_gravity = Gravity::new(3.71).unwrap();
6049        // At the station, so the density cannot depend on gravity.
6050        let aerodynamic = |enable_magnus, gravity| {
6051            let inputs = BallisticInputs {
6052                enable_magnus,
6053                ..match_308_inputs()
6054            };
6055            aerodynamic_acceleration_with_gravity(&inputs, gravity, Vector3::zeros(), velocity)
6056        };
6057
6058        // Control: without Magnus the aerodynamic acceleration is the same under both gravities.
6059        let earth = aerodynamic(false, Gravity::EARTH);
6060        let low = aerodynamic(false, low_gravity);
6061        assert!(
6062            (earth - low).norm() < 1e-12,
6063            "Magnus-off drag must not depend on gravity: earth={earth:?}, low={low:?}"
6064        );
6065
6066        // Yaw of repose is proportional to g, and the Magnus force to the sine of that small yaw.
6067        let earth = aerodynamic(true, Gravity::EARTH) - earth;
6068        let low = aerodynamic(true, low_gravity) - low;
6069        let ratio = low.y / earth.y;
6070        let expected = low_gravity.magnitude() / Gravity::EARTH.magnitude();
6071        assert!(
6072            earth.y < 0.0,
6073            "right-hand Magnus must point down: {earth:?}"
6074        );
6075        assert!(
6076            (ratio - expected).abs() < 1e-6,
6077            "Magnus must scale with the simulation's gravity: low/earth={ratio}, expected={expected}"
6078        );
6079    }
6080}
6081
6082#[cfg(test)]
6083mod result_sanity_tests {
6084    use super::*;
6085
6086    fn default_solver() -> TrajectorySolver {
6087        TrajectorySolver::new(
6088            BallisticInputs::default(),
6089            WindConditions::default(),
6090            AtmosphericConditions::default(),
6091        )
6092    }
6093
6094    fn minimal_result() -> TrajectoryResult {
6095        TrajectoryResult {
6096            max_range: 100.0,
6097            max_height: 1.0,
6098            time_of_flight: 0.5,
6099            impact_velocity: 700.0,
6100            impact_energy: 2450.0,
6101            projectile_mass_kg: 0.01,
6102            line_of_sight_height_m: 1.5,
6103            station_speed_of_sound_mps: 340.0,
6104            termination: TrajectoryTermination::MaxRange,
6105            points: vec![],
6106            sampled_points: None,
6107            min_pitch_damping: None,
6108            transonic_mach: None,
6109            angular_state: None,
6110            max_yaw_angle: None,
6111            max_precession_angle: None,
6112            aerodynamic_jump: None,
6113            mach_1_2_distance_m: None,
6114            mach_1_0_distance_m: None,
6115            mach_0_9_distance_m: None,
6116        }
6117    }
6118
6119    #[test]
6120    fn mba1293_negative_scalars_fail_the_result_postcondition() {
6121        let solver = default_solver();
6122        solver
6123            .validate_result_sanity(&minimal_result())
6124            .expect("a sane result must pass");
6125
6126        for (name, mutate) in [
6127            (
6128                "max_range",
6129                (|r| r.max_range = -50.588) as fn(&mut TrajectoryResult),
6130            ),
6131            ("time_of_flight", |r| r.time_of_flight = -1.0),
6132            ("impact_velocity", |r| r.impact_velocity = -700.0),
6133            ("impact_energy", |r| r.impact_energy = -1.0),
6134        ] {
6135            let mut result = minimal_result();
6136            mutate(&mut result);
6137            let error = solver
6138                .validate_result_sanity(&result)
6139                .expect_err("negative scalar must fail");
6140            assert!(
6141                error.to_string().contains(name),
6142                "error for {name} did not name the field: {error}"
6143            );
6144        }
6145    }
6146
6147    #[test]
6148    fn mba1293_speed_budget_bounds_legitimate_states_and_rejects_divergence() {
6149        let solver = default_solver();
6150        let mv = solver.inputs.muzzle_velocity;
6151
6152        // A state at muzzle speed is always inside the budget.
6153        let position = Vector3::new(10.0, 0.0, 0.0);
6154        solver
6155            .validate_integration_state(&position, &Vector3::new(mv, 0.0, 0.0), 0.01)
6156            .expect("muzzle-speed state must pass");
6157
6158        // The MBA-1293 explosion (13x the muzzle speed) must be rejected as divergence.
6159        let error = solver
6160            .validate_integration_state(&position, &Vector3::new(-13.0 * mv, 0.0, 0.0), 0.01)
6161            .expect_err("13x muzzle speed must fail the budget");
6162        assert!(error.to_string().contains("diverged"), "{error}");
6163
6164        // The budget grows with gravity's g*t so long lobbed arcs never trip it.
6165        let after_fall = mv + crate::constants::G_ACCEL_MPS2 * 60.0;
6166        solver
6167            .validate_integration_state(&position, &Vector3::new(0.0, -after_fall, 0.0), 60.0)
6168            .expect("gravity-accelerated speed within g*t must pass");
6169    }
6170}
6171
6172#[cfg(test)]
6173mod trajectory_point_budget_tests {
6174    use super::*;
6175    use crate::MAX_TRAJECTORY_SAMPLES;
6176
6177    fn solver_with_budget(
6178        use_rk4: bool,
6179        use_adaptive_rk45: bool,
6180        point_budget: usize,
6181        max_range: f64,
6182    ) -> TrajectorySolver {
6183        let inputs = BallisticInputs {
6184            use_rk4,
6185            use_adaptive_rk45,
6186            ground_threshold: f64::NEG_INFINITY,
6187            ..BallisticInputs::default()
6188        };
6189        let mut solver = TrajectorySolver::new(
6190            inputs,
6191            WindConditions::default(),
6192            AtmosphericConditions::default(),
6193        );
6194        solver.max_trajectory_points = point_budget;
6195        solver.set_max_range(max_range);
6196        solver.set_time_step(0.001);
6197        solver
6198    }
6199
6200    #[test]
6201    fn mba1283_every_solver_errors_instead_of_exceeding_point_budget() {
6202        for (mode, use_rk4, use_adaptive_rk45) in [
6203            ("Euler", false, false),
6204            ("RK4", true, false),
6205            ("RK45", true, true),
6206        ] {
6207            let error = solver_with_budget(use_rk4, use_adaptive_rk45, 3, 10.0)
6208                .solve()
6209                .expect_err("a solve requiring more than three points must fail");
6210            assert!(
6211                error.to_string().contains("point limit of 3"),
6212                "unexpected {mode} point-budget error: {error}"
6213            );
6214        }
6215    }
6216
6217    #[test]
6218    fn mba1283_interpolated_endpoint_counts_toward_point_budget() {
6219        for (mode, use_rk4, use_adaptive_rk45) in [
6220            ("Euler", false, false),
6221            ("RK4", true, false),
6222            ("RK45", true, true),
6223        ] {
6224            let result = solver_with_budget(use_rk4, use_adaptive_rk45, 2, 0.1)
6225                .solve()
6226                .expect("the initial point plus exact endpoint fit a two-point budget");
6227            assert_eq!(result.points.len(), 2, "unexpected {mode} point count");
6228
6229            let error = solver_with_budget(use_rk4, use_adaptive_rk45, 1, 0.1)
6230                .solve()
6231                .expect_err("the exact endpoint must not exceed a one-point budget");
6232            assert!(
6233                error.to_string().contains("point limit of 1"),
6234                "unexpected {mode} endpoint-budget error: {error}"
6235            );
6236        }
6237    }
6238
6239    /// MBA-1599: a stalled flight stores one point per downrange distance, but every step it
6240    /// collapses still counts against the point budget. The budget here is one more than the
6241    /// points the flight stores, so the stored points alone can never reach it and only the
6242    /// collapsed steps can. It is still far smaller than the steps the flight took, so the solve
6243    /// fails at the budget as it did when every step was stored, instead of running on to the
6244    /// ground threshold. (A budget of exactly the stored points would fail on the first collapse
6245    /// after the last stored point whether or not collapsed steps were counted.)
6246    #[test]
6247    fn mba1599_collapsed_stall_steps_count_toward_point_budget() {
6248        for (mode, use_rk4, use_adaptive_rk45, bc_value) in [
6249            ("Euler", false, false, 2.0e-4),
6250            ("RK4", true, false, 1.0e-4),
6251            ("RK45", true, true, 1.0e-4),
6252        ] {
6253            let stalled = |point_budget: usize| {
6254                let inputs = BallisticInputs {
6255                    bc_value,
6256                    bc_type: DragModel::G7,
6257                    bullet_mass: 0.01134,
6258                    bullet_diameter: 0.00782,
6259                    muzzle_velocity: 823.0,
6260                    use_rk4,
6261                    use_adaptive_rk45,
6262                    ground_threshold: -100.0,
6263                    ..BallisticInputs::default()
6264                };
6265                let mut solver = TrajectorySolver::new(
6266                    inputs,
6267                    WindConditions::default(),
6268                    AtmosphericConditions::default(),
6269                );
6270                solver.max_trajectory_points = point_budget;
6271                solver.set_max_range(300.0);
6272                solver.set_time_step(0.001);
6273                solver.solve()
6274            };
6275
6276            let flown = stalled(MAX_TRAJECTORY_POINTS)
6277                .unwrap_or_else(|error| panic!("{mode}: the stall ends inside the cap: {error}"));
6278            assert_eq!(
6279                flown.termination,
6280                TrajectoryTermination::GroundThreshold,
6281                "{mode}"
6282            );
6283            let stored = flown.points.len();
6284            let budget = stored + 1;
6285            let Err(error) = stalled(budget) else {
6286                panic!("{mode}: the flight fit a {budget}-point budget with {stored} stored points, so the collapsed steps were not counted");
6287            };
6288            assert!(
6289                error
6290                    .to_string()
6291                    .contains(&format!("trajectory point limit of {budget} exceeded")),
6292                "unexpected {mode} stall-budget error: {error}"
6293            );
6294        }
6295    }
6296
6297    /// MBA-1599: the budget counts calls, stored or collapsed, so the call that fails is call
6298    /// budget + 1 however many earlier calls collapsed. That is the call that failed when every
6299    /// call stored a point. The stored terminal point is held to the same count.
6300    #[test]
6301    fn mba1599_point_budget_fails_on_the_same_call_with_or_without_collapsing() {
6302        let solver = solver_with_budget(true, false, 4, 10.0);
6303        let point = |x: f64| TrajectoryPoint {
6304            time: x,
6305            position: Vector3::new(x, 0.0, 0.0),
6306            velocity_magnitude: 1.0,
6307            kinetic_energy: 0.0,
6308            drag_coefficient: None,
6309        };
6310        let assert_over_budget = |error: BallisticsError| {
6311            assert!(
6312                error
6313                    .to_string()
6314                    .contains("trajectory point limit of 4 exceeded"),
6315                "unexpected point-budget error: {error}"
6316            );
6317        };
6318        for distances in [
6319            [0.0, 1.0, 2.0, 3.0],
6320            [0.0, 1.0, 1.0, 2.0],
6321            [0.0, 0.0, 0.0, 0.0],
6322        ] {
6323            let mut points = Vec::new();
6324            let mut collapsed_steps = 0;
6325            for x in distances {
6326                solver
6327                    .push_trajectory_point(&mut points, point(x), &mut collapsed_steps)
6328                    .expect("four calls fit a four-point budget");
6329            }
6330            assert_eq!(points.len() + collapsed_steps, 4, "{distances:?}");
6331
6332            // A fifth call fails whether it would collapse or store a point.
6333            for x in [distances[3], 5.0] {
6334                let error = solver
6335                    .push_trajectory_point(
6336                        &mut points.clone(),
6337                        point(x),
6338                        &mut collapsed_steps.clone(),
6339                    )
6340                    .expect_err("a fifth call exceeds a four-point budget");
6341                assert_over_budget(error);
6342            }
6343            // So does a terminal point stored after four integration steps.
6344            let error = solver
6345                .append_terminal_endpoint(
6346                    &mut points.clone(),
6347                    Vector3::new(20.0, 0.0, 0.0),
6348                    Vector3::new(1.0, 0.0, 0.0),
6349                    10.0,
6350                    &mut 0.0,
6351                    &mut collapsed_steps.clone(),
6352                )
6353                .expect_err("the terminal point is a fifth call");
6354            assert_over_budget(error);
6355        }
6356    }
6357
6358    #[test]
6359    fn mba1299_every_solver_preflights_the_sample_budget() {
6360        for (mode, use_rk4, use_adaptive_rk45) in [
6361            ("Euler", false, false),
6362            ("RK4", true, false),
6363            ("RK45", true, true),
6364        ] {
6365            let inputs = BallisticInputs {
6366                use_rk4,
6367                use_adaptive_rk45,
6368                enable_trajectory_sampling: true,
6369                sample_interval: 1.0,
6370                ground_threshold: f64::NEG_INFINITY,
6371                ..BallisticInputs::default()
6372            };
6373            let mut solver = TrajectorySolver::new(
6374                inputs,
6375                WindConditions::default(),
6376                AtmosphericConditions::default(),
6377            );
6378            solver.set_max_range(MAX_TRAJECTORY_SAMPLES as f64);
6379            // If validation does not reject the sample grid before dispatch, the first attempted
6380            // integration point produces a distinct point-budget error.
6381            solver.max_trajectory_points = 0;
6382
6383            let error = solver
6384                .solve()
6385                .expect_err("an over-limit sample grid must fail before integration");
6386            assert!(
6387                error
6388                    .to_string()
6389                    .contains("trajectory sample limit of 250000 exceeded"),
6390                "unexpected {mode} sample-budget error: {error}"
6391            );
6392        }
6393    }
6394
6395    #[test]
6396    fn mba1299_normal_sampling_does_not_change_solver_results() {
6397        for (mode, use_rk4, use_adaptive_rk45) in [
6398            ("Euler", false, false),
6399            ("RK4", true, false),
6400            ("RK45", true, true),
6401        ] {
6402            let solve = |enable_trajectory_sampling| {
6403                let inputs = BallisticInputs {
6404                    use_rk4,
6405                    use_adaptive_rk45,
6406                    enable_trajectory_sampling,
6407                    sample_interval: 0.5,
6408                    ground_threshold: f64::NEG_INFINITY,
6409                    ..BallisticInputs::default()
6410                };
6411                let mut solver = TrajectorySolver::new(
6412                    inputs,
6413                    WindConditions::default(),
6414                    AtmosphericConditions::default(),
6415                );
6416                solver.set_max_range(2.0);
6417                solver.solve().expect("normal short-range solve")
6418            };
6419
6420            let baseline = solve(false);
6421            let sampled = solve(true);
6422            for (field, left, right) in [
6423                ("max_range", baseline.max_range, sampled.max_range),
6424                ("max_height", baseline.max_height, sampled.max_height),
6425                (
6426                    "time_of_flight",
6427                    baseline.time_of_flight,
6428                    sampled.time_of_flight,
6429                ),
6430                (
6431                    "impact_velocity",
6432                    baseline.impact_velocity,
6433                    sampled.impact_velocity,
6434                ),
6435                (
6436                    "impact_energy",
6437                    baseline.impact_energy,
6438                    sampled.impact_energy,
6439                ),
6440            ] {
6441                assert_eq!(
6442                    left.to_bits(),
6443                    right.to_bits(),
6444                    "{mode} sampling changed {field}"
6445                );
6446            }
6447            assert_eq!(baseline.points.len(), sampled.points.len());
6448            for (index, (left, right)) in baseline.points.iter().zip(&sampled.points).enumerate() {
6449                assert_eq!(
6450                    left.time.to_bits(),
6451                    right.time.to_bits(),
6452                    "{mode} point {index}"
6453                );
6454                assert_eq!(
6455                    left.position.map(f64::to_bits),
6456                    right.position.map(f64::to_bits),
6457                    "{mode} point {index} position"
6458                );
6459                assert_eq!(
6460                    left.velocity_magnitude.to_bits(),
6461                    right.velocity_magnitude.to_bits(),
6462                    "{mode} point {index} velocity"
6463                );
6464                assert_eq!(
6465                    left.kinetic_energy.to_bits(),
6466                    right.kinetic_energy.to_bits(),
6467                    "{mode} point {index} energy"
6468                );
6469            }
6470            assert!(baseline.sampled_points.is_none());
6471            let samples = sampled
6472                .sampled_points
6473                .expect("sampling-enabled solve should return observations");
6474            assert_eq!(
6475                samples
6476                    .iter()
6477                    .map(|sample| sample.distance_m)
6478                    .collect::<Vec<_>>(),
6479                vec![0.0, 0.5, 1.0, 1.5, 2.0],
6480                "{mode} normal sampling grid changed"
6481            );
6482        }
6483    }
6484}
6485
6486#[cfg(test)]
6487mod monte_carlo_result_tests {
6488    use super::*;
6489
6490    fn make_results(impact_positions: Vec<Vector3<f64>>) -> MonteCarloResults {
6491        let count = impact_positions.len();
6492        MonteCarloResults {
6493            ranges: vec![500.0; count],
6494            impact_velocities: vec![300.0; count],
6495            impact_positions,
6496        }
6497    }
6498
6499    #[test]
6500    fn target_plane_cep_excludes_shortfall_markers() {
6501        let mut positions: Vec<Vector3<f64>> = (1..=5)
6502            .map(|radius| Vector3::new(0.0, radius as f64, 0.0))
6503            .collect();
6504        positions.extend((0..5).map(|_| Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0)));
6505        let results = make_results(positions);
6506
6507        assert_eq!(results.target_arrival_count(), 5);
6508        assert_eq!(results.target_shortfall_fraction(), 0.5);
6509        assert_eq!(results.target_plane_cep(), Some(3.0));
6510
6511        let one_shortfall = make_results(vec![
6512            Vector3::new(0.0, 1.0, 0.0),
6513            Vector3::new(0.0, 2.0, 0.0),
6514            Vector3::new(0.0, 3.0, 0.0),
6515            Vector3::new(0.0, 4.0, 0.0),
6516            Vector3::new(0.0, 5.0, 0.0),
6517            Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0),
6518        ]);
6519        assert_eq!(one_shortfall.target_plane_cep(), Some(3.0));
6520    }
6521
6522    #[test]
6523    fn all_shortfalls_have_no_cep_but_still_count_as_misses() {
6524        let all_shortfalls = make_results(vec![
6525            Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0),
6526            Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0),
6527        ]);
6528        assert_eq!(all_shortfalls.target_arrival_count(), 0);
6529        assert_eq!(all_shortfalls.target_shortfall_fraction(), 1.0);
6530        assert_eq!(all_shortfalls.target_plane_cep(), None);
6531        assert_eq!(all_shortfalls.hit_probability(0.3), 0.0);
6532
6533        let one_hit_one_shortfall = make_results(vec![
6534            Vector3::new(0.0, 0.1, 0.0),
6535            Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0),
6536        ]);
6537        assert_eq!(one_hit_one_shortfall.hit_probability(0.3), 0.5);
6538    }
6539
6540    // MBA-1317: WEZ rectangular hit probability.
6541    #[test]
6542    fn rect_hit_probability_checks_independent_axis_halves() {
6543        let results = make_results(vec![
6544            // Inside a 0.4 (lateral) x 0.6 (vertical) box: half-width 0.2, half-height 0.3.
6545            Vector3::new(0.0, 0.1, 0.1),
6546            // On the lateral edge (exactly half-width) -> counts as a hit ("<=").
6547            Vector3::new(0.0, 0.0, 0.2),
6548            // Outside laterally (just past half-width).
6549            Vector3::new(0.0, 0.0, 0.201),
6550            // Outside vertically (just past half-height).
6551            Vector3::new(0.0, 0.301, 0.0),
6552            // Shortfall marker: stays in the denominator, never a hit.
6553            Vector3::new(0.0, TARGET_NOT_REACHED_SENTINEL_M, 0.0),
6554        ]);
6555        // 2 hits out of 5 samples.
6556        assert!((results.rect_hit_probability(0.4, 0.6) - 0.4).abs() < 1e-12);
6557    }
6558
6559    #[test]
6560    fn rect_hit_probability_matches_circular_hit_probability_for_a_centered_hit() {
6561        let results = make_results(vec![Vector3::new(0.0, 0.0, 0.0)]);
6562        assert_eq!(results.rect_hit_probability(0.5, 0.5), 1.0);
6563        assert_eq!(results.hit_probability(0.3), 1.0);
6564    }
6565
6566    #[test]
6567    fn rect_hit_probability_is_zero_for_empty_or_nonpositive_dimensions() {
6568        let empty = make_results(vec![]);
6569        assert_eq!(empty.rect_hit_probability(1.0, 1.0), 0.0);
6570
6571        let results = make_results(vec![Vector3::new(0.0, 0.0, 0.0)]);
6572        assert_eq!(results.rect_hit_probability(0.0, 1.0), 0.0);
6573        assert_eq!(results.rect_hit_probability(1.0, 0.0), 0.0);
6574        assert_eq!(results.rect_hit_probability(-1.0, 1.0), 0.0);
6575    }
6576}
6577
6578/// 0.49.0: the `*_with_station_presence` twins. Every NEITHER assertion is against a value
6579/// pinned at ab4eb0c, never against the legacy name alone: the legacy names now delegate to
6580/// the twins, so a twin-against-legacy comparison would move with a broken twin.
6581#[cfg(test)]
6582mod station_presence_twin_tests {
6583    use super::*;
6584
6585    fn rifle() -> BallisticInputs {
6586        BallisticInputs {
6587            muzzle_velocity: 823.0,
6588            bc_value: 0.475,
6589            bc_type: DragModel::G1,
6590            bullet_mass: 0.0109,
6591            bullet_diameter: 0.00782,
6592            sight_height: 0.0381,
6593            ..Default::default()
6594        }
6595    }
6596
6597    fn air(temperature: f64, pressure: f64, altitude: f64) -> AtmosphericConditions {
6598        AtmosphericConditions {
6599            temperature,
6600            pressure,
6601            humidity: 50.0,
6602            altitude,
6603        }
6604    }
6605
6606    #[test]
6607    fn zero_twins_reproduce_ab4eb0c_with_neither_and_fly_a_supplied_reading() {
6608        // (temperature, pressure, altitude, zero angle, near crossing, far crossing for a
6609        // 0.002 rad bore angle), all captured at ab4eb0c.
6610        let cases = [
6611            (
6612                20.0,
6613                1013.0,
6614                150.0,
6615                f64::from_bits(0x3f65f73333333333),
6616                f64::from_bits(0x4034aaf5701ff812),
6617                f64::from_bits(0x406c0aa313fbdeae),
6618            ),
6619            (
6620                15.0,
6621                850.0,
6622                1500.0,
6623                f64::from_bits(0x3f6594ccccccccce),
6624                f64::from_bits(0x4034aa5d88f5baa5),
6625                f64::from_bits(0x406c6ab97964e714),
6626            ),
6627        ];
6628        for (t, p, alt, zero, near, far) in cases {
6629            let calm = WindConditions::default;
6630            let legacy = calculate_zero_angle_with_conditions(
6631                rifle(),
6632                300.0,
6633                0.0381,
6634                calm(),
6635                air(t, p, alt),
6636            )
6637            .unwrap();
6638            let neither = calculate_zero_angle_with_station_presence(
6639                rifle(),
6640                300.0,
6641                0.0381,
6642                calm(),
6643                air(t, p, alt),
6644                StationAtmospherePresence::NEITHER,
6645            )
6646            .unwrap();
6647            assert_eq!(
6648                legacy.to_bits(),
6649                zero.to_bits(),
6650                "legacy zero ({t}, {p}, {alt})"
6651            );
6652            assert_eq!(
6653                neither.to_bits(),
6654                zero.to_bits(),
6655                "NEITHER zero ({t}, {p}, {alt})"
6656            );
6657
6658            let legacy_range = calculate_zero_range_from_angle_with_conditions(
6659                rifle(),
6660                0.002,
6661                0.0381,
6662                calm(),
6663                air(t, p, alt),
6664            )
6665            .unwrap();
6666            let neither_range = calculate_zero_range_from_angle_with_station_presence(
6667                rifle(),
6668                0.002,
6669                0.0381,
6670                calm(),
6671                air(t, p, alt),
6672                StationAtmospherePresence::NEITHER,
6673            )
6674            .unwrap();
6675            for crossings in [legacy_range, neither_range] {
6676                assert_eq!(crossings.near_m.unwrap().to_bits(), near.to_bits());
6677                assert_eq!(crossings.far_m.unwrap().to_bits(), far.to_bits());
6678            }
6679
6680            // BOTH flies the pair as given: it equals the authoritative path that existed at
6681            // ab4eb0c, and differs from the swapped NEITHER result.
6682            let both = calculate_zero_angle_with_station_presence(
6683                rifle(),
6684                300.0,
6685                0.0381,
6686                calm(),
6687                air(t, p, alt),
6688                StationAtmospherePresence::BOTH,
6689            )
6690            .unwrap();
6691            let authoritative = calculate_zero_angle_with_resolved_conditions(
6692                rifle(),
6693                300.0,
6694                0.0381,
6695                calm(),
6696                air(t, p, alt),
6697            )
6698            .unwrap();
6699            assert_eq!(both.to_bits(), authoritative.to_bits());
6700            assert_ne!(
6701                both.to_bits(),
6702                zero.to_bits(),
6703                "BOTH must not swap ({t}, {p}, {alt})"
6704            );
6705
6706            let both_range = calculate_zero_range_from_angle_with_station_presence(
6707                rifle(),
6708                0.002,
6709                0.0381,
6710                calm(),
6711                air(t, p, alt),
6712                StationAtmospherePresence::BOTH,
6713            )
6714            .unwrap();
6715            let authoritative_range = calculate_zero_range_from_angle_with_resolved_conditions(
6716                rifle(),
6717                0.002,
6718                0.0381,
6719                calm(),
6720                air(t, p, alt),
6721            )
6722            .unwrap();
6723            assert_eq!(both_range, authoritative_range);
6724            assert_ne!(both_range.far_m.unwrap().to_bits(), far.to_bits());
6725        }
6726    }
6727
6728    #[test]
6729    fn solver_twin_with_both_lies_between_out_of_band_neighbours() {
6730        let impact = |p: f64, presence| {
6731            let mut solver = TrajectorySolver::new_with_station_presence(
6732                rifle(),
6733                WindConditions::default(),
6734                air(20.0, p, 150.0),
6735                presence,
6736            );
6737            solver.set_max_range(600.0);
6738            solver.solve().unwrap().impact_velocity
6739        };
6740        let low = impact(1012.0, StationAtmospherePresence::NEITHER);
6741        let high = impact(1014.0, StationAtmospherePresence::NEITHER);
6742        let both = impact(1013.0, StationAtmospherePresence::BOTH);
6743        let neither = impact(1013.0, StationAtmospherePresence::NEITHER);
6744        // Denser air slows the bullet more: velocity falls as pressure rises.
6745        assert!(high < both && both < low, "{high} < {both} < {low}");
6746        assert_ne!(both.to_bits(), neither.to_bits());
6747
6748        let temperature = |t: f64, presence| {
6749            let mut solver = TrajectorySolver::new_with_station_presence(
6750                rifle(),
6751                WindConditions::default(),
6752                air(t, 850.0, 1500.0),
6753                presence,
6754            );
6755            solver.set_max_range(600.0);
6756            solver.solve().unwrap().impact_velocity
6757        };
6758        let cold = temperature(14.8, StationAtmospherePresence::NEITHER);
6759        let warm = temperature(15.2, StationAtmospherePresence::NEITHER);
6760        let both = temperature(15.0, StationAtmospherePresence::BOTH);
6761        // Warmer air is thinner: velocity rises with temperature.
6762        assert!(cold < both && both < warm, "{cold} < {both} < {warm}");
6763        assert_ne!(
6764            both.to_bits(),
6765            temperature(15.0, StationAtmospherePresence::NEITHER).to_bits()
6766        );
6767    }
6768
6769    /// The `legacy_seeded_estimates_are_pinned_bit_for_bit` fixture through the twin.
6770    #[test]
6771    fn monte_carlo_twin_with_neither_reproduces_the_legacy_seeded_pins() {
6772        let inputs = BallisticInputs {
6773            muzzle_velocity: 800.0,
6774            ..BallisticInputs::default()
6775        };
6776        let params = MonteCarloParams {
6777            num_simulations: 200,
6778            target_distance: Some(500.0),
6779            ..MonteCarloParams::default()
6780        };
6781        let results = run_monte_carlo_with_station_presence(
6782            inputs,
6783            WindConditions::default(),
6784            params,
6785            0.01,
6786            Some(0x1352_5EED),
6787            StationAtmospherePresence::NEITHER,
6788        )
6789        .unwrap();
6790        assert_eq!(results.ranges.len(), 200);
6791        assert_eq!(
6792            results.hit_probability(DEFAULT_HIT_RADIUS_M).to_bits(),
6793            0.14_f64.to_bits()
6794        );
6795        let expected_ranges: [f64; 3] = [1907.972891143359, 1936.408435469319, 1912.8150447617645];
6796        let expected_velocities: [f64; 3] =
6797            [238.6187151542299, 239.91923651600106, 243.14112455427164];
6798        let expected_positions: [(f64, f64); 3] = [
6799            (-0.0643556039548101, 0.7344970252014579),
6800            (0.5769422971539162, 0.27227201756386726),
6801            (-0.7440425792472842, 0.1541804446282822),
6802        ];
6803        for i in 0..3 {
6804            assert_eq!(results.ranges[i].to_bits(), expected_ranges[i].to_bits());
6805            assert_eq!(
6806                results.impact_velocities[i].to_bits(),
6807                expected_velocities[i].to_bits()
6808            );
6809            assert_eq!(
6810                results.impact_positions[i].y.to_bits(),
6811                expected_positions[i].0.to_bits()
6812            );
6813            assert_eq!(
6814                results.impact_positions[i].z.to_bits(),
6815                expected_positions[i].1.to_bits()
6816            );
6817        }
6818    }
6819
6820    fn in_band_mc_inputs() -> BallisticInputs {
6821        BallisticInputs {
6822            muzzle_velocity: 800.0,
6823            temperature: 20.0,
6824            pressure: 1013.0,
6825            altitude: 150.0,
6826            ..BallisticInputs::default()
6827        }
6828    }
6829
6830    #[test]
6831    fn monte_carlo_in_band_seeded_case_is_pinned_and_both_differs() {
6832        let params = MonteCarloParams {
6833            num_simulations: 50,
6834            target_distance: Some(500.0),
6835            ..MonteCarloParams::default()
6836        };
6837        // captured at ab4eb0c: (range, impact velocity) of the first three trials
6838        let pinned = [
6839            (
6840                f64::from_bits(0x409e36cf28692da7),
6841                f64::from_bits(0x406e6111e25be26e),
6842            ),
6843            (
6844                f64::from_bits(0x409eaa63c504259d),
6845                f64::from_bits(0x406e8a3b4e34eef5),
6846            ),
6847            (
6848                f64::from_bits(0x409e4838920e477c),
6849                f64::from_bits(0x406ef31c9a289584),
6850            ),
6851        ];
6852        let seeded = run_monte_carlo_with_wind_and_direction_std_dev_seeded(
6853            in_band_mc_inputs(),
6854            WindConditions::default(),
6855            params.clone(),
6856            0.01,
6857            0x1352_5EED,
6858        )
6859        .unwrap();
6860        let neither = run_monte_carlo_with_station_presence(
6861            in_band_mc_inputs(),
6862            WindConditions::default(),
6863            params.clone(),
6864            0.01,
6865            Some(0x1352_5EED),
6866            StationAtmospherePresence::NEITHER,
6867        )
6868        .unwrap();
6869        for results in [&seeded, &neither] {
6870            assert_eq!(results.ranges.len(), 50);
6871            for (i, (range, velocity)) in pinned.iter().enumerate() {
6872                assert_eq!(results.ranges[i].to_bits(), range.to_bits(), "range[{i}]");
6873                assert_eq!(
6874                    results.impact_velocities[i].to_bits(),
6875                    velocity.to_bits(),
6876                    "velocity[{i}]"
6877                );
6878            }
6879        }
6880        let both = run_monte_carlo_with_station_presence(
6881            in_band_mc_inputs(),
6882            WindConditions::default(),
6883            params,
6884            0.01,
6885            Some(0x1352_5EED),
6886            StationAtmospherePresence::BOTH,
6887        )
6888        .unwrap();
6889        assert_ne!(both.ranges[0].to_bits(), pinned[0].0.to_bits());
6890        // 1013 hPa is denser than the ICAO 995 hPa it used to be swapped for: shorter flights.
6891        assert!(both.ranges[0] < pinned[0].0);
6892    }
6893
6894    #[test]
6895    fn adaptive_monte_carlo_twin_is_pinned_and_both_differs() {
6896        let params = MonteCarloParams {
6897            num_simulations: 1,
6898            velocity_std_dev: 3.0,
6899            angle_std_dev: 3.5e-4,
6900            bc_std_dev: 0.01,
6901            wind_speed_std_dev: 1.0,
6902            target_distance: Some(300.0),
6903            base_wind_speed: 0.0,
6904            base_wind_direction: 0.0,
6905            azimuth_std_dev: 3.5e-4,
6906        };
6907        let convergence = McConvergence {
6908            min_samples: 40,
6909            max_samples: 80,
6910            batch_size: 40,
6911            ..McConvergence::default()
6912        };
6913        let legacy = run_monte_carlo_adaptive_seeded(
6914            &in_band_mc_inputs(),
6915            &WindConditions::default(),
6916            &params,
6917            &convergence,
6918            DEFAULT_HIT_RADIUS_M,
6919            7,
6920        )
6921        .unwrap();
6922        let neither = run_monte_carlo_adaptive_seeded_with_station_presence(
6923            &in_band_mc_inputs(),
6924            &WindConditions::default(),
6925            &params,
6926            &convergence,
6927            DEFAULT_HIT_RADIUS_M,
6928            7,
6929            StationAtmospherePresence::NEITHER,
6930        )
6931        .unwrap();
6932        for report in [&legacy, &neither] {
6933            assert_eq!(report.samples, 80);
6934            assert_eq!(report.attempts, 80);
6935            // captured at ab4eb0c
6936            assert_eq!(
6937                report.hit_probability.to_bits(),
6938                f64::from_bits(0x3fef99999999999a).to_bits()
6939            );
6940            assert_eq!(
6941                report.mean_impact_velocity_mps.to_bits(),
6942                0x406e99be313a64aa
6943            );
6944            assert_eq!(report.mean_drop_at_target_m.to_bits(), 0x3f63de496e905406);
6945        }
6946        let both = run_monte_carlo_adaptive_seeded_with_station_presence(
6947            &in_band_mc_inputs(),
6948            &WindConditions::default(),
6949            &params,
6950            &convergence,
6951            DEFAULT_HIT_RADIUS_M,
6952            7,
6953            StationAtmospherePresence::BOTH,
6954        )
6955        .unwrap();
6956        assert_ne!(both.mean_impact_velocity_mps.to_bits(), 0x406e99be313a64aa);
6957    }
6958
6959    /// Every trial's impact position is its deviation from the baseline solve, so the baseline
6960    /// must fly the trials' air. With no dispersion each trial is the baseline itself, so the
6961    /// deviation is exactly zero under every presence, in-band readings included.
6962    #[test]
6963    fn monte_carlo_baseline_flies_the_trials_air() {
6964        let params = MonteCarloParams {
6965            num_simulations: 3,
6966            velocity_std_dev: 0.0,
6967            angle_std_dev: 0.0,
6968            bc_std_dev: 0.0,
6969            wind_speed_std_dev: 0.0,
6970            target_distance: Some(500.0),
6971            base_wind_speed: 0.0,
6972            base_wind_direction: 0.0,
6973            azimuth_std_dev: 0.0,
6974        };
6975        let inputs = BallisticInputs {
6976            temperature: 15.0,
6977            ..in_band_mc_inputs()
6978        };
6979        for presence in [
6980            StationAtmospherePresence::NEITHER,
6981            StationAtmospherePresence::new(true, false),
6982            StationAtmospherePresence::new(false, true),
6983            StationAtmospherePresence::BOTH,
6984        ] {
6985            let results = run_monte_carlo_with_station_presence(
6986                inputs.clone(),
6987                WindConditions::default(),
6988                params.clone(),
6989                0.0,
6990                Some(7),
6991                presence,
6992            )
6993            .unwrap();
6994            assert_eq!(results.impact_positions.len(), 3);
6995            for position in &results.impact_positions {
6996                assert_eq!(position.y.to_bits(), 0.0_f64.to_bits(), "{presence:?}");
6997                assert_eq!(position.z.to_bits(), 0.0_f64.to_bits(), "{presence:?}");
6998            }
6999        }
7000    }
7001
7002    /// A zeroed drop fit zeroes in the air it flies: drops generated from a solve zeroed at
7003    /// 100 m in a typed in-band air fit back with no residual. A zero left on the old rule
7004    /// (ICAO air) under a flight in the typed air leaves one at every candidate BC.
7005    #[test]
7006    fn estimate_bc_fit_twin_zeroes_in_the_supplied_air() {
7007        let atmosphere = air(15.0, 1013.0, 150.0);
7008        let presence = StationAtmospherePresence::BOTH;
7009        let mut inputs = BallisticInputs {
7010            muzzle_velocity: 823.0,
7011            bc_value: 0.475,
7012            bc_type: DragModel::G1,
7013            bullet_mass: 0.0109,
7014            bullet_diameter: 0.00782,
7015            sight_height: 0.0381,
7016            ..Default::default()
7017        };
7018        inputs.muzzle_angle = calculate_zero_angle_with_station_presence(
7019            inputs.clone(),
7020            100.0,
7021            0.0381,
7022            WindConditions::default(),
7023            atmosphere.clone(),
7024            presence,
7025        )
7026        .unwrap();
7027        let mut solver = TrajectorySolver::new_with_station_presence(
7028            inputs,
7029            WindConditions::default(),
7030            atmosphere.clone(),
7031            presence,
7032        );
7033        solver.set_max_range(750.0);
7034        let flight = solver.solve().unwrap();
7035        let points: Vec<(f64, f64)> = [300.0, 500.0]
7036            .iter()
7037            .map(|&d| {
7038                (
7039                    d,
7040                    fit_value_at(&flight.points, d, BcFitMode::Drop, 0.0381).unwrap(),
7041                )
7042            })
7043            .collect();
7044        let fit = estimate_bc_fit_with_station_presence(
7045            823.0,
7046            0.0109,
7047            0.00782,
7048            &points,
7049            DragModel::G1,
7050            BcFitMode::Drop,
7051            atmosphere,
7052            Some(100.0),
7053            0.0381,
7054            presence,
7055        )
7056        .unwrap();
7057        assert!((fit.bc - 0.475).abs() < 1e-9, "{fit:?}");
7058        assert!(fit.rms_error < 1e-9, "{fit:?}");
7059    }
7060
7061    #[test]
7062    fn estimate_bc_fit_twin_is_pinned_and_both_fits_the_supplied_air() {
7063        let points = [(300.0, 650.0), (500.0, 560.0)];
7064        let fit = |p: f64, presence| {
7065            estimate_bc_fit_with_station_presence(
7066                823.0,
7067                0.0109,
7068                0.00782,
7069                &points,
7070                DragModel::G1,
7071                BcFitMode::Velocity,
7072                air(20.0, p, 150.0),
7073                None,
7074                0.0381,
7075                presence,
7076            )
7077            .unwrap()
7078            .bc
7079        };
7080        let legacy = estimate_bc_fit(
7081            823.0,
7082            0.0109,
7083            0.00782,
7084            &points,
7085            DragModel::G1,
7086            BcFitMode::Velocity,
7087            air(20.0, 1013.0, 150.0),
7088            None,
7089            0.0381,
7090        )
7091        .unwrap()
7092        .bc;
7093        // captured at ab4eb0c: 1013.0 hPa at 150 m was fitted in ICAO 995 hPa air.
7094        let pinned = f64::from_bits(0x3fe0000000000003);
7095        assert_eq!(legacy.to_bits(), pinned.to_bits());
7096        assert_eq!(
7097            fit(1013.0, StationAtmospherePresence::NEITHER).to_bits(),
7098            pinned.to_bits()
7099        );
7100        // The fit searches a 0.001 BC grid and 1 hPa moves the answer by about 0.0005, so the
7101        // out-of-band neighbours are 3 hPa away on each side to make "strictly between"
7102        // resolvable. They are flown as typed by every rule.
7103        let low = fit(1010.0, StationAtmospherePresence::NEITHER);
7104        let high = fit(1016.0, StationAtmospherePresence::NEITHER);
7105        let both = fit(1013.0, StationAtmospherePresence::BOTH);
7106        assert!(low < both && both < high, "{low} < {both} < {high}");
7107    }
7108
7109    #[test]
7110    fn monte_carlo_kernel_twin_is_pinned_and_both_lies_between_neighbours() {
7111        let kernel_inputs = |p: f64| BallisticInputs {
7112            muzzle_velocity: 823.0,
7113            bc_value: 0.475,
7114            bullet_mass: 0.0109,
7115            bullet_diameter: 0.00782,
7116            target_distance: 500.0,
7117            temperature: 20.0,
7118            pressure: p,
7119            altitude: 150.0,
7120            ..Default::default()
7121        };
7122        let solve = |p: f64, presence| {
7123            crate::monte_carlo::solve_trajectory_for_monte_carlo_with_station_presence(
7124                &kernel_inputs(p),
7125                presence,
7126            )
7127            .unwrap()
7128        };
7129        let legacy =
7130            crate::monte_carlo::solve_trajectory_for_monte_carlo(&kernel_inputs(1013.0)).unwrap();
7131        let neither = solve(1013.0, StationAtmospherePresence::NEITHER);
7132        for output in [&legacy, &neither] {
7133            // captured at ab4eb0c
7134            assert_eq!(output.drop.to_bits(), 0x40039eddd41bdb44);
7135            assert_eq!(output.velocity.to_bits(), 0x4080fe7009642434);
7136        }
7137        let low = solve(1012.0, StationAtmospherePresence::NEITHER).velocity;
7138        let high = solve(1014.0, StationAtmospherePresence::NEITHER).velocity;
7139        let both = solve(1013.0, StationAtmospherePresence::BOTH).velocity;
7140        assert!(high < both && both < low, "{high} < {both} < {low}");
7141    }
7142}
7143
7144#[cfg(test)]
7145mod monte_carlo_seeded_tests {
7146    use super::*;
7147
7148    /// Shared fixture for the seeded Monte Carlo tests: the crate defaults at an explicit
7149    /// 800 m/s muzzle velocity in still air.
7150    ///
7151    /// The brief for this task assumed the module already had such a helper; it did not (every
7152    /// test above restated the same two literals inline), so it is introduced here and the
7153    /// adaptive-driver tests below share it. The existing tests keep their own inline fixtures
7154    /// deliberately: rewriting them to call this helper would change nothing about what they
7155    /// assert but would put unrelated churn in the same commit as a bit-for-bit pin.
7156    fn seeded_test_fixture() -> (BallisticInputs, WindConditions) {
7157        (
7158            BallisticInputs {
7159                muzzle_velocity: 800.0,
7160                ..BallisticInputs::default()
7161            },
7162            WindConditions::default(),
7163        )
7164    }
7165
7166    /// Dispersions for the adaptive-driver tests: a 300 m shot with small pointing and wind
7167    /// error, so almost every trial lands inside `DEFAULT_HIT_RADIUS_M` of the baseline.
7168    ///
7169    /// Tuned for *test wall time*, deliberately. A confidence sequence's half-width scales
7170    /// with `sqrt(p(1-p) * log(n) / n)`, so a hit probability near `1` reaches the default
7171    /// `0.02` target inside `min_samples` (1000 trials) while a `p` near `0.5` would need
7172    /// roughly 4-5x as many -- and every trial is a full trajectory solve, run unoptimised
7173    /// under `cargo test`. It stays a little short of a degenerate `p == 1`: a handful of
7174    /// misses in 1000 keeps the hit *counting* under test rather than trivially saturated.
7175    fn loose_params() -> MonteCarloParams {
7176        MonteCarloParams {
7177            num_simulations: 1, // deliberately absurd: the adaptive driver must ignore it
7178            velocity_std_dev: 3.0,
7179            angle_std_dev: 3.5e-4,
7180            bc_std_dev: 0.01,
7181            wind_speed_std_dev: 1.0,
7182            target_distance: Some(300.0),
7183            base_wind_speed: 0.0,
7184            base_wind_direction: 0.0,
7185            azimuth_std_dev: 3.5e-4,
7186        }
7187    }
7188
7189    /// Params whose target plane sits *inside* the ground-impact scatter, so some trials
7190    /// arrive at it and some fall short.
7191    ///
7192    /// The pin fixture records ground impact between 1907 m and 1936 m for this projectile, so
7193    /// a 1920 m plane splits the run. That split is the point: with `loose_params()` every
7194    /// trial arrives, which makes `arrivals`, `samples` and `attempts` numerically identical
7195    /// and therefore useless for catching a report that populates one of them from another.
7196    fn mixed_arrival_params() -> MonteCarloParams {
7197        MonteCarloParams {
7198            num_simulations: 1,
7199            target_distance: Some(1920.0),
7200            ..MonteCarloParams::default()
7201        }
7202    }
7203
7204    /// THE bit-for-bit compatibility contract for the legacy seeded Monte Carlo path
7205    /// (Plan C spec 9.5).
7206    ///
7207    /// Every literal below was captured by running this exact fixture against the tree at
7208    /// commit 5a421bf ("fix(decision-support): close the last three Plan B branch-review
7209    /// gaps"), i.e. the last commit before any Plan C change touched `src/cli_api.rs`, and
7210    /// printed with `{:?}` (shortest round-tripping form, so the pasted literal reproduces the
7211    /// captured `f64` to the bit).
7212    ///
7213    /// It exists to be run BEFORE and AFTER the per-trial body is extracted into
7214    /// [`MonteCarloTrialSampler::sample_one_trial`]. The extraction is only legitimate if the
7215    /// sequence and count of RNG draws per trial is unchanged, and that is not something the
7216    /// type system can check: a reordered or added draw shifts every subsequent sample and
7217    /// shows up here as a mismatch. If this test fails after a refactor of the trial loop, the
7218    /// refactor is wrong -- do not re-capture the literals.
7219    #[test]
7220    fn legacy_seeded_estimates_are_pinned_bit_for_bit() {
7221        let (inputs, wind) = seeded_test_fixture();
7222        let params = MonteCarloParams {
7223            num_simulations: 200,
7224            target_distance: Some(500.0),
7225            ..MonteCarloParams::default()
7226        };
7227
7228        let results = run_monte_carlo_with_wind_and_direction_std_dev_seeded(
7229            inputs,
7230            wind,
7231            params,
7232            0.01,
7233            0x1352_5EED,
7234        )
7235        .expect("seeded legacy run");
7236
7237        // Every trial is recorded: no solve failed and no target-plane interpolation was
7238        // skipped, so the three vectors are all 200 long. This is asserted, not assumed --
7239        // an extraction that started dropping trials would otherwise shorten the vectors
7240        // while the first three entries below still matched.
7241        assert_eq!(results.ranges.len(), 200, "ranges length");
7242        assert_eq!(
7243            results.impact_velocities.len(),
7244            200,
7245            "impact_velocities length"
7246        );
7247        assert_eq!(
7248            results.impact_positions.len(),
7249            200,
7250            "impact_positions length"
7251        );
7252
7253        // 28 hits in 200 trials.
7254        assert_eq!(
7255            results.hit_probability(DEFAULT_HIT_RADIUS_M).to_bits(),
7256            0.14_f64.to_bits(),
7257            "hit_probability = {:?}",
7258            results.hit_probability(DEFAULT_HIT_RADIUS_M)
7259        );
7260
7261        let expected_ranges: [f64; 3] = [1907.972891143359, 1936.408435469319, 1912.8150447617645];
7262        let expected_velocities: [f64; 3] =
7263            [238.6187151542299, 239.91923651600106, 243.14112455427164];
7264        let expected_positions: [(f64, f64, f64); 3] = [
7265            (0.0, -0.0643556039548101, 0.7344970252014579),
7266            (0.0, 0.5769422971539162, 0.27227201756386726),
7267            (0.0, -0.7440425792472842, 0.1541804446282822),
7268        ];
7269
7270        for (i, expected) in expected_ranges.iter().enumerate() {
7271            assert_eq!(
7272                results.ranges[i].to_bits(),
7273                expected.to_bits(),
7274                "ranges[{i}] = {:?}, pinned {expected:?}",
7275                results.ranges[i]
7276            );
7277        }
7278        for (i, expected) in expected_velocities.iter().enumerate() {
7279            assert_eq!(
7280                results.impact_velocities[i].to_bits(),
7281                expected.to_bits(),
7282                "impact_velocities[{i}] = {:?}, pinned {expected:?}",
7283                results.impact_velocities[i]
7284            );
7285        }
7286        for (i, (x, y, z)) in expected_positions.iter().enumerate() {
7287            let actual = results.impact_positions[i];
7288            assert_eq!(
7289                actual.x.to_bits(),
7290                x.to_bits(),
7291                "impact_positions[{i}].x = {:?}",
7292                actual.x
7293            );
7294            assert_eq!(
7295                actual.y.to_bits(),
7296                y.to_bits(),
7297                "impact_positions[{i}].y = {:?}",
7298                actual.y
7299            );
7300            assert_eq!(
7301                actual.z.to_bits(),
7302                z.to_bits(),
7303                "impact_positions[{i}].z = {:?}",
7304                actual.z
7305            );
7306        }
7307    }
7308
7309    #[test]
7310    fn seeded_runs_are_deterministic_and_match_the_using_rng_path() {
7311        let inputs = BallisticInputs {
7312            muzzle_velocity: 800.0,
7313            ..BallisticInputs::default()
7314        };
7315        let params = MonteCarloParams {
7316            num_simulations: 64,
7317            target_distance: Some(200.0),
7318            ..MonteCarloParams::default()
7319        };
7320
7321        let a = run_monte_carlo_with_wind_and_direction_std_dev_seeded(
7322            inputs.clone(),
7323            WindConditions::default(),
7324            params.clone(),
7325            0.01,
7326            42,
7327        )
7328        .expect("seeded run a");
7329        let b = run_monte_carlo_with_wind_and_direction_std_dev_seeded(
7330            inputs,
7331            WindConditions::default(),
7332            params,
7333            0.01,
7334            42,
7335        )
7336        .expect("seeded run b");
7337
7338        assert_eq!(a.ranges.len(), b.ranges.len());
7339        for (ra, rb) in a.ranges.iter().zip(b.ranges.iter()) {
7340            assert_eq!(ra.to_bits(), rb.to_bits());
7341        }
7342        for (pa, pb) in a.impact_positions.iter().zip(b.impact_positions.iter()) {
7343            assert_eq!(pa.x.to_bits(), pb.x.to_bits());
7344            assert_eq!(pa.y.to_bits(), pb.y.to_bits());
7345            assert_eq!(pa.z.to_bits(), pb.z.to_bits());
7346        }
7347    }
7348
7349    #[test]
7350    fn different_seeds_generally_produce_different_draws() {
7351        let inputs = BallisticInputs {
7352            muzzle_velocity: 800.0,
7353            ..BallisticInputs::default()
7354        };
7355        let params = MonteCarloParams {
7356            num_simulations: 32,
7357            velocity_std_dev: 5.0,
7358            target_distance: Some(200.0),
7359            ..MonteCarloParams::default()
7360        };
7361
7362        let a = run_monte_carlo_with_wind_and_direction_std_dev_seeded(
7363            inputs.clone(),
7364            WindConditions::default(),
7365            params.clone(),
7366            0.0,
7367            1,
7368        )
7369        .expect("seeded run a");
7370        let b = run_monte_carlo_with_wind_and_direction_std_dev_seeded(
7371            inputs,
7372            WindConditions::default(),
7373            params,
7374            0.0,
7375            2,
7376        )
7377        .expect("seeded run b");
7378
7379        assert_ne!(a.impact_velocities, b.impact_velocities);
7380    }
7381
7382    #[test]
7383    fn adaptive_stops_at_target_half_width_on_an_easy_case() {
7384        let (inputs, wind) = seeded_test_fixture();
7385        let conv = McConvergence {
7386            target_half_width: 0.05,
7387            ..Default::default()
7388        };
7389        let r = run_monte_carlo_adaptive_seeded(
7390            &inputs,
7391            &wind,
7392            &loose_params(),
7393            &conv,
7394            DEFAULT_HIT_RADIUS_M,
7395            0x1352_ADA9,
7396        )
7397        .unwrap();
7398
7399        assert_eq!(r.stop_reason, McStopReason::TargetHalfWidthMet);
7400        assert!(
7401            (r.ci_high - r.ci_low) / 2.0 <= 0.05 + 1e-12,
7402            "half-width {} exceeds the requested 0.05",
7403            (r.ci_high - r.ci_low) / 2.0
7404        );
7405        assert!(r.samples >= conv.min_samples, "stopped below min_samples");
7406        assert!(r.samples < conv.max_samples, "did not actually stop early");
7407        assert!(r.samples.is_multiple_of(conv.batch_size) || r.samples == conv.min_samples);
7408        assert!(r.ci_low <= r.hit_probability && r.hit_probability <= r.ci_high);
7409
7410        // The report must describe the run it actually did, not the params it ignored.
7411        assert_eq!(r.hit_radius_m, DEFAULT_HIT_RADIUS_M);
7412        assert_eq!(r.target_distance_m, 300.0);
7413        assert_eq!(r.confidence_percent, 95);
7414        // `num_simulations` is 1 in `loose_params`; if it were honoured we would see 1 sample.
7415        assert!(
7416            r.samples > 1,
7417            "params.num_simulations must be ignored by the adaptive driver"
7418        );
7419        // The streaming moments ran over trials that reached the plane: a 300 m shot at
7420        // 800 m/s is still supersonic, and the dispersions are nonzero, so both must be too.
7421        assert!(
7422            r.mean_impact_velocity_mps > 0.0,
7423            "no impact velocity accumulated"
7424        );
7425        assert!(
7426            r.std_drop_at_target_m > 0.0 && r.std_wind_drift_at_target_m > 0.0,
7427            "dispersion collapsed: drop sd {} drift sd {}",
7428            r.std_drop_at_target_m,
7429            r.std_wind_drift_at_target_m
7430        );
7431    }
7432
7433    #[test]
7434    fn adaptive_caps_at_max_samples_on_an_impossible_target() {
7435        let (inputs, wind) = seeded_test_fixture();
7436        let conv = McConvergence {
7437            target_half_width: 1e-6,
7438            max_samples: 3_000,
7439            batch_size: 500,
7440            min_samples: 1_000,
7441            level: ConfidenceLevel::P95,
7442        };
7443        let r = run_monte_carlo_adaptive_seeded(
7444            &inputs,
7445            &wind,
7446            &loose_params(),
7447            &conv,
7448            DEFAULT_HIT_RADIUS_M,
7449            7,
7450        )
7451        .unwrap();
7452
7453        assert_eq!(r.stop_reason, McStopReason::MaxSamplesReached);
7454        assert_eq!(r.samples, 3_000);
7455    }
7456
7457    /// The adaptive middle: a stop that is decided by the DATA, not by either bound.
7458    ///
7459    /// Every other stopping test lands on a boundary -- the easy case and the determinism case
7460    /// both stop at exactly `min_samples`, and the impossible case runs to `max_samples`. None
7461    /// of them would notice if the half-width check were, say, only consulted on the first
7462    /// batch. Here `min_samples` is 0 and `max_samples` is far away, so the sample count is
7463    /// whatever the confidence sequence decides it is, and the assertions pin that it landed
7464    /// strictly inside both bounds after more than one batch.
7465    #[test]
7466    fn adaptive_stops_between_the_floor_and_the_ceiling() {
7467        let (inputs, wind) = seeded_test_fixture();
7468        let conv = McConvergence {
7469            level: ConfidenceLevel::P95,
7470            target_half_width: 0.03,
7471            min_samples: 0,
7472            max_samples: 10_000,
7473            batch_size: 100,
7474        };
7475        let r = run_monte_carlo_adaptive_seeded(
7476            &inputs,
7477            &wind,
7478            &loose_params(),
7479            &conv,
7480            DEFAULT_HIT_RADIUS_M,
7481            0x1352_5A1D,
7482        )
7483        .unwrap();
7484
7485        assert_eq!(r.stop_reason, McStopReason::TargetHalfWidthMet);
7486        assert!(r.samples > 0);
7487        assert!(
7488            r.samples.is_multiple_of(conv.batch_size),
7489            "samples {} is not a whole number of batches",
7490            r.samples
7491        );
7492        assert!(
7493            r.samples > conv.min_samples,
7494            "stopped on the floor, not on the data"
7495        );
7496        assert!(
7497            r.samples < conv.max_samples,
7498            "ran to the ceiling, so nothing adaptive was exercised"
7499        );
7500        assert!(
7501            r.samples > conv.batch_size,
7502            "stopped on the very first look ({} samples); the multi-batch path is untested",
7503            r.samples
7504        );
7505        assert!((r.ci_high - r.ci_low) / 2.0 <= 0.03 + 1e-12);
7506        assert!(r.ci_low <= r.hit_probability && r.hit_probability <= r.ci_high);
7507        assert_eq!(r.attempts, r.samples, "no trial should have been dropped");
7508    }
7509
7510    /// The final batch is truncated so `max_samples` is a hard ceiling, not a threshold the
7511    /// last batch overshoots.
7512    ///
7513    /// `750 = 500 + 250`, so the second batch must run short. With a `max_samples` that is a
7514    /// whole multiple of `batch_size` -- as in every other test here -- the
7515    /// `batch_size.min(max_samples - attempts)` clamp never actually clamps, and a driver that
7516    /// ignored it entirely would pass. This one would report 1000.
7517    #[test]
7518    fn adaptive_runs_a_truncated_final_batch_up_to_max_samples() {
7519        let (inputs, wind) = seeded_test_fixture();
7520        let conv = McConvergence {
7521            level: ConfidenceLevel::P95,
7522            target_half_width: 1e-6,
7523            min_samples: 0,
7524            max_samples: 750,
7525            batch_size: 500,
7526        };
7527        let r = run_monte_carlo_adaptive_seeded(
7528            &inputs,
7529            &wind,
7530            &loose_params(),
7531            &conv,
7532            DEFAULT_HIT_RADIUS_M,
7533            0x1352_7B10,
7534        )
7535        .unwrap();
7536
7537        assert_eq!(r.stop_reason, McStopReason::MaxSamplesReached);
7538        assert_eq!(
7539            r.samples, 750,
7540            "the 250-trial final batch did not run, or was not truncated"
7541        );
7542        assert_eq!(r.attempts, 750);
7543        assert!(!r.samples.is_multiple_of(conv.batch_size));
7544    }
7545
7546    #[test]
7547    fn adaptive_is_deterministic_for_a_seed() {
7548        let (inputs, wind) = seeded_test_fixture();
7549        let conv = McConvergence::default();
7550        let a = run_monte_carlo_adaptive_seeded(
7551            &inputs,
7552            &wind,
7553            &loose_params(),
7554            &conv,
7555            DEFAULT_HIT_RADIUS_M,
7556            99,
7557        )
7558        .unwrap();
7559        let b = run_monte_carlo_adaptive_seeded(
7560            &inputs,
7561            &wind,
7562            &loose_params(),
7563            &conv,
7564            DEFAULT_HIT_RADIUS_M,
7565            99,
7566        )
7567        .unwrap();
7568
7569        assert_eq!(a.hit_probability.to_bits(), b.hit_probability.to_bits());
7570        assert_eq!(a.samples, b.samples);
7571        assert_eq!(a.ci_low.to_bits(), b.ci_low.to_bits());
7572        assert_eq!(a.ci_high.to_bits(), b.ci_high.to_bits());
7573        // The streaming moments have to be reproducible too, not just the hit counts: they
7574        // are the part that would drift if the trial body consumed draws inconsistently.
7575        assert_eq!(
7576            a.mean_impact_velocity_mps.to_bits(),
7577            b.mean_impact_velocity_mps.to_bits()
7578        );
7579        assert_eq!(
7580            a.std_drop_at_target_m.to_bits(),
7581            b.std_drop_at_target_m.to_bits()
7582        );
7583    }
7584
7585    #[test]
7586    fn adaptive_report_carries_schema_method_and_all_four_assumptions() {
7587        let (inputs, wind) = seeded_test_fixture();
7588        // Nothing here depends on the statistics converging, so keep it to one batch. The
7589        // fixture is the mixed-arrival one so the three cardinalities come out DISTINCT --
7590        // see the assertions below.
7591        let conv = McConvergence {
7592            min_samples: 0,
7593            max_samples: 50,
7594            batch_size: 50,
7595            target_half_width: 1.0,
7596            level: ConfidenceLevel::P90,
7597        };
7598        let r = run_monte_carlo_adaptive_seeded(
7599            &inputs,
7600            &wind,
7601            &mixed_arrival_params(),
7602            &conv,
7603            DEFAULT_HIT_RADIUS_M,
7604            0x1352_D0C5,
7605        )
7606        .unwrap();
7607
7608        assert_eq!(r.schema_version, MC_ADAPTIVE_SCHEMA_VERSION_V1);
7609        assert_eq!(r.schema_version, 1);
7610        assert_eq!(r.method, "anytime_beta_binomial_mixture_cs_v1");
7611        assert_eq!(r.confidence_percent, 90);
7612
7613        // The three cardinalities, pinned against each other rather than each against 50.
7614        // `attempts` and `samples` are both 50 here (nothing was dropped), but `arrivals` is
7615        // strictly smaller because the 1920 m plane sits inside the ground-impact scatter --
7616        // so a report that populated `arrivals` from `samples` (or vice versa) fails here,
7617        // which it could not if all three were the same number.
7618        assert_eq!(r.attempts, 50, "one full batch was drawn");
7619        assert_eq!(r.samples, 50, "no trial was dropped by the solver");
7620        assert!(
7621            r.arrivals > 0 && r.arrivals < r.samples,
7622            "fixture must split the run: arrivals {} of samples {}",
7623            r.arrivals,
7624            r.samples
7625        );
7626        // ...and the at-target statistics really are the `arrivals` population: two or more
7627        // arrivals is what makes a Bessel-corrected standard deviation defined at all.
7628        assert!(
7629            r.arrivals >= 2,
7630            "arrivals {} too few for a sample sd",
7631            r.arrivals
7632        );
7633        assert!(r.std_drop_at_target_m > 0.0 && r.std_impact_velocity_mps > 0.0);
7634        // The ordering invariant the doc comments promise.
7635        assert!(r.attempts >= r.samples && r.samples >= r.arrivals);
7636
7637        // Length AND per-index full-string equality against literals restated here character
7638        // for character. Deliberately NOT compared against MC_ADAPTIVE_ASSUMPTIONS_V1: that
7639        // would be circular, passing for any edit to the constant. `contains()` is likewise
7640        // not a pin -- a substring probe survives a sentence being reworded, truncated, or
7641        // having its meaning inverted around the matched word.
7642        assert_eq!(r.assumptions.len(), 4, "exactly four assumptions expected");
7643        assert_eq!(
7644            r.assumptions[0],
7645            "Sampling uncertainty only: intervals cover Monte Carlo sampling error, not model error in the trajectory solver or its inputs."
7646        );
7647        assert_eq!(
7648            r.assumptions[1],
7649            "Anytime-valid stopping: the beta-binomial mixture confidence sequence keeps its coverage guarantee despite stopping the moment the target half-width is met."
7650        );
7651        assert_eq!(
7652            r.assumptions[2],
7653            "Input dispersions are the independent normal distributions declared in MonteCarloParams; correlations between inputs are not modeled."
7654        );
7655        assert_eq!(
7656            r.assumptions[3],
7657            "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."
7658        );
7659
7660        // The stop reason is a snake_case string on the wire, not a variant name or an index.
7661        assert_eq!(
7662            serde_json::to_string(&McStopReason::TargetHalfWidthMet).unwrap(),
7663            "\"target_half_width_met\""
7664        );
7665        assert_eq!(
7666            serde_json::to_string(&McStopReason::MaxSamplesReached).unwrap(),
7667            "\"max_samples_reached\""
7668        );
7669    }
7670
7671    #[test]
7672    fn adaptive_rejects_nonsense_convergence() {
7673        let (inputs, wind) = seeded_test_fixture();
7674        let run = |conv: McConvergence| {
7675            run_monte_carlo_adaptive_seeded(
7676                &inputs,
7677                &wind,
7678                &loose_params(),
7679                &conv,
7680                DEFAULT_HIT_RADIUS_M,
7681                1,
7682            )
7683            .unwrap_err()
7684        };
7685
7686        for bad_width in [0.0, -0.01, f64::NAN] {
7687            let err = run(McConvergence {
7688                target_half_width: bad_width,
7689                ..Default::default()
7690            });
7691            assert!(
7692                err.contains("target_half_width"),
7693                "error must name the field, got: {err}"
7694            );
7695        }
7696
7697        let err = run(McConvergence {
7698            batch_size: 0,
7699            ..Default::default()
7700        });
7701        assert!(err.contains("batch_size"), "got: {err}");
7702
7703        let err = run(McConvergence {
7704            min_samples: 5_000,
7705            max_samples: 1_000,
7706            ..Default::default()
7707        });
7708        assert!(err.contains("max_samples"), "got: {err}");
7709        assert!(err.contains("min_samples"), "got: {err}");
7710
7711        let err = run(McConvergence {
7712            max_samples: 0,
7713            min_samples: 0,
7714            ..Default::default()
7715        });
7716        assert!(err.contains("max_samples"), "got: {err}");
7717
7718        // Validation happens before any trajectory work: a rejected config costs no solves.
7719        let err = McConvergence {
7720            batch_size: 0,
7721            ..Default::default()
7722        }
7723        .validate()
7724        .unwrap_err();
7725        assert!(err.contains("batch_size"));
7726    }
7727
7728    #[test]
7729    fn wilson_companion_matches_hit_probability_and_wilson_interval() {
7730        let (inputs, wind) = seeded_test_fixture();
7731        let params = MonteCarloParams {
7732            num_simulations: 128,
7733            target_distance: Some(500.0),
7734            ..MonteCarloParams::default()
7735        };
7736        let results = run_monte_carlo_with_wind_and_direction_std_dev_seeded(
7737            inputs,
7738            wind,
7739            params,
7740            0.01,
7741            0x1352_C0DE,
7742        )
7743        .expect("seeded legacy run");
7744
7745        for level in [
7746            ConfidenceLevel::P90,
7747            ConfidenceLevel::P95,
7748            ConfidenceLevel::P99,
7749        ] {
7750            let (p_hat, (lo, hi), n) = results.hit_probability_wilson(DEFAULT_HIT_RADIUS_M, level);
7751
7752            // The companion is a composition, not a re-derivation: p_hat must be the very
7753            // number `hit_probability` returns, bit for bit.
7754            assert_eq!(
7755                p_hat.to_bits(),
7756                results.hit_probability(DEFAULT_HIT_RADIUS_M).to_bits()
7757            );
7758            assert_eq!(n, results.impact_positions.len() as u64);
7759
7760            // ...and the bounds must be `wilson_interval` over the same counts. The hit count
7761            // is recomputed here from the shared predicate rather than taken from the
7762            // companion, so a wrong denominator (e.g. excluding target shortfalls) fails.
7763            let hits = results
7764                .impact_positions
7765                .iter()
7766                .filter(|p| MonteCarloResults::position_is_hit(p, DEFAULT_HIT_RADIUS_M))
7767                .count() as u64;
7768            let (want_lo, want_hi) = wilson_interval(hits, n, level);
7769            assert_eq!(lo.to_bits(), want_lo.to_bits());
7770            assert_eq!(hi.to_bits(), want_hi.to_bits());
7771            assert!(lo <= p_hat && p_hat <= hi, "interval excludes p_hat");
7772        }
7773
7774        // Empty results: hit_probability's 0.0 alongside wilson_interval's n == 0 ignorance.
7775        let empty = MonteCarloResults {
7776            ranges: Vec::new(),
7777            impact_velocities: Vec::new(),
7778            impact_positions: Vec::new(),
7779        };
7780        assert_eq!(
7781            empty.hit_probability_wilson(DEFAULT_HIT_RADIUS_M, ConfidenceLevel::P95),
7782            (0.0, (0.0, 1.0), 0)
7783        );
7784    }
7785}
7786
7787#[cfg(test)]
7788mod monte_carlo_powder_curve_tests {
7789    use super::*;
7790    use rand::{rngs::StdRng, SeedableRng};
7791
7792    #[test]
7793    fn powder_curve_preserves_sampled_muzzle_velocity_dispersion() {
7794        let inputs = BallisticInputs {
7795            muzzle_velocity: 700.0,
7796            powder_temp_curve: Some(vec![(15.0, 800.0)]),
7797            powder_curve_temp_c: Some(15.0),
7798            ..BallisticInputs::default()
7799        };
7800        let params = MonteCarloParams {
7801            num_simulations: 16,
7802            velocity_std_dev: 20.0,
7803            angle_std_dev: 1e-12,
7804            bc_std_dev: 1e-12,
7805            wind_speed_std_dev: 1e-12,
7806            target_distance: Some(100.0),
7807            azimuth_std_dev: 1e-12,
7808            ..MonteCarloParams::default()
7809        };
7810
7811        let mut rng = StdRng::seed_from_u64(0x5EED_1176);
7812        let results = run_monte_carlo_with_wind_and_direction_std_dev_using_rng(
7813            inputs,
7814            WindConditions::default(),
7815            params,
7816            0.0,
7817            StationAtmospherePresence::NEITHER,
7818            &mut rng,
7819        )
7820        .expect("Monte Carlo solve");
7821        let min_velocity = results
7822            .impact_velocities
7823            .iter()
7824            .copied()
7825            .fold(f64::INFINITY, f64::min);
7826        let max_velocity = results
7827            .impact_velocities
7828            .iter()
7829            .copied()
7830            .fold(f64::NEG_INFINITY, f64::max);
7831
7832        assert!(
7833            max_velocity - min_velocity > 1.0,
7834            "20 m/s muzzle spread collapsed after curve resolution: impact-velocity span={} m/s",
7835            max_velocity - min_velocity
7836        );
7837    }
7838}
7839
7840#[cfg(test)]
7841mod monte_carlo_wind_sampling_tests {
7842    use super::*;
7843    use rand::{rngs::StdRng, SeedableRng};
7844
7845    #[test]
7846    fn wind_speed_sigma_does_not_change_seeded_direction_draws() {
7847        let base_wind = WindConditions {
7848            speed: 100.0,
7849            direction: 0.37,
7850            vertical_speed: 0.0,
7851        };
7852        let narrow_speed = MonteCarloWindSampler::new(&base_wind, 0.5, 0.2).unwrap();
7853        let wide_speed = MonteCarloWindSampler::new(&base_wind, 4.0, 0.2).unwrap();
7854        let mut narrow_rng = StdRng::seed_from_u64(0x5EED_1223);
7855        let mut wide_rng = StdRng::seed_from_u64(0x5EED_1223);
7856        let mut speed_changed = false;
7857
7858        for _ in 0..32 {
7859            let narrow = narrow_speed.sample(&mut narrow_rng);
7860            let wide = wide_speed.sample(&mut wide_rng);
7861            assert!(narrow.speed > 0.0 && wide.speed > 0.0);
7862            assert_eq!(narrow.direction.to_bits(), wide.direction.to_bits());
7863            speed_changed |= narrow.speed.to_bits() != wide.speed.to_bits();
7864        }
7865        assert!(
7866            speed_changed,
7867            "different speed sigmas must still vary speed draws"
7868        );
7869    }
7870
7871    #[test]
7872    fn zero_direction_sigma_has_no_angular_jitter() {
7873        let base_wind = WindConditions {
7874            speed: 100.0,
7875            direction: 0.37,
7876            vertical_speed: 0.0,
7877        };
7878        let sampler = MonteCarloWindSampler::new(&base_wind, 4.0, 0.0).unwrap();
7879        let mut rng = StdRng::seed_from_u64(0x5EED_1223);
7880        let mut speed_changed = false;
7881
7882        for _ in 0..32 {
7883            let wind = sampler.sample(&mut rng);
7884            speed_changed |= wind.speed.to_bits() != base_wind.speed.to_bits();
7885            assert_eq!(wind.direction.to_bits(), base_wind.direction.to_bits());
7886        }
7887        assert!(speed_changed, "speed uncertainty should remain active");
7888    }
7889
7890    #[test]
7891    fn direction_sigma_controls_seeded_angular_spread_in_radians() {
7892        let base_wind = WindConditions {
7893            speed: 100.0,
7894            direction: 0.37,
7895            vertical_speed: 0.0,
7896        };
7897        let narrow = MonteCarloWindSampler::new(&base_wind, 4.0, 0.1).unwrap();
7898        let wide = MonteCarloWindSampler::new(&base_wind, 4.0, 0.2).unwrap();
7899        let mut narrow_rng = StdRng::seed_from_u64(0x5EED_1223);
7900        let mut wide_rng = StdRng::seed_from_u64(0x5EED_1223);
7901        let mut nonzero_direction_draw = false;
7902
7903        for _ in 0..32 {
7904            let narrow_wind = narrow.sample(&mut narrow_rng);
7905            let wide_wind = wide.sample(&mut wide_rng);
7906            assert_eq!(narrow_wind.speed.to_bits(), wide_wind.speed.to_bits());
7907
7908            let narrow_delta = narrow_wind.direction - base_wind.direction;
7909            let wide_delta = wide_wind.direction - base_wind.direction;
7910            assert!((wide_delta - 2.0 * narrow_delta).abs() < 1e-12);
7911            nonzero_direction_draw |= narrow_delta.abs() > 1e-6;
7912        }
7913        assert!(
7914            nonzero_direction_draw,
7915            "positive radians sigma must vary direction"
7916        );
7917    }
7918
7919    #[test]
7920    fn direction_sigma_rejects_negative_or_nonfinite_values() {
7921        let base_wind = WindConditions::default();
7922        for sigma in [-0.1, f64::NAN, f64::INFINITY] {
7923            assert!(MonteCarloWindSampler::new(&base_wind, 1.0, sigma).is_err());
7924        }
7925    }
7926
7927    #[test]
7928    fn base_vertical_wind_rides_into_every_mc_sample() {
7929        // MBA-728: vertical wind is a systematic input, not a dispersion source —
7930        // every sampled wind must carry the base vertical un-dispersed. (Before
7931        // this fix, samples dropped it, biasing the whole MC cloud vs the baseline.)
7932        use rand::SeedableRng;
7933        let base_wind = WindConditions {
7934            vertical_speed: 4.2,
7935            ..Default::default()
7936        };
7937        let sampler = MonteCarloWindSampler::new(&base_wind, 1.0, 0.2).unwrap();
7938        let mut rng = rand::rngs::StdRng::seed_from_u64(7);
7939        for _ in 0..32 {
7940            let w = sampler.sample(&mut rng);
7941            assert_eq!(w.vertical_speed, 4.2);
7942        }
7943    }
7944
7945    #[test]
7946    fn negative_speed_sample_reverses_wind_direction() {
7947        let direction = 0.25;
7948        let signed_speed = -2.5;
7949        let wind = wind_from_signed_speed_sample(signed_speed, direction, 0.0);
7950        let positive_wind = wind_from_signed_speed_sample(2.5, direction, 0.0);
7951
7952        assert_eq!(wind.speed, 2.5);
7953        assert!(
7954            (wind.direction - (direction + std::f64::consts::PI)).abs() < f64::EPSILON,
7955            "negative speed must reverse direction by pi: got {}",
7956            wind.direction
7957        );
7958        assert_eq!(positive_wind.speed, 2.5);
7959        assert_eq!(positive_wind.direction, direction);
7960
7961        let normalized_x = -wind.speed * wind.direction.cos();
7962        let normalized_z = -wind.speed * wind.direction.sin();
7963        let signed_x = -signed_speed * direction.cos();
7964        let signed_z = -signed_speed * direction.sin();
7965        assert!((normalized_x - signed_x).abs() < 1e-12);
7966        assert!((normalized_z - signed_z).abs() < 1e-12);
7967    }
7968}
7969
7970#[cfg(test)]
7971mod bc_fit_objective_tests {
7972    use super::*;
7973
7974    fn velocity_point(range_m: f64, velocity_mps: f64) -> TrajectoryPoint {
7975        TrajectoryPoint {
7976            time: 0.0,
7977            position: Vector3::new(range_m, 0.0, 0.0),
7978            velocity_magnitude: velocity_mps,
7979            kinetic_energy: 0.0,
7980            drag_coefficient: None,
7981        }
7982    }
7983
7984    #[test]
7985    fn candidate_that_misses_an_observation_has_no_score() {
7986        let trajectory = vec![velocity_point(0.0, 800.0), velocity_point(100.0, 700.0)];
7987        let observations = vec![(50.0, 750.0), (150.0, 600.0)];
7988
7989        assert!(
7990            fit_residual_sse(&trajectory, &observations, BcFitMode::Velocity, 0.0).is_none(),
7991            "a candidate that reaches only one of two observations must not compete on partial SSE"
7992        );
7993
7994        let complete_observations = vec![(50.0, 740.0), (100.0, 680.0)];
7995        assert_eq!(
7996            fit_residual_sse(
7997                &trajectory,
7998                &complete_observations,
7999                BcFitMode::Velocity,
8000                0.0,
8001            ),
8002            Some(500.0)
8003        );
8004    }
8005}
8006
8007#[cfg(test)]
8008mod cluster_bc_reference_space_tests {
8009    use super::*;
8010
8011    fn acceleration_at_1100_fps(inputs: BallisticInputs) -> Vector3<f64> {
8012        let solver = TrajectorySolver::new(
8013            inputs,
8014            WindConditions::default(),
8015            AtmosphericConditions::default(),
8016        );
8017        let position = Vector3::zeros();
8018        let velocity = Vector3::new(1100.0 / 3.28084, 0.0, 0.0);
8019        let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
8020        solver.calculate_acceleration(
8021            &position,
8022            &velocity,
8023            &Vector3::zeros(),
8024            (temp_c, pressure_hpa, density / 1.225),
8025        )
8026    }
8027
8028    #[test]
8029    fn solver_passes_g7_reference_model_to_cluster_classifier() {
8030        let inputs = BallisticInputs {
8031            bc_value: 0.190,
8032            bc_type: DragModel::G7,
8033            bullet_mass: 77.0 * crate::constants::GRAINS_TO_KG,
8034            bullet_diameter: 0.224 * 0.0254,
8035            use_cluster_bc: true,
8036            ..BallisticInputs::default()
8037        };
8038
8039        let solver = TrajectorySolver::new(
8040            inputs,
8041            WindConditions::default(),
8042            AtmosphericConditions::default(),
8043        );
8044        let corrected = solver.apply_cluster_bc_correction(0.190, 2800.0);
8045
8046        assert!(
8047            (corrected / 0.190 - 1.004).abs() < 1e-12,
8048            "solver selected the wrong G7 cluster multiplier: {}",
8049            corrected / 0.190
8050        );
8051    }
8052
8053    #[test]
8054    fn velocity_bc_segments_are_not_cluster_corrected_twice() {
8055        let segmented_clustered = BallisticInputs {
8056            bc_value: 0.5,
8057            bc_type: DragModel::G7,
8058            use_bc_segments: true,
8059            bc_segments_data: Some(vec![
8060                crate::BCSegmentData {
8061                    velocity_min: 0.0,
8062                    velocity_max: 1_600.0,
8063                    bc_value: 0.4,
8064                },
8065                crate::BCSegmentData {
8066                    velocity_min: 1_600.0,
8067                    velocity_max: 5_000.0,
8068                    bc_value: 0.45,
8069                },
8070            ]),
8071            use_cluster_bc: true,
8072            ..BallisticInputs::default()
8073        };
8074        let mut segmented_only = segmented_clustered.clone();
8075        segmented_only.use_cluster_bc = false;
8076        let mut constant_clustered = segmented_clustered.clone();
8077        constant_clustered.bc_value = 0.4;
8078        constant_clustered.bc_segments_data = None;
8079
8080        let stacked = acceleration_at_1100_fps(segmented_clustered);
8081        let segment_only = acceleration_at_1100_fps(segmented_only);
8082        let cluster_only = acceleration_at_1100_fps(constant_clustered);
8083
8084        assert!(
8085            (stacked.x - segment_only.x).abs() < 1e-12,
8086            "segment BC already owns the velocity shape: stacked ax={} segment-only ax={}",
8087            stacked.x,
8088            segment_only.x
8089        );
8090        assert!(
8091            (cluster_only.x - segment_only.x).abs() > 1e-6,
8092            "cluster correction must remain active for a constant BC"
8093        );
8094    }
8095
8096    #[test]
8097    fn mach_bc_segments_are_not_cluster_corrected_twice() {
8098        let mach_segmented_clustered = BallisticInputs {
8099            bc_value: 0.5,
8100            bc_type: DragModel::G7,
8101            use_bc_segments: false,
8102            bc_segments: Some(vec![(0.5, 0.3), (1.5, 0.5)]),
8103            use_cluster_bc: true,
8104            ..BallisticInputs::default()
8105        };
8106        let mut mach_segmented_only = mach_segmented_clustered.clone();
8107        mach_segmented_only.use_cluster_bc = false;
8108
8109        let stacked = acceleration_at_1100_fps(mach_segmented_clustered);
8110        let segment_only = acceleration_at_1100_fps(mach_segmented_only);
8111
8112        assert!(
8113            (stacked.x - segment_only.x).abs() < 1e-12,
8114            "Mach segment BC already owns the velocity shape: stacked ax={} segment-only ax={}",
8115            stacked.x,
8116            segment_only.x
8117        );
8118    }
8119}
8120
8121#[cfg(test)]
8122mod velocity_bc_flag_tests {
8123    use super::*;
8124
8125    fn acceleration_at_600_mps(inputs: BallisticInputs) -> Vector3<f64> {
8126        let solver = TrajectorySolver::new(
8127            inputs,
8128            WindConditions::default(),
8129            AtmosphericConditions::default(),
8130        );
8131        let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
8132        solver.calculate_acceleration(
8133            &Vector3::zeros(),
8134            &Vector3::new(600.0, 0.0, 0.0),
8135            &Vector3::zeros(),
8136            (temp_c, pressure_hpa, density / 1.225),
8137        )
8138    }
8139
8140    #[test]
8141    fn velocity_bc_data_requires_opt_in_in_trajectory_solver() {
8142        let scalar_inputs = BallisticInputs {
8143            bc_value: 0.5,
8144            bc_type: DragModel::G7,
8145            ..BallisticInputs::default()
8146        };
8147        let mut disabled_inputs = scalar_inputs.clone();
8148        disabled_inputs.bc_segments_data = Some(vec![crate::BCSegmentData {
8149            velocity_min: 0.0,
8150            velocity_max: 4_000.0,
8151            bc_value: 0.46,
8152        }]);
8153        disabled_inputs.use_bc_segments = false;
8154        let mut enabled_inputs = disabled_inputs.clone();
8155        enabled_inputs.use_bc_segments = true;
8156        let mut mach_only_inputs = scalar_inputs.clone();
8157        mach_only_inputs.bc_segments = Some(vec![(0.0, 0.4), (3.0, 0.4)]);
8158        let mut disabled_with_both = mach_only_inputs.clone();
8159        disabled_with_both.bc_segments_data = disabled_inputs.bc_segments_data.clone();
8160
8161        let scalar = acceleration_at_600_mps(scalar_inputs);
8162        let disabled = acceleration_at_600_mps(disabled_inputs);
8163        let enabled = acceleration_at_600_mps(enabled_inputs);
8164        let mach_only = acceleration_at_600_mps(mach_only_inputs);
8165        let disabled_with_both = acceleration_at_600_mps(disabled_with_both);
8166
8167        assert_eq!(
8168            disabled.x.to_bits(),
8169            scalar.x.to_bits(),
8170            "a populated velocity table must not change drag while use_bc_segments is false"
8171        );
8172        assert!(
8173            enabled.x < disabled.x - 1.0,
8174            "enabling the lower BC table must increase drag: disabled ax={} enabled ax={}",
8175            disabled.x,
8176            enabled.x
8177        );
8178        assert_eq!(
8179            disabled_with_both.x.to_bits(),
8180            mach_only.x.to_bits(),
8181            "disabling velocity data must fall through to an explicit Mach table"
8182        );
8183    }
8184}
8185
8186#[cfg(test)]
8187mod mach_bc_segment_tests {
8188    use super::*;
8189
8190    #[test]
8191    fn trajectory_solver_interpolates_explicit_mach_bc_segments() {
8192        let segmented_inputs = BallisticInputs {
8193            bc_value: 0.8,
8194            use_bc_segments: false,
8195            bc_segments: Some(vec![(1.0, 0.2), (2.0, 0.4)]),
8196            bc_segments_data: None,
8197            ..BallisticInputs::default()
8198        };
8199
8200        let mut expected_inputs = segmented_inputs.clone();
8201        expected_inputs.bc_value = 0.3;
8202        expected_inputs.bc_segments = None;
8203
8204        let atmosphere = AtmosphericConditions::default();
8205        let segmented_solver = TrajectorySolver::new(
8206            segmented_inputs,
8207            WindConditions::default(),
8208            atmosphere.clone(),
8209        );
8210        let expected_solver =
8211            TrajectorySolver::new(expected_inputs, WindConditions::default(), atmosphere);
8212        let position = Vector3::zeros();
8213        let (density, _, temp_c, pressure_hpa) = segmented_solver.resolved_atmosphere();
8214        let (_, local_speed_of_sound) = crate::atmosphere::get_local_atmosphere_humid(
8215            segmented_solver.atmosphere.altitude,
8216            segmented_solver.atmosphere.altitude,
8217            temp_c,
8218            pressure_hpa,
8219            density / 1.225,
8220            segmented_solver.atmosphere.humidity,
8221        );
8222        let velocity = Vector3::new(1.5 * local_speed_of_sound, 0.0, 0.0);
8223        let resolved_atmo = (temp_c, pressure_hpa, density / 1.225);
8224
8225        let segmented_acceleration = segmented_solver.calculate_acceleration(
8226            &position,
8227            &velocity,
8228            &Vector3::zeros(),
8229            resolved_atmo,
8230        );
8231        let expected_acceleration = expected_solver.calculate_acceleration(
8232            &position,
8233            &velocity,
8234            &Vector3::zeros(),
8235            resolved_atmo,
8236        );
8237
8238        assert!(
8239            (segmented_acceleration.x - expected_acceleration.x).abs() < 1e-12,
8240            "Mach 1.5 must interpolate BC 0.3: segmented ax={} expected ax={}",
8241            segmented_acceleration.x,
8242            expected_acceleration.x
8243        );
8244    }
8245}
8246
8247#[cfg(test)]
8248mod custom_drag_table_validation_tests {
8249    use super::*;
8250
8251    #[test]
8252    fn solve_accepts_zero_bc_when_custom_table_present() {
8253        let inputs = BallisticInputs {
8254            bc_value: 0.0, // ignored when a table is set
8255            bullet_mass: 0.0106,
8256            bullet_diameter: 0.00782,
8257            muzzle_velocity: 850.0,
8258            custom_drag_table: Some(crate::drag::DragTable::new(
8259                vec![0.5, 1.0, 2.0, 3.0],
8260                vec![0.23, 0.40, 0.30, 0.26],
8261            )),
8262            ..BallisticInputs::default()
8263        };
8264        let solver = TrajectorySolver::new(
8265            inputs,
8266            WindConditions::default(),
8267            AtmosphericConditions::default(),
8268        );
8269        // Must not error on the bc_value gate.
8270        assert!(solver.solve().is_ok());
8271    }
8272
8273    #[test]
8274    fn solve_still_requires_bc_without_table() {
8275        let inputs = BallisticInputs {
8276            bc_value: 0.0,
8277            bullet_mass: 0.0106,
8278            bullet_diameter: 0.00782,
8279            muzzle_velocity: 850.0,
8280            ..BallisticInputs::default()
8281        };
8282        let solver = TrajectorySolver::new(
8283            inputs,
8284            WindConditions::default(),
8285            AtmosphericConditions::default(),
8286        );
8287        assert!(solver.solve().is_err());
8288    }
8289}
8290
8291/// MBA-1356: cd_scale — a whole-curve multiplier on the custom-deck Cd.
8292#[cfg(test)]
8293mod cd_scale_tests {
8294    use super::*;
8295
8296    fn deck() -> crate::drag::DragTable {
8297        crate::drag::DragTable::new(vec![0.5, 1.0, 2.0, 3.0], vec![0.23, 0.40, 0.30, 0.26])
8298    }
8299
8300    fn deck_inputs(cd_scale: f64) -> BallisticInputs {
8301        BallisticInputs {
8302            bullet_mass: 0.0106,
8303            bullet_diameter: 0.00782,
8304            muzzle_velocity: 850.0,
8305            custom_drag_table: Some(deck()),
8306            cd_scale,
8307            ..BallisticInputs::default()
8308        }
8309    }
8310
8311    #[test]
8312    fn default_cd_scale_is_one() {
8313        assert_eq!(BallisticInputs::default().cd_scale, 1.0);
8314    }
8315
8316    /// (a) Default invariance: omitting `cd_scale` (picked up from `..Default::default()`)
8317    /// must be byte-identical to explicitly setting `1.0`, on the custom-deck Cd itself.
8318    #[test]
8319    fn cd_scale_absent_is_byte_identical_to_explicit_one() {
8320        let omitted = BallisticInputs {
8321            bullet_mass: 0.0106,
8322            bullet_diameter: 0.00782,
8323            muzzle_velocity: 850.0,
8324            custom_drag_table: Some(deck()),
8325            ..BallisticInputs::default()
8326        };
8327        let explicit = BallisticInputs {
8328            cd_scale: 1.0,
8329            ..omitted.clone()
8330        };
8331
8332        let solver_omitted = TrajectorySolver::new(
8333            omitted,
8334            WindConditions::default(),
8335            AtmosphericConditions::default(),
8336        );
8337        let solver_explicit = TrajectorySolver::new(
8338            explicit,
8339            WindConditions::default(),
8340            AtmosphericConditions::default(),
8341        );
8342
8343        let cd_omitted = solver_omitted.calculate_drag_coefficient(700.0, 340.0);
8344        let cd_explicit = solver_explicit.calculate_drag_coefficient(700.0, 340.0);
8345        assert_eq!(
8346            cd_omitted.to_bits(),
8347            cd_explicit.to_bits(),
8348            "default cd_scale must be bit-identical to an explicit 1.0"
8349        );
8350
8351        // And a full custom-deck solve (the existing pre-MBA-1356 test surface) must still
8352        // succeed unchanged with the field simply absent from the literal.
8353        let result = solver_omitted.solve();
8354        assert!(
8355            result.is_ok(),
8356            "existing custom-deck solves must pass unchanged"
8357        );
8358    }
8359
8360    /// The custom-deck interpolation site multiplies the deck's Cd by `cd_scale` exactly.
8361    #[test]
8362    fn cd_scale_multiplies_the_interpolated_cd_exactly() {
8363        let velocity = 700.0;
8364        let speed_of_sound = 340.0;
8365        let mach = velocity / speed_of_sound;
8366        let expected_unscaled = deck().interpolate(mach);
8367
8368        for &scale in &[0.90, 1.0, 1.10, 1.5] {
8369            let solver = TrajectorySolver::new(
8370                deck_inputs(scale),
8371                WindConditions::default(),
8372                AtmosphericConditions::default(),
8373            );
8374            let cd = solver.calculate_drag_coefficient(velocity, speed_of_sound);
8375            assert!(
8376                (cd - expected_unscaled * scale).abs() < 1e-12,
8377                "scale={scale}: cd={cd} expected={}",
8378                expected_unscaled * scale
8379            );
8380        }
8381    }
8382
8383    /// (b) Scale-direction test, cli_api (RK4/RK45) solver path: a higher cd_scale means more
8384    /// drag, so the solve loses more velocity (and correspondingly drops more) over the same
8385    /// downrange distance; a lower cd_scale loses less.
8386    #[test]
8387    fn cd_scale_direction_on_cli_api_solver() {
8388        let solve = |scale: f64| {
8389            TrajectorySolver::new(
8390                deck_inputs(scale),
8391                WindConditions::default(),
8392                AtmosphericConditions::default(),
8393            )
8394            .solve()
8395            .expect("custom-deck solve should succeed")
8396        };
8397
8398        let baseline = solve(1.0);
8399        let scaled_up = solve(1.10);
8400        let scaled_down = solve(0.90);
8401
8402        assert!(
8403            scaled_up.impact_velocity < baseline.impact_velocity,
8404            "cd_scale=1.10 must increase drag -> lower impact velocity: base={} up={}",
8405            baseline.impact_velocity,
8406            scaled_up.impact_velocity
8407        );
8408        assert!(
8409            scaled_down.impact_velocity > baseline.impact_velocity,
8410            "cd_scale=0.90 must decrease drag -> higher impact velocity: base={} down={}",
8411            baseline.impact_velocity,
8412            scaled_down.impact_velocity
8413        );
8414    }
8415
8416    /// (d) `validate_for_solve` rejects a non-finite or non-positive `cd_scale`.
8417    #[test]
8418    fn validate_for_solve_rejects_invalid_cd_scale() {
8419        for bad in [0.0, -1.0, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
8420            let solver = TrajectorySolver::new(
8421                deck_inputs(bad),
8422                WindConditions::default(),
8423                AtmosphericConditions::default(),
8424            );
8425            assert!(
8426                solver.solve().is_err(),
8427                "cd_scale={bad} must be rejected by validate_for_solve"
8428            );
8429        }
8430    }
8431
8432    /// `require_positive("cd_scale", ...)` is unconditional in `validate_for_solve` (see the
8433    /// comment there) — it must reject an invalid scale even with NO custom drag table present,
8434    /// not just on the deck path the other tests in this module exercise. Otherwise a caller
8435    /// that sets an invalid `cd_scale` without ever touching `custom_drag_table` would slip
8436    /// through unvalidated (the field would simply go unread on this path, but the validation
8437    /// gate itself must not silently skip it).
8438    #[test]
8439    fn validate_for_solve_rejects_invalid_cd_scale_without_a_custom_drag_table() {
8440        for bad in [0.0, -1.0, f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
8441            let inputs = BallisticInputs {
8442                bc_value: 0.5,
8443                bc_type: crate::DragModel::G1,
8444                bullet_mass: 0.0106,
8445                bullet_diameter: 0.00782,
8446                muzzle_velocity: 850.0,
8447                cd_scale: bad,
8448                ..BallisticInputs::default()
8449            };
8450            assert!(
8451                inputs.custom_drag_table.is_none(),
8452                "precondition: no custom deck"
8453            );
8454            let solver = TrajectorySolver::new(
8455                inputs,
8456                WindConditions::default(),
8457                AtmosphericConditions::default(),
8458            );
8459            assert!(
8460                solver.solve().is_err(),
8461                "cd_scale={bad} must be rejected by validate_for_solve even without a custom \
8462                 drag table"
8463            );
8464        }
8465    }
8466
8467    /// cd_scale must be inert on the standard G-model/BC path (no custom_drag_table): the
8468    /// scale is read only inside the `custom_drag_table` branch, so a scale far from 1.0 must
8469    /// not perturb a plain G1/G7 solve at all.
8470    #[test]
8471    fn cd_scale_is_inert_without_a_custom_drag_table() {
8472        let make = |cd_scale: f64| BallisticInputs {
8473            bc_value: 0.5,
8474            bc_type: crate::DragModel::G1,
8475            bullet_mass: 0.0106,
8476            bullet_diameter: 0.00782,
8477            muzzle_velocity: 850.0,
8478            cd_scale,
8479            ..BallisticInputs::default()
8480        };
8481        let solver_neutral = TrajectorySolver::new(
8482            make(1.0),
8483            WindConditions::default(),
8484            AtmosphericConditions::default(),
8485        );
8486        let solver_far = TrajectorySolver::new(
8487            make(1.5),
8488            WindConditions::default(),
8489            AtmosphericConditions::default(),
8490        );
8491        let cd_neutral = solver_neutral.calculate_drag_coefficient(700.0, 340.0);
8492        let cd_far = solver_far.calculate_drag_coefficient(700.0, 340.0);
8493        assert_eq!(
8494            cd_neutral.to_bits(),
8495            cd_far.to_bits(),
8496            "cd_scale must not affect the G-model/BC drag path"
8497        );
8498    }
8499
8500    /// (c) Cross-solver consistency: cli_api (RK4/RK45), derivatives-driven, and fast_trajectory
8501    /// all shift together (same relative direction) under cd_scale != 1.0 on the same deck.
8502    #[test]
8503    fn cd_scale_shifts_all_three_solver_paths_in_the_same_direction() {
8504        // --- cli_api ---
8505        let cli_solve = |scale: f64| {
8506            TrajectorySolver::new(
8507                deck_inputs(scale),
8508                WindConditions::default(),
8509                AtmosphericConditions::default(),
8510            )
8511            .solve()
8512            .expect("cli_api custom-deck solve should succeed")
8513        };
8514        let cli_baseline = cli_solve(1.0);
8515        let cli_scaled = cli_solve(1.10);
8516        assert!(
8517            cli_scaled.impact_velocity < cli_baseline.impact_velocity,
8518            "cli_api: cd_scale=1.10 must lower impact velocity"
8519        );
8520
8521        // --- derivatives (the RK4/RK45 generic integrator's kernel) ---
8522        let derivatives_accel_x = |scale: f64| {
8523            let inputs = deck_inputs(scale);
8524            crate::derivatives::compute_derivatives(
8525                nalgebra::Vector3::zeros(),
8526                nalgebra::Vector3::new(700.0, 0.0, 0.0),
8527                &inputs,
8528                nalgebra::Vector3::zeros(),
8529                (1.225, 340.0, 0.0, 0.0),
8530                inputs.bc_value,
8531                None,
8532                0.0,
8533                None,
8534            )[3]
8535        };
8536        let deriv_baseline = derivatives_accel_x(1.0);
8537        let deriv_scaled = derivatives_accel_x(1.10);
8538        assert!(
8539            deriv_scaled < deriv_baseline,
8540            "derivatives: cd_scale=1.10 must make x-acceleration more negative (more drag): \
8541             base={deriv_baseline} scaled={deriv_scaled}"
8542        );
8543
8544        // --- fast_trajectory (fast_integrate) ---
8545        let fast_final_speed = |scale: f64| {
8546            let inputs = deck_inputs(scale);
8547            let wind_sock = crate::wind::WindSock::new(vec![]);
8548            let params = crate::fast_trajectory::FastIntegrationParams {
8549                horiz: 500.0,
8550                vert: 0.0,
8551                initial_state: [0.0, 0.0, 0.0, 850.0, 0.0, 0.0],
8552                t_span: (0.0, 5.0),
8553                atmo_params: (0.0, 15.0, 1013.25, 1.0),
8554                atmo_sock: None,
8555            };
8556            let solution = crate::fast_trajectory::fast_integrate(&inputs, &wind_sock, params);
8557            assert!(
8558                solution.success,
8559                "fast_integrate must succeed for scale={scale}"
8560            );
8561            let last = solution.t.len() - 1;
8562            let (vx, vy, vz) = (
8563                solution.y[3][last],
8564                solution.y[4][last],
8565                solution.y[5][last],
8566            );
8567            (vx * vx + vy * vy + vz * vz).sqrt()
8568        };
8569        let fast_baseline = fast_final_speed(1.0);
8570        let fast_scaled = fast_final_speed(1.10);
8571        assert!(
8572            fast_scaled < fast_baseline,
8573            "fast_trajectory: cd_scale=1.10 must lower final speed: base={fast_baseline} scaled={fast_scaled}"
8574        );
8575    }
8576}
8577
8578/// `custom_drag_reference`: a custom table flown as a reference curve, divided by the resolved
8579/// BC exactly as a built-in model's curve is, instead of the projectile's own Cd over sectional
8580/// density.
8581#[cfg(test)]
8582mod custom_drag_reference_tests {
8583    use super::*;
8584
8585    /// The engine's own G7 table, as a caller would send it.
8586    fn g7_table() -> crate::drag::DragTable {
8587        crate::drag::DragTable::from_csv_str(include_str!("../data/g7.csv"))
8588            .expect("bundled table parses")
8589    }
8590
8591    /// The engine's own G1 table, a curve far from `bc_type: G7`'s.
8592    fn g1_table() -> crate::drag::DragTable {
8593        crate::drag::DragTable::from_csv_str(include_str!("../data/g1.csv"))
8594            .expect("bundled table parses")
8595    }
8596
8597    /// A .308 175 gr G7 0.243 at 823 m/s with a two-band ladder (edge at 2400 fps) whose BCs both
8598    /// differ from the scalar. The imperial mirrors are set too, so the sectional density read
8599    /// off these inputs is the one `TrajectorySolver::new` re-derives from the SI fields.
8600    fn laddered_inputs() -> BallisticInputs {
8601        BallisticInputs {
8602            bc_value: 0.243,
8603            bc_type: DragModel::G7,
8604            bullet_mass: 175.0 * crate::constants::GRAINS_TO_KG,
8605            bullet_diameter: 0.308 * 0.0254,
8606            weight_grains: 175.0,
8607            caliber_inches: 0.308,
8608            muzzle_velocity: 823.0,
8609            use_bc_segments: true,
8610            bc_segments_data: Some(vec![
8611                crate::BCSegmentData {
8612                    velocity_min: 2400.0,
8613                    velocity_max: 4000.0,
8614                    bc_value: 0.260,
8615                },
8616                crate::BCSegmentData {
8617                    velocity_min: 0.0,
8618                    velocity_max: 2400.0,
8619                    bc_value: 0.220,
8620                },
8621            ]),
8622            ..BallisticInputs::default()
8623        }
8624    }
8625
8626    fn reference(inputs: BallisticInputs) -> BallisticInputs {
8627        BallisticInputs {
8628            custom_drag_table: Some(g7_table()),
8629            custom_drag_reference: true,
8630            ..inputs
8631        }
8632    }
8633
8634    fn solve(inputs: BallisticInputs) -> TrajectoryResult {
8635        let mut solver = TrajectorySolver::new(
8636            inputs,
8637            WindConditions::default(),
8638            AtmosphericConditions::default(),
8639        );
8640        solver.set_max_range(800.0);
8641        solver.solve().expect("solve")
8642    }
8643
8644    #[test]
8645    fn default_is_the_projectiles_own_curve() {
8646        assert!(!BallisticInputs::default().custom_drag_reference);
8647    }
8648
8649    #[test]
8650    fn the_denominator_is_the_resolved_bc_for_a_reference_curve() {
8651        let own = BallisticInputs {
8652            custom_drag_table: Some(g7_table()),
8653            ..laddered_inputs()
8654        };
8655        let sd = own.sectional_density_lb_in2().expect("SD");
8656        assert_eq!(own.custom_drag_denominator(0.220), sd);
8657        let reference = reference(laddered_inputs());
8658        assert_eq!(reference.custom_drag_denominator(0.220), 0.220);
8659        assert_eq!(reference.custom_drag_denominator(0.260), 0.260);
8660    }
8661
8662    /// The same ladder on the built-in G7 model and on the engine's G7 table sent as a reference
8663    /// curve is the same arithmetic, so the two fly bit-identically. Without the flag the table
8664    /// is read as the projectile's own Cd over sectional density, a different projectile.
8665    #[test]
8666    fn a_reference_curve_flies_a_ladder_exactly_as_the_builtin_model() {
8667        let builtin = solve(laddered_inputs());
8668        let table = solve(reference(laddered_inputs()));
8669        assert_eq!(table.points.len(), builtin.points.len());
8670        for (t, b) in table.points.iter().zip(&builtin.points) {
8671            assert_eq!(t.position, b.position);
8672            assert_eq!(
8673                t.velocity_magnitude.to_bits(),
8674                b.velocity_magnitude.to_bits()
8675            );
8676        }
8677
8678        let own_curve = solve(BallisticInputs {
8679            custom_drag_reference: false,
8680            ..reference(laddered_inputs())
8681        });
8682        assert!(
8683            (own_curve.impact_velocity - builtin.impact_velocity).abs() > 10.0,
8684            "the fixture must tell the readings apart: {} vs {}",
8685            own_curve.impact_velocity,
8686            builtin.impact_velocity
8687        );
8688    }
8689
8690    /// The table is what flies, not `bc_type`'s curve: the G7 table above is the same numbers as
8691    /// `bc_type: G7`, so it cannot tell them apart. The G1 table flown as a reference curve while
8692    /// `bc_type` stays G7 must equal the built-in G1 model with the same ladder, bit for bit; a
8693    /// drag site that read `bc_type` beside the flag would give the G7 answer instead.
8694    #[test]
8695    fn a_reference_curve_flies_the_table_not_bc_types_curve() {
8696        let table = solve(BallisticInputs {
8697            custom_drag_table: Some(g1_table()),
8698            custom_drag_reference: true,
8699            bc_type: DragModel::G7,
8700            ..laddered_inputs()
8701        });
8702        let builtin_g1 = solve(BallisticInputs {
8703            bc_type: DragModel::G1,
8704            ..laddered_inputs()
8705        });
8706        assert_eq!(table.points.len(), builtin_g1.points.len());
8707        for (t, b) in table.points.iter().zip(&builtin_g1.points) {
8708            assert_eq!(t.position, b.position);
8709            assert_eq!(
8710                t.velocity_magnitude.to_bits(),
8711                b.velocity_magnitude.to_bits()
8712            );
8713        }
8714
8715        let builtin_g7 = solve(laddered_inputs());
8716        assert!(
8717            (builtin_g1.impact_velocity - builtin_g7.impact_velocity).abs() > 50.0,
8718            "the fixture must tell the curves apart: G1 {} vs G7 {}",
8719            builtin_g1.impact_velocity,
8720            builtin_g7.impact_velocity
8721        );
8722    }
8723
8724    /// The BC divides the curve again, so it is held to the built-in model's rule, while the
8725    /// own-curve reading still ignores it.
8726    #[test]
8727    fn validate_for_solve_requires_a_bc_for_a_reference_curve() {
8728        for bad in [0.0, -0.1, f64::NAN, f64::INFINITY] {
8729            let inputs = BallisticInputs {
8730                bc_value: bad,
8731                ..reference(laddered_inputs())
8732            };
8733            let error = TrajectorySolver::new(
8734                inputs.clone(),
8735                WindConditions::default(),
8736                AtmosphericConditions::default(),
8737            )
8738            .solve()
8739            .expect_err("a reference curve needs a finite positive BC");
8740            assert!(error.to_string().contains("bc_value"), "{error}");
8741
8742            let own_curve = BallisticInputs {
8743                custom_drag_reference: false,
8744                use_bc_segments: false,
8745                bc_segments_data: None,
8746                ..inputs
8747            };
8748            assert!(
8749                TrajectorySolver::new(
8750                    own_curve,
8751                    WindConditions::default(),
8752                    AtmosphericConditions::default(),
8753                )
8754                .solve()
8755                .is_ok(),
8756                "bc_value={bad} is still ignored by the own-curve reading"
8757            );
8758        }
8759    }
8760
8761    /// An Army Standard Metro BC divides a reference curve, so its conversion is real: the
8762    /// scalar and every band convert exactly as they would on the built-in model, and no inert
8763    /// warning is raised.
8764    #[test]
8765    fn army_standard_metro_converts_a_reference_curves_bcs() {
8766        let asm = BallisticInputs {
8767            bc_reference_standard: BcReferenceStandard::ArmyStandardMetro,
8768            ..reference(laddered_inputs())
8769        };
8770        assert!(asm.bc_reference_standard_inert_warning().is_none());
8771        assert!(BallisticInputs {
8772            custom_drag_reference: false,
8773            ..asm.clone()
8774        }
8775        .bc_reference_standard_inert_warning()
8776        .is_some());
8777
8778        let converted = solve(asm);
8779        let builtin_asm = solve(BallisticInputs {
8780            bc_reference_standard: BcReferenceStandard::ArmyStandardMetro,
8781            ..laddered_inputs()
8782        });
8783        let icao = solve(reference(laddered_inputs()));
8784        assert_eq!(
8785            converted.impact_velocity.to_bits(),
8786            builtin_asm.impact_velocity.to_bits()
8787        );
8788        assert!(
8789            converted.impact_velocity < icao.impact_velocity,
8790            "the ASM BCs are smaller once converted, so the bullet must slow more: {} vs {}",
8791            converted.impact_velocity,
8792            icao.impact_velocity
8793        );
8794    }
8795
8796    /// The reported Cd is the projectile's own: the reference curve times SD over the band BC
8797    /// that flies, exactly as for a built-in model.
8798    #[test]
8799    fn the_reported_cd_is_the_projectiles_own() {
8800        let inputs = reference(laddered_inputs());
8801        let sd = inputs.sectional_density_lb_in2().expect("SD");
8802        let solver = TrajectorySolver::new(
8803            inputs,
8804            WindConditions::default(),
8805            AtmosphericConditions::default(),
8806        );
8807        let sos = 340.0;
8808        for (fps, band_bc) in [(2600.0, 0.260), (2200.0, 0.220)] {
8809            let velocity = fps / 3.28084;
8810            let expected = g7_table().interpolate(velocity / sos) * sd / band_bc;
8811            let reported = solver
8812                .effective_drag_coefficient(velocity, sos)
8813                .expect("mass and diameter are set");
8814            assert!(
8815                (reported - expected).abs() < 1e-12,
8816                "{fps} fps: reported {reported}, expected {expected}"
8817            );
8818        }
8819    }
8820}
8821
8822#[cfg(test)]
8823mod humid_local_mach_tests {
8824    use super::*;
8825
8826    fn solver_with_station_humidity(humidity_percent: f64) -> TrajectorySolver {
8827        let inputs = BallisticInputs {
8828            custom_drag_table: Some(crate::drag::DragTable::new(vec![0.5, 1.5], vec![0.1, 1.1])),
8829            ..BallisticInputs::default()
8830        };
8831        TrajectorySolver::new(
8832            inputs,
8833            WindConditions::default(),
8834            AtmosphericConditions {
8835                temperature: 30.0,
8836                pressure: 1013.25,
8837                humidity: humidity_percent,
8838                altitude: 0.0,
8839            },
8840        )
8841    }
8842
8843    fn acceleration(solver: &TrajectorySolver, base_ratio: f64) -> Vector3<f64> {
8844        solver.calculate_acceleration(
8845            &Vector3::zeros(),
8846            &Vector3::new(350.0, 0.0, 0.0),
8847            &Vector3::zeros(),
8848            (30.0, 1013.25, base_ratio),
8849        )
8850    }
8851
8852    #[test]
8853    fn local_mach_uses_station_humidity_when_density_is_held_constant() {
8854        let dry = acceleration(&solver_with_station_humidity(0.0), 1.0);
8855        let humid = acceleration(&solver_with_station_humidity(100.0), 1.0);
8856
8857        assert!(
8858            humid.x > dry.x,
8859            "humid sound speed should lower Mach and drag on the rising test curve: dry ax={} humid ax={}",
8860            dry.x,
8861            humid.x
8862        );
8863    }
8864
8865    #[test]
8866    fn active_atmosphere_zone_uses_zone_humidity_instead_of_station_humidity() {
8867        let zone_humidity = 80.0;
8868        let zone_ratio =
8869            crate::atmosphere::calculate_air_density_cimp(30.0, 1013.25, zone_humidity) / 1.225;
8870        let station_solver = solver_with_station_humidity(zone_humidity);
8871        let mut zoned_solver = solver_with_station_humidity(0.0);
8872        zoned_solver.set_atmo_segments(vec![(30.0, 1013.25, zone_humidity, 1_000.0)]);
8873
8874        let station = acceleration(&station_solver, zone_ratio);
8875        let zoned = acceleration(&zoned_solver, zone_ratio);
8876
8877        assert!(
8878            (zoned - station).norm() < 1e-12,
8879            "active zone T/P/RH should override the station atmosphere: station={station:?} zoned={zoned:?}"
8880        );
8881    }
8882}
8883
8884#[cfg(test)]
8885mod inclined_atmosphere_frame_tests {
8886    use super::*;
8887
8888    fn expected_shot_frame_vector(level: Vector3<f64>, angle: f64) -> Vector3<f64> {
8889        let (sin_angle, cos_angle) = angle.sin_cos();
8890        Vector3::new(
8891            level.x * cos_angle + level.y * sin_angle,
8892            -level.x * sin_angle + level.y * cos_angle,
8893            level.z,
8894        )
8895    }
8896
8897    #[test]
8898    fn inclined_positions_at_same_world_altitude_have_same_solver_acceleration() {
8899        let angle = std::f64::consts::FRAC_PI_6;
8900        let inputs = BallisticInputs {
8901            shooting_angle: angle,
8902            ..BallisticInputs::default()
8903        };
8904        let atmosphere = AtmosphericConditions {
8905            altitude: 100.0,
8906            ..AtmosphericConditions::default()
8907        };
8908        let solver = TrajectorySolver::new(inputs, WindConditions::default(), atmosphere);
8909        let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
8910        let resolved_atmo = (temp_c, pressure_hpa, density / 1.225);
8911        let velocity = Vector3::new(600.0, 0.0, 0.0);
8912        let along_slant = Vector3::new(1_000.0, 0.0, 0.0);
8913        let across_slant = Vector3::new(0.0, 500.0 / angle.cos(), 0.0);
8914
8915        let a = solver.calculate_acceleration(
8916            &along_slant,
8917            &velocity,
8918            &Vector3::zeros(),
8919            resolved_atmo,
8920        );
8921        let b = solver.calculate_acceleration(
8922            &across_slant,
8923            &velocity,
8924            &Vector3::zeros(),
8925            resolved_atmo,
8926        );
8927
8928        assert!(
8929            (a - b).norm() < 1e-10,
8930            "solver acceleration differs at equal world altitude: {a:?} vs {b:?}"
8931        );
8932    }
8933
8934    #[test]
8935    fn inclined_headwind_is_rotated_into_solver_frame() {
8936        let angle = std::f64::consts::FRAC_PI_6;
8937        let inputs = BallisticInputs {
8938            shooting_angle: angle,
8939            ..BallisticInputs::default()
8940        };
8941        let solver = TrajectorySolver::new(
8942            inputs,
8943            WindConditions::default(),
8944            AtmosphericConditions::default(),
8945        );
8946        let level_headwind = Vector3::new(-100.0, 0.0, 0.0);
8947        let velocity = expected_shot_frame_vector(level_headwind, angle);
8948        let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
8949        let actual = solver.calculate_acceleration(
8950            &Vector3::zeros(),
8951            &velocity,
8952            &level_headwind,
8953            (temp_c, pressure_hpa, density / 1.225),
8954        );
8955
8956        assert!(
8957            (actual - solver.gravity_acceleration()).norm() < 1e-12,
8958            "co-moving horizontal wind must leave only shot-frame gravity: {actual:?}"
8959        );
8960    }
8961
8962    #[test]
8963    fn inclined_coriolis_is_rotated_into_solver_frame() {
8964        let angle = std::f64::consts::FRAC_PI_6;
8965        let latitude_deg = 45.0_f64;
8966        let shot_azimuth = 0.4_f64;
8967        let velocity = Vector3::new(600.0, 20.0, 5.0);
8968        let base_inputs = BallisticInputs {
8969            shooting_angle: angle,
8970            latitude: Some(latitude_deg),
8971            shot_azimuth,
8972            ..BallisticInputs::default()
8973        };
8974        let acceleration = |enable_coriolis| {
8975            let mut inputs = base_inputs.clone();
8976            inputs.enable_coriolis = enable_coriolis;
8977            let solver = TrajectorySolver::new(
8978                inputs,
8979                WindConditions::default(),
8980                AtmosphericConditions::default(),
8981            );
8982            let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
8983            solver.calculate_acceleration(
8984                &Vector3::zeros(),
8985                &velocity,
8986                &Vector3::zeros(),
8987                (temp_c, pressure_hpa, density / 1.225),
8988            )
8989        };
8990
8991        let omega_earth = 7.2921159e-5_f64;
8992        let latitude = latitude_deg.to_radians();
8993        let level_omega = Vector3::new(
8994            omega_earth * latitude.cos() * shot_azimuth.cos(),
8995            omega_earth * latitude.sin(),
8996            -omega_earth * latitude.cos() * shot_azimuth.sin(),
8997        );
8998        let expected = -2.0 * expected_shot_frame_vector(level_omega, angle).cross(&velocity);
8999        let actual = acceleration(true) - acceleration(false);
9000
9001        assert!(
9002            (actual - expected).norm() < 1e-12,
9003            "inclined Coriolis mismatch: actual={actual:?}, expected={expected:?}"
9004        );
9005    }
9006}
9007
9008#[cfg(test)]
9009mod terminal_range_interpolation_tests {
9010    use super::*;
9011
9012    #[test]
9013    fn terminal_finalizer_selects_the_earliest_crossed_boundary() {
9014        let inputs = BallisticInputs {
9015            ground_threshold: 0.0,
9016            ..BallisticInputs::default()
9017        };
9018        let mut solver = TrajectorySolver::new(
9019            inputs,
9020            WindConditions::default(),
9021            AtmosphericConditions::default(),
9022        );
9023        solver.set_max_range(120.0);
9024
9025        let previous_speed = 700.0;
9026        let mut points = vec![TrajectoryPoint {
9027            time: 99.0,
9028            position: Vector3::new(90.0, 1.0, -1.0),
9029            velocity_magnitude: previous_speed,
9030            kinetic_energy: 0.5 * solver.inputs.bullet_mass * previous_speed.powi(2),
9031            drag_coefficient: None,
9032        }];
9033        let mut max_height = 1.0;
9034        let termination = solver
9035            .append_terminal_endpoint(
9036                &mut points,
9037                Vector3::new(130.0, -3.0, 3.0),
9038                Vector3::new(600.0, 0.0, 0.0),
9039                101.0,
9040                &mut max_height,
9041                &mut 0,
9042            )
9043            .expect("the final step brackets supported boundaries");
9044
9045        assert_eq!(termination, TrajectoryTermination::GroundThreshold);
9046        assert_eq!(points.len(), 2);
9047        let terminal = points.last().expect("terminal point");
9048        assert_eq!(terminal.time, 99.5);
9049        assert_eq!(terminal.position, Vector3::new(100.0, 0.0, 0.0));
9050        assert_eq!(terminal.velocity_magnitude, 675.0);
9051        assert_eq!(
9052            terminal.kinetic_energy,
9053            0.5 * solver.inputs.bullet_mass * 675.0_f64.powi(2)
9054        );
9055
9056        // At x=100 the range and ground crossings tie; physical ground impact wins explicitly.
9057        solver.set_max_range(100.0);
9058        let mut tied_points = vec![points[0].clone()];
9059        assert_eq!(
9060            solver
9061                .append_terminal_endpoint(
9062                    &mut tied_points,
9063                    Vector3::new(130.0, -3.0, 3.0),
9064                    Vector3::new(600.0, 0.0, 0.0),
9065                    101.0,
9066                    &mut max_height,
9067                    &mut 0,
9068                )
9069                .expect("tied boundaries remain a valid terminal"),
9070            TrajectoryTermination::GroundThreshold
9071        );
9072    }
9073
9074    #[test]
9075    fn sub_ulp_terminal_crossing_replaces_instead_of_duplicating_range() {
9076        let ground_threshold = f64::from_bits(1.0_f64.to_bits() - 1);
9077        let inputs = BallisticInputs {
9078            ground_threshold,
9079            ..BallisticInputs::default()
9080        };
9081        let mut solver = TrajectorySolver::new(
9082            inputs,
9083            WindConditions::default(),
9084            AtmosphericConditions::default(),
9085        );
9086        solver.set_max_range(1_000.0);
9087
9088        let speed = 700.0;
9089        let mut points = vec![TrajectoryPoint {
9090            time: 0.0,
9091            position: Vector3::new(100.0, 1.0, 0.0),
9092            velocity_magnitude: speed,
9093            kinetic_energy: 0.5 * solver.inputs.bullet_mass * speed.powi(2),
9094            drag_coefficient: None,
9095        }];
9096        let mut max_height = 1.0;
9097        let termination = solver
9098            .append_terminal_endpoint(
9099                &mut points,
9100                Vector3::new(101.0, 0.0, 0.0),
9101                Vector3::new(699.0, 0.0, 0.0),
9102                1.0,
9103                &mut max_height,
9104                &mut 0,
9105            )
9106            .expect("sub-ULP ground crossing remains representable as one terminal state");
9107
9108        assert_eq!(termination, TrajectoryTermination::GroundThreshold);
9109        assert_eq!(points.len(), 1);
9110        assert_eq!(points[0].position.x, 100.0);
9111        assert_eq!(points[0].position.y.to_bits(), ground_threshold.to_bits());
9112        assert!(points[0].time > 0.0);
9113    }
9114
9115    /// MBA-1599: at the bottom of the BC range the projectile sheds its downrange speed within a
9116    /// few metres and then falls almost straight down. Once a whole step moves x by less than half
9117    /// a ULP, `x + vx*dt == x` while y keeps falling. Every solver must keep one stored point per
9118    /// downrange distance and still end at the real boundary, so the checked observation API
9119    /// accepts the flight. Euler flies 2e-4 because explicit Euler at 1 ms is unstable at 1e-4.
9120    #[test]
9121    fn downrange_stall_replaces_instead_of_duplicating_range() {
9122        for (name, use_rk4, use_adaptive_rk45, bc_value) in [
9123            ("Euler", false, false, 2.0e-4),
9124            ("RK4", true, false, 1.0e-4),
9125            ("RK45", true, true, 1.0e-4),
9126        ] {
9127            let inputs = BallisticInputs {
9128                bc_value,
9129                bc_type: DragModel::G7,
9130                bullet_mass: 0.01134,
9131                bullet_diameter: 0.00782,
9132                muzzle_velocity: 823.0,
9133                use_rk4,
9134                use_adaptive_rk45,
9135                ground_threshold: -100.0,
9136                ..BallisticInputs::default()
9137            };
9138            let mut solver = TrajectorySolver::new(
9139                inputs,
9140                WindConditions::default(),
9141                AtmosphericConditions::default(),
9142            );
9143            solver.set_max_range(300.0);
9144            solver.set_time_step(0.001);
9145
9146            let result = solver
9147                .solve()
9148                .unwrap_or_else(|error| panic!("{name}: a stalled flight still solves: {error}"));
9149            assert!(
9150                result
9151                    .points
9152                    .windows(2)
9153                    .all(|pair| pair[1].position.x > pair[0].position.x),
9154                "{name}: stored downrange distances must strictly increase"
9155            );
9156            assert_eq!(
9157                result.termination,
9158                TrajectoryTermination::GroundThreshold,
9159                "{name}"
9160            );
9161            assert!(
9162                result.max_range > 0.0 && result.max_range < 300.0,
9163                "{name}: the stall ends short of max_range, got {} m",
9164                result.max_range
9165            );
9166            let terminal = result.points.last().expect("terminal point");
9167            assert_eq!(terminal.position.y, -100.0, "{name}");
9168            result
9169                .sample_observations(50.0, 100)
9170                .unwrap_or_else(|error| panic!("{name}: checked sampling refused: {error}"));
9171        }
9172    }
9173
9174    #[test]
9175    fn every_solver_appends_an_exact_max_range_endpoint() {
9176        let target_range = 0.1;
9177        let modes = [
9178            ("Euler", false, false),
9179            ("RK4", true, false),
9180            ("RK45", true, true),
9181        ];
9182
9183        for (name, use_rk4, use_adaptive_rk45) in modes {
9184            let inputs = BallisticInputs {
9185                use_rk4,
9186                use_adaptive_rk45,
9187                ground_threshold: f64::NEG_INFINITY,
9188                enable_trajectory_sampling: true,
9189                sample_interval: target_range,
9190                ..BallisticInputs::default()
9191            };
9192            let mut solver = TrajectorySolver::new(
9193                inputs,
9194                WindConditions::default(),
9195                AtmosphericConditions::default(),
9196            );
9197            solver.set_max_range(target_range);
9198
9199            let result = solver.solve().expect("short-range solve should succeed");
9200            let terminal = result.points.last().expect("terminal point is missing");
9201            let muzzle = result.points.first().expect("muzzle point is missing");
9202
9203            assert_eq!(result.termination, TrajectoryTermination::MaxRange);
9204            assert_eq!(
9205                terminal.position.x.to_bits(),
9206                target_range.to_bits(),
9207                "{name} did not terminate exactly at max_range"
9208            );
9209            assert_eq!(result.max_range.to_bits(), target_range.to_bits());
9210            assert!(
9211                result.time_of_flight > 0.0 && result.time_of_flight < solver.time_step,
9212                "{name} terminal time was not interpolated within the crossing step: {}",
9213                result.time_of_flight
9214            );
9215            assert_eq!(result.time_of_flight.to_bits(), terminal.time.to_bits());
9216            assert_eq!(
9217                result.impact_velocity.to_bits(),
9218                terminal.velocity_magnitude.to_bits()
9219            );
9220            assert_eq!(
9221                result.impact_energy.to_bits(),
9222                terminal.kinetic_energy.to_bits()
9223            );
9224            let expected_energy = 0.5 * solver.inputs.bullet_mass * result.impact_velocity.powi(2);
9225            assert!((result.impact_energy - expected_energy).abs() < 1e-12);
9226            assert!(terminal.velocity_magnitude < muzzle.velocity_magnitude);
9227            assert!(terminal.kinetic_energy < muzzle.kinetic_energy);
9228
9229            let terminal_sample = result
9230                .sampled_points
9231                .as_ref()
9232                .and_then(|samples| samples.last())
9233                .expect("terminal trajectory sample is missing");
9234            assert_eq!(
9235                terminal_sample.distance_m.to_bits(),
9236                target_range.to_bits(),
9237                "{name} sampling did not include max_range"
9238            );
9239            assert_eq!(
9240                terminal_sample.time_s.to_bits(),
9241                result.time_of_flight.to_bits()
9242            );
9243            assert_eq!(
9244                terminal_sample.velocity_mps.to_bits(),
9245                result.impact_velocity.to_bits()
9246            );
9247            assert!((terminal_sample.energy_j - result.impact_energy).abs() < 1e-12);
9248        }
9249    }
9250}
9251
9252#[cfg(test)]
9253mod precession_inertia_wiring_tests {
9254    use super::*;
9255
9256    #[test]
9257    fn solver_uses_projectile_specific_moments_of_inertia() {
9258        let mass_kg = 55.0 * crate::constants::GRAINS_TO_KG;
9259        let caliber_m = 0.224 * 0.0254;
9260        let length_m = 0.75 * 0.0254;
9261        let inputs = BallisticInputs {
9262            bullet_mass: mass_kg,
9263            bullet_diameter: caliber_m,
9264            bullet_length: length_m,
9265            muzzle_velocity: 800.0,
9266            twist_rate: 7.0,
9267            enable_precession_nutation: true,
9268            use_rk4: false,
9269            use_adaptive_rk45: false,
9270            ..BallisticInputs::default()
9271        };
9272        let mut solver = TrajectorySolver::new(
9273            inputs,
9274            WindConditions::default(),
9275            AtmosphericConditions::default(),
9276        );
9277        solver.set_max_range(0.1);
9278
9279        let (air_density, speed_of_sound, _, _) = solver.resolved_atmosphere();
9280        let velocity_mps = solver.inputs.muzzle_velocity;
9281        let velocity_fps = velocity_mps * 3.28084;
9282        let twist_rate_ft = solver.inputs.twist_rate / 12.0;
9283        let spin_rate_rad_s = (velocity_fps / twist_rate_ft) * 2.0 * std::f64::consts::PI;
9284        let initial_state = AngularState {
9285            pitch_angle: 0.001,
9286            yaw_angle: 0.001,
9287            pitch_rate: 0.0,
9288            yaw_rate: 0.0,
9289            precession_angle: 0.0,
9290            nutation_phase: 0.0,
9291        };
9292        let params = PrecessionNutationParams {
9293            mass_kg,
9294            caliber_m,
9295            length_m,
9296            spin_rate_rad_s,
9297            spin_inertia: crate::spin_decay::calculate_moment_of_inertia(
9298                mass_kg, caliber_m, length_m, "ogive",
9299            ),
9300            transverse_inertia: crate::pitch_damping::calculate_transverse_moment_of_inertia(
9301                mass_kg, caliber_m, length_m, "ogive",
9302            ),
9303            velocity_mps,
9304            air_density_kg_m3: air_density,
9305            mach: velocity_mps / speed_of_sound,
9306            pitch_damping_coeff: PitchDampingCoefficients::default().subsonic,
9307            nutation_damping_factor: 0.05,
9308        };
9309        let expected = calculate_combined_angular_motion(
9310            &params,
9311            &initial_state,
9312            0.0,
9313            solver.time_step,
9314            0.001,
9315        );
9316        let actual = solver
9317            .solve()
9318            .expect("one-step solve should succeed")
9319            .angular_state
9320            .expect("precession/nutation was enabled");
9321
9322        assert!(
9323            (actual.precession_angle - expected.precession_angle).abs() < 1e-15,
9324            "precession phase used the wrong inertia: actual={}, expected={}",
9325            actual.precession_angle,
9326            expected.precession_angle
9327        );
9328        assert!(
9329            (actual.nutation_phase - expected.nutation_phase).abs() < 1e-15,
9330            "nutation phase used the wrong inertia: actual={}, expected={}",
9331            actual.nutation_phase,
9332            expected.nutation_phase
9333        );
9334    }
9335}
9336
9337#[cfg(test)]
9338mod form_factor_drag_tests {
9339    use super::*;
9340
9341    fn acceleration_with_form_factor_flag(enabled: bool) -> Vector3<f64> {
9342        let inputs = BallisticInputs {
9343            bc_value: 0.462,
9344            bc_type: DragModel::G1,
9345            bullet_model: Some("168gr SMK Match".to_string()),
9346            use_form_factor: enabled,
9347            ..BallisticInputs::default()
9348        };
9349        let solver = TrajectorySolver::new(
9350            inputs,
9351            WindConditions::default(),
9352            AtmosphericConditions::default(),
9353        );
9354        let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
9355        solver.calculate_acceleration(
9356            &Vector3::zeros(),
9357            &Vector3::new(600.0, 0.0, 0.0),
9358            &Vector3::zeros(),
9359            (temp_c, pressure_hpa, density / 1.225),
9360        )
9361    }
9362
9363    #[test]
9364    fn measured_bc_drag_does_not_apply_name_based_form_factor_again() {
9365        let baseline = acceleration_with_form_factor_flag(false);
9366        let flagged = acceleration_with_form_factor_flag(true);
9367
9368        assert!(
9369            (flagged - baseline).norm() < 1e-12,
9370            "published BC already encodes form factor: baseline={baseline:?} flagged={flagged:?}"
9371        );
9372    }
9373}
9374
9375#[cfg(test)]
9376mod rk45_adaptivity_tests {
9377    use super::*;
9378
9379    #[test]
9380    fn cli_rk45_error_norm_scales_components_independently() {
9381        let position = Vector3::new(1.0e9, 0.0, 0.0);
9382        let velocity = Vector3::new(800.0, 0.0, 0.0);
9383        let fifth_position = position;
9384        let fifth_velocity = velocity;
9385        let fourth_position = position;
9386        let fourth_velocity = Vector3::new(800.0, 1.0e-3, 0.0);
9387
9388        let error = cli_rk45_error_norm(
9389            &position,
9390            &velocity,
9391            &fifth_position,
9392            &fifth_velocity,
9393            &fourth_position,
9394            &fourth_velocity,
9395        );
9396        let expected = 1.0e-3 / 6.0_f64.sqrt();
9397
9398        assert!(
9399            (error - expected).abs() <= 1e-15,
9400            "large downrange position masked a velocity-component error: {error}"
9401        );
9402    }
9403
9404    fn discontinuous_wind_solver() -> TrajectorySolver {
9405        let inputs = BallisticInputs::default();
9406        let mut solver = TrajectorySolver::new(
9407            inputs,
9408            WindConditions::default(),
9409            AtmosphericConditions::default(),
9410        );
9411        solver.set_wind_segments(vec![
9412            crate::wind::WindSegment::new(0.0, 90.0, 4.0),
9413            crate::wind::WindSegment::new(1_000.0, 90.0, 10_000.0),
9414        ]);
9415        solver
9416    }
9417
9418    #[test]
9419    fn rk45_retries_discontinuous_trial_before_advancing() {
9420        let solver = discontinuous_wind_solver();
9421        let position = Vector3::new(0.0, solver.inputs.muzzle_height, 0.0);
9422        let velocity = Vector3::new(solver.inputs.muzzle_velocity, 0.0, 0.0);
9423        let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
9424        let resolved_atmo = (temp_c, pressure_hpa, density / 1.225);
9425        let dt = 0.01;
9426
9427        let rejected_trial = solver.rk45_step(
9428            &position,
9429            &velocity,
9430            dt,
9431            &Vector3::zeros(),
9432            RK45_TOLERANCE,
9433            resolved_atmo,
9434        );
9435        assert!(
9436            rejected_trial.error > RK45_TOLERANCE,
9437            "discontinuous full step must exceed tolerance, got {}",
9438            rejected_trial.error
9439        );
9440
9441        let accepted =
9442            solver.adaptive_rk45_step(&position, &velocity, dt, &Vector3::zeros(), resolved_atmo);
9443        assert!(accepted.used_dt < dt, "oversized trial was not retried");
9444        assert!(
9445            accepted.error <= RK45_TOLERANCE || accepted.used_dt <= RK45_MIN_DT,
9446            "accepted error {} exceeds tolerance at dt {}",
9447            accepted.error,
9448            accepted.used_dt
9449        );
9450
9451        let accepted_trial = solver.rk45_step(
9452            &position,
9453            &velocity,
9454            accepted.used_dt,
9455            &Vector3::zeros(),
9456            RK45_TOLERANCE,
9457            resolved_atmo,
9458        );
9459        assert_eq!(accepted.position, accepted_trial.position);
9460        assert_eq!(accepted.velocity, accepted_trial.velocity);
9461        assert!((RK45_MIN_DT..=RK45_MAX_DT).contains(&accepted.next_dt));
9462    }
9463}
9464
9465#[cfg(test)]
9466mod ground_termination_tests {
9467    use super::*;
9468    use crate::trajectory_observation::TrajectoryObservationFlag;
9469
9470    #[test]
9471    fn every_solver_reports_one_exact_early_ground_endpoint() {
9472        for (name, use_rk4, use_adaptive_rk45) in [
9473            ("Euler", false, false),
9474            ("RK4", true, false),
9475            ("RK45", true, true),
9476        ] {
9477            let inputs = BallisticInputs {
9478                muzzle_height: 1.0,
9479                muzzle_angle: -0.2,
9480                ground_threshold: 0.0,
9481                use_rk4,
9482                use_adaptive_rk45,
9483                ..BallisticInputs::default()
9484            };
9485            let mut solver = TrajectorySolver::new(
9486                inputs,
9487                WindConditions::default(),
9488                AtmosphericConditions::default(),
9489            );
9490            solver.set_max_range(1_000.0);
9491
9492            let result = solver.solve().expect("early-ground solve should succeed");
9493            let terminal = result.points.last().expect("terminal point is missing");
9494
9495            assert_eq!(result.termination, TrajectoryTermination::GroundThreshold);
9496            assert_eq!(terminal.position.y.to_bits(), 0.0_f64.to_bits());
9497            assert!(
9498                terminal.position.x < 1_000.0,
9499                "{name} incorrectly reached max range"
9500            );
9501            assert_eq!(result.max_range.to_bits(), terminal.position.x.to_bits());
9502            assert_eq!(
9503                result
9504                    .points
9505                    .iter()
9506                    .filter(|point| point.position.y == 0.0)
9507                    .count(),
9508                1,
9509                "{name} did not retain exactly one ground endpoint"
9510            );
9511
9512            let observations = result
9513                .sample_observations(1.0, 100)
9514                .expect("checked early-ground sampling should succeed");
9515            assert!(observations[..observations.len() - 1]
9516                .iter()
9517                .all(|observation| observation.distance_m < terminal.position.x));
9518            let terminal_observation = observations.last().expect("terminal observation");
9519            assert_eq!(
9520                terminal_observation.distance_m.to_bits(),
9521                terminal.position.x.to_bits()
9522            );
9523            assert!(terminal_observation
9524                .flags
9525                .contains(&TrajectoryObservationFlag::Terminal));
9526            assert!(terminal_observation
9527                .flags
9528                .contains(&TrajectoryObservationFlag::GroundThreshold));
9529            assert_eq!(
9530                observations
9531                    .iter()
9532                    .filter(|observation| observation
9533                        .flags
9534                        .contains(&TrajectoryObservationFlag::Terminal))
9535                    .count(),
9536                1,
9537                "{name} repeated the terminal observation"
9538            );
9539        }
9540    }
9541
9542    // Regression lock for the unified ground termination: solve_euler/solve_rk4/solve_rk45 all
9543    // loop while `position.y > ground_threshold` (default -100.0), so they agree with RK45. A
9544    // lofted shot that returns to launch level before reaching max_range must keep descending to
9545    // the -100 m floor instead of stopping at y = 0 — and RK4-fixed and RK45 must behave the same.
9546    #[test]
9547    fn rk4_and_rk45_descend_to_ground_threshold() {
9548        for adaptive in [false, true] {
9549            let inputs = BallisticInputs {
9550                muzzle_angle: 0.1, // ~5.7 deg: arcs up, then descends past launch level
9551                use_rk4: true,
9552                use_adaptive_rk45: adaptive,
9553                ..BallisticInputs::default()
9554            };
9555            assert_eq!(
9556                inputs.ground_threshold, -100.0,
9557                "default ground_threshold is -100 m"
9558            );
9559
9560            let mut solver = TrajectorySolver::new(
9561                inputs,
9562                WindConditions::default(),
9563                AtmosphericConditions::default(),
9564            );
9565            // Huge max range: termination must be driven by ground_threshold, not the range cap.
9566            solver.set_max_range(1.0e7);
9567
9568            let result = solver.solve().expect("solve should succeed");
9569            let final_y = result
9570                .points
9571                .last()
9572                .expect("trajectory has points")
9573                .position
9574                .y;
9575            assert!(
9576                final_y < -1.0,
9577                "adaptive_rk45={adaptive}: final y = {final_y} m; a lofted shot should descend \
9578                 past launch level toward the ground_threshold floor, not stop at y = 0"
9579            );
9580        }
9581    }
9582}
9583
9584#[cfg(test)]
9585mod magnus_stability_tests {
9586    use super::*;
9587
9588    #[test]
9589    fn yaw_of_repose_magnus_force_is_vertical_and_twist_signed() {
9590        let acceleration = |enable_magnus, is_twist_right| {
9591            let inputs = BallisticInputs {
9592                muzzle_velocity: 822.96,
9593                bullet_mass: 168.0 * crate::constants::GRAINS_TO_KG,
9594                bullet_diameter: 0.308 * 0.0254,
9595                bullet_length: 1.215 * 0.0254,
9596                twist_rate: 10.0,
9597                is_twist_right,
9598                enable_magnus,
9599                ..BallisticInputs::default()
9600            };
9601            let solver = TrajectorySolver::new(
9602                inputs,
9603                WindConditions::default(),
9604                AtmosphericConditions::default(),
9605            );
9606            let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
9607            solver.calculate_acceleration(
9608                &Vector3::zeros(),
9609                &Vector3::new(822.96, 0.0, 0.0),
9610                &Vector3::zeros(),
9611                (temp_c, pressure_hpa, density / 1.225),
9612            )
9613        };
9614
9615        let baseline = acceleration(false, true);
9616        let right_twist = acceleration(true, true) - baseline;
9617        let left_twist = acceleration(true, false) - baseline;
9618
9619        assert!(
9620            right_twist.y < 0.0,
9621            "right-hand Magnus must point down, got {right_twist:?}"
9622        );
9623        assert!(
9624            left_twist.y > 0.0,
9625            "left-hand Magnus must point up, got {left_twist:?}"
9626        );
9627        assert!((right_twist.y + left_twist.y).abs() < 1e-12);
9628        assert!(right_twist.x.abs() < 1e-12 && right_twist.z.abs() < 1e-12);
9629        assert!(left_twist.x.abs() < 1e-12 && left_twist.z.abs() < 1e-12);
9630    }
9631
9632    #[test]
9633    fn magnus_uses_velocity_corrected_muzzle_stability_gate() {
9634        let muzzle_velocity = 1_400.0 / 3.28084;
9635        let inputs = BallisticInputs {
9636            muzzle_velocity,
9637            bullet_mass: 168.0 * crate::constants::GRAINS_TO_KG,
9638            bullet_diameter: 0.308 * 0.0254,
9639            bullet_length: 1.215 * 0.0254,
9640            twist_rate: 15.0,
9641            enable_magnus: true,
9642            ..BallisticInputs::default()
9643        };
9644        let solver = TrajectorySolver::new(
9645            inputs.clone(),
9646            WindConditions::default(),
9647            AtmosphericConditions::default(),
9648        );
9649
9650        let bare_sg = crate::spin_drift::miller_stability(0.308, 168.0, 15.0, 1.215);
9651        let canonical_sg = solver.effective_spin_drift_sg();
9652        assert!(bare_sg > 1.0, "test requires bare Sg above the Magnus gate");
9653        assert!(
9654            canonical_sg < 1.0,
9655            "velocity-corrected Sg must be below the gate, got {canonical_sg}"
9656        );
9657
9658        let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
9659        let acceleration = solver.calculate_acceleration(
9660            &Vector3::zeros(),
9661            &Vector3::new(muzzle_velocity, 0.0, 0.0),
9662            &Vector3::zeros(),
9663            (temp_c, pressure_hpa, density / 1.225),
9664        );
9665        let mut baseline_inputs = inputs;
9666        baseline_inputs.enable_magnus = false;
9667        let baseline_solver = TrajectorySolver::new(
9668            baseline_inputs,
9669            WindConditions::default(),
9670            AtmosphericConditions::default(),
9671        );
9672        let baseline = baseline_solver.calculate_acceleration(
9673            &Vector3::zeros(),
9674            &Vector3::new(muzzle_velocity, 0.0, 0.0),
9675            &Vector3::zeros(),
9676            (temp_c, pressure_hpa, density / 1.225),
9677        );
9678
9679        assert_eq!(
9680            acceleration, baseline,
9681            "canonical Sg below 1 must suppress every Magnus acceleration component"
9682        );
9683    }
9684
9685    #[test]
9686    fn magnus_force_grows_as_fixed_spin_projectile_slows() {
9687        let inputs = BallisticInputs {
9688            muzzle_velocity: 800.0,
9689            bullet_mass: 168.0 * crate::constants::GRAINS_TO_KG,
9690            bullet_diameter: 0.308 * 0.0254,
9691            bullet_length: 1.215 * 0.0254,
9692            twist_rate: 12.0,
9693            enable_magnus: true,
9694            ..BallisticInputs::default()
9695        };
9696
9697        let magnus_acceleration = |speed_mps| {
9698            let evaluate = |enable_magnus| {
9699                let mut run_inputs = inputs.clone();
9700                run_inputs.enable_magnus = enable_magnus;
9701                let solver = TrajectorySolver::new(
9702                    run_inputs,
9703                    WindConditions::default(),
9704                    AtmosphericConditions::default(),
9705                );
9706                let (density, _, temp_c, pressure_hpa) = solver.resolved_atmosphere();
9707                solver
9708                    .calculate_acceleration(
9709                        &Vector3::zeros(),
9710                        &Vector3::new(speed_mps, 0.0, 0.0),
9711                        &Vector3::zeros(),
9712                        (temp_c, pressure_hpa, density / 1.225),
9713                    )
9714                    .y
9715            };
9716            (evaluate(true) - evaluate(false)).abs()
9717        };
9718
9719        let fast = magnus_acceleration(200.0);
9720        let slow = magnus_acceleration(100.0);
9721        let ratio = slow / fast;
9722        let expected_ratio = 2.0_f64.powf(5.0 / 3.0);
9723
9724        assert!(fast > 0.0 && slow > 0.0, "fast={fast}, slow={slow}");
9725        assert!(
9726            (ratio - expected_ratio).abs() < 1e-3,
9727            "fixed-spin Magnus acceleration must grow downrange; slow/fast={ratio}, \
9728             expected={expected_ratio}"
9729        );
9730    }
9731}
9732
9733#[cfg(test)]
9734mod coriolis_direction_tests {
9735    use super::*;
9736    use std::f64::consts::FRAC_PI_2;
9737
9738    #[test]
9739    fn supersonic_crossing_flags_a_positive_range_sample() {
9740        // A supersonic shot that slows past Mach 1 must flag a sampled point as a Mach
9741        // transition. The underlying transonic_distances were a Vec::new() TODO, so this
9742        // flag was NEVER set regardless of trajectory — this is the regression guard.
9743        use crate::trajectory_sampling::TrajectoryFlag;
9744
9745        for (solver_name, use_rk4, use_adaptive_rk45) in [
9746            ("Euler", false, false),
9747            ("RK4", true, false),
9748            ("RK45", true, true),
9749        ] {
9750            let inputs = BallisticInputs {
9751                muzzle_velocity: 850.0,
9752                bc_value: 0.2,
9753                bc_type: DragModel::G7,
9754                muzzle_angle: 0.03,
9755                enable_trajectory_sampling: true,
9756                sample_interval: 50.0,
9757                use_rk4,
9758                use_adaptive_rk45,
9759                ..BallisticInputs::default()
9760            };
9761            let mut solver = TrajectorySolver::new(
9762                inputs,
9763                WindConditions::default(),
9764                AtmosphericConditions::default(),
9765            );
9766            solver.set_max_range(2000.0);
9767            let samples = solver
9768                .solve()
9769                .expect("supersonic solve should succeed")
9770                .sampled_points
9771                .expect("sampling was enabled");
9772            let flagged_distances: Vec<_> = samples
9773                .iter()
9774                .filter(|sample| sample.flags.contains(&TrajectoryFlag::MachTransition))
9775                .map(|sample| sample.distance_m)
9776                .collect();
9777
9778            assert!(
9779                !flagged_distances.is_empty()
9780                    && flagged_distances.iter().all(|distance| *distance > 0.0),
9781                "{solver_name} must flag genuine crossings only at positive range: {flagged_distances:?}"
9782            );
9783        }
9784    }
9785
9786    #[test]
9787    fn subsonic_launch_does_not_flag_a_muzzle_transition() {
9788        use crate::trajectory_sampling::TrajectoryFlag;
9789
9790        for (solver_name, use_rk4, use_adaptive_rk45) in [
9791            ("Euler", false, false),
9792            ("RK4", true, false),
9793            ("RK45", true, true),
9794        ] {
9795            let inputs = BallisticInputs {
9796                muzzle_velocity: 250.0,
9797                muzzle_angle: 0.02,
9798                enable_trajectory_sampling: true,
9799                sample_interval: 25.0,
9800                use_rk4,
9801                use_adaptive_rk45,
9802                ..BallisticInputs::default()
9803            };
9804            let mut solver = TrajectorySolver::new(
9805                inputs,
9806                WindConditions::default(),
9807                AtmosphericConditions::default(),
9808            );
9809            solver.set_max_range(300.0);
9810            let samples = solver
9811                .solve()
9812                .expect("subsonic solve should succeed")
9813                .sampled_points
9814                .expect("sampling was enabled");
9815
9816            assert!(
9817                samples
9818                    .iter()
9819                    .all(|sample| !sample.flags.contains(&TrajectoryFlag::MachTransition)),
9820                "{solver_name} marked a Mach transition for a launch already below Mach 1"
9821            );
9822        }
9823    }
9824
9825    #[test]
9826    fn mach_transition_tracker_requires_a_downward_crossing() {
9827        fn record(mach_values: &[f64]) -> Vec<f64> {
9828            let mut tracker = MachTransitionTracker::default();
9829            let mut distances = Vec::new();
9830            for (index, mach) in mach_values.iter().copied().enumerate() {
9831                tracker.record_downward_crossings(mach, index as f64 * 10.0, &mut distances);
9832            }
9833            distances
9834        }
9835
9836        assert!(record(&[0.9, 0.8, 0.7]).is_empty());
9837        assert_eq!(record(&[1.1, 1.05, 0.99]), vec![20.0]);
9838        assert_eq!(record(&[1.2, 1.19, 1.0, 0.99]), vec![10.0, 30.0]);
9839        assert_eq!(record(&[0.9, 1.3, 1.1, 0.9, 1.3, 0.8]), vec![20.0, 30.0]);
9840        assert!(record(&[1.3, f64::NAN, 1.1]).is_empty());
9841    }
9842
9843    #[test]
9844    fn mach_transition_tracker_labels_0_9_without_touching_the_flat_vec() {
9845        // MBA-1405: the tracker gains a third (0.9) threshold, but it must NEVER be appended
9846        // to the flat `distances` Vec — only its own labeled field. Reuses the exact input
9847        // sequences pinned by `mach_transition_tracker_requires_a_downward_crossing` above (the
9848        // pre-change flat-Vec behavior for those sequences) and additionally asserts the
9849        // labeled fields.
9850        fn record(mach_values: &[f64]) -> (Vec<f64>, MachTransitionTracker) {
9851            let mut tracker = MachTransitionTracker::default();
9852            let mut distances = Vec::new();
9853            for (index, mach) in mach_values.iter().copied().enumerate() {
9854                tracker.record_downward_crossings(mach, index as f64 * 10.0, &mut distances);
9855            }
9856            (distances, tracker)
9857        }
9858
9859        // Already at/below 1.2 and 1.0 at the muzzle (never crosses those downward from
9860        // above), but DOES cross 0.9 downward at x=10 (previous_mach 0.9 >= 0.9, mach 0.8 < 0.9).
9861        let (distances, tracker) = record(&[0.9, 0.8, 0.7]);
9862        assert!(distances.is_empty()); // flat Vec unchanged (pinned pre-change value)
9863        assert_eq!(tracker.mach_1_2_distance_m, None);
9864        assert_eq!(tracker.mach_1_0_distance_m, None);
9865        assert_eq!(tracker.mach_0_9_distance_m, Some(10.0));
9866
9867        // Crosses 1.0 only (never reaches 1.2 from above, never reaches 0.9).
9868        let (distances, tracker) = record(&[1.1, 1.05, 0.99]);
9869        assert_eq!(distances, vec![20.0]);
9870        assert_eq!(tracker.mach_1_2_distance_m, None);
9871        assert_eq!(tracker.mach_1_0_distance_m, Some(20.0));
9872        assert_eq!(tracker.mach_0_9_distance_m, None);
9873
9874        // Crosses 1.2 then 1.0, never reaches 0.9 (lowest sample is 0.99).
9875        let (distances, tracker) = record(&[1.2, 1.19, 1.0, 0.99]);
9876        assert_eq!(distances, vec![10.0, 30.0]); // flat Vec unchanged
9877        assert_eq!(tracker.mach_1_2_distance_m, Some(10.0));
9878        assert_eq!(tracker.mach_1_0_distance_m, Some(30.0));
9879        assert_eq!(tracker.mach_0_9_distance_m, None);
9880
9881        // Crosses 1.2, then 1.0, then 0.9 — the flat Vec must be EXACTLY {20.0, 30.0} as
9882        // before (0.9 crossing at x=50.0 must NOT appear in it).
9883        let (distances, tracker) = record(&[0.9, 1.3, 1.1, 0.9, 1.3, 0.8]);
9884        assert_eq!(distances, vec![20.0, 30.0]); // flat Vec unchanged (pinned pre-change value)
9885        assert_eq!(tracker.mach_1_2_distance_m, Some(20.0));
9886        assert_eq!(tracker.mach_1_0_distance_m, Some(30.0));
9887        assert_eq!(tracker.mach_0_9_distance_m, Some(50.0));
9888        assert!(
9889            tracker.mach_1_2_distance_m < tracker.mach_1_0_distance_m
9890                && tracker.mach_1_0_distance_m < tracker.mach_0_9_distance_m,
9891            "labeled crossings must be strictly increasing downrange"
9892        );
9893
9894        // NAN resets tracking exactly as before; no labels set either.
9895        let (distances, tracker) = record(&[1.3, f64::NAN, 1.1]);
9896        assert!(distances.is_empty());
9897        assert_eq!(tracker.mach_1_2_distance_m, None);
9898        assert_eq!(tracker.mach_1_0_distance_m, None);
9899        assert_eq!(tracker.mach_0_9_distance_m, None);
9900    }
9901
9902    #[test]
9903    fn humidity_percent_converts_and_clamps() {
9904        // MBA-722: BallisticInputs.humidity is a 0-1 fraction; the helper yields 0-100 percent.
9905        let mut i = BallisticInputs {
9906            humidity: 0.5,
9907            ..BallisticInputs::default()
9908        };
9909        assert!((i.humidity_percent() - 50.0).abs() < 1e-9, "0.5 -> 50%");
9910        i.humidity = 0.0;
9911        assert_eq!(i.humidity_percent(), 0.0);
9912        i.humidity = 1.0;
9913        assert_eq!(i.humidity_percent(), 100.0);
9914        i.humidity = 1.5; // out of range -> clamped, never > 100
9915        assert_eq!(i.humidity_percent(), 100.0);
9916    }
9917
9918    /// Vertical position (m) at a given downrange `range_m`, for a shot fired along
9919    /// compass bearing `shot_azimuth` (radians, 0=N) with Coriolis enabled.
9920    fn vertical_at(shot_azimuth: f64, range_m: f64) -> f64 {
9921        let inputs = BallisticInputs {
9922            muzzle_velocity: 800.0,
9923            bc_value: 0.5,
9924            bc_type: DragModel::G7,
9925            muzzle_angle: 0.02, // ~20 mrad so it carries well past range_m
9926            enable_coriolis: true,
9927            latitude: Some(45.0),
9928            shot_azimuth,
9929            ground_threshold: f64::NEG_INFINITY, // never terminate early
9930            ..BallisticInputs::default()
9931        };
9932        let mut solver = TrajectorySolver::new(
9933            inputs,
9934            WindConditions::default(),
9935            AtmosphericConditions::default(),
9936        );
9937        solver.set_max_range(range_m + 50.0);
9938        let r = solver.solve().expect("solve");
9939        let pts = &r.points;
9940        for i in 1..pts.len() {
9941            if pts[i].position.x >= range_m {
9942                let p1 = &pts[i - 1];
9943                let p2 = &pts[i];
9944                let t = (range_m - p1.position.x) / (p2.position.x - p1.position.x);
9945                return p1.position.y + t * (p2.position.y - p1.position.y);
9946            }
9947        }
9948        panic!("range {range_m} not reached");
9949    }
9950
9951    /// Regression for the shot-direction Coriolis bug: the Eötvös vertical term
9952    /// `a_up = +2Ω cosφ v_east` lifts an EAST shot and depresses a WEST shot, so at a
9953    /// common range east must sit HIGHER than west, with north in between. Before the
9954    /// fix, `--shot-direction` never reached the solver and E/W/N were identical.
9955    #[test]
9956    fn eotvos_east_higher_than_west() {
9957        let range = 600.0;
9958        let east = vertical_at(FRAC_PI_2, range); // 90° E
9959        let west = vertical_at(3.0 * FRAC_PI_2, range); // 270° W
9960        let north = vertical_at(0.0, range); // 0° N
9961        assert!(
9962            east > west,
9963            "east ({east:.5}) must be higher than west ({west:.5}) at {range} m (Eötvös)"
9964        );
9965        assert!(
9966            east > north && north > west,
9967            "north ({north:.5}) must lie between east ({east:.5}) and west ({west:.5})"
9968        );
9969        assert!(
9970            (east - west) > 1e-3,
9971            "E-W vertical separation ({:.6} m) should be physically meaningful, not FP noise",
9972            east - west
9973        );
9974    }
9975
9976    /// MBA-1405: labeled Mach-crossing distances on `TrajectoryResult`, pinned exactly against
9977    /// a capture of the (pre-change) flat `transonic_distances` Vec taken from this same
9978    /// muzzle_velocity=850/bc=0.2 G7/angle=0.03/max_range=2000 scenario, for all three solver
9979    /// paths. Because `MachTransitionTracker::record_downward_crossings` writes the labeled
9980    /// field and pushes to the flat Vec from the SAME `downrange_m` value at the SAME crossing,
9981    /// these pinned labels ARE the flat-Vec content for the 1.2/1.0 thresholds — proving the
9982    /// flat Vec is untouched by the 0.9 addition.
9983    #[test]
9984    fn labeled_mach_crossings_match_pinned_pre_change_flat_vec_across_solvers() {
9985        // (solver_name, use_rk4, use_adaptive_rk45, expected mach_1_2, expected mach_1_0)
9986        let cases = [
9987            (
9988                "Euler",
9989                false,
9990                false,
9991                670.9878683238721_f64,
9992                805.5274119916264_f64,
9993            ),
9994            (
9995                "RK4",
9996                true,
9997                false,
9998                671.7257336844475_f64,
9999                805.933409072171_f64,
10000            ),
10001            (
10002                "RK45",
10003                true,
10004                true,
10005                672.4905711917901_f64,
10006                806.5709746782849_f64,
10007            ),
10008        ];
10009
10010        for (solver_name, use_rk4, use_adaptive_rk45, expected_1_2, expected_1_0) in cases {
10011            let inputs = BallisticInputs {
10012                muzzle_velocity: 850.0,
10013                bc_value: 0.2,
10014                bc_type: DragModel::G7,
10015                muzzle_angle: 0.03,
10016                use_rk4,
10017                use_adaptive_rk45,
10018                ..BallisticInputs::default()
10019            };
10020            let mut solver = TrajectorySolver::new(
10021                inputs,
10022                WindConditions::default(),
10023                AtmosphericConditions::default(),
10024            );
10025            solver.set_max_range(2000.0);
10026            let result = solver.solve().expect("solve should succeed");
10027
10028            assert_eq!(
10029                result.mach_1_2_distance_m,
10030                Some(expected_1_2),
10031                "{solver_name}: mach_1_2_distance_m must match the pinned pre-change flat-Vec value"
10032            );
10033            assert_eq!(
10034                result.mach_1_0_distance_m,
10035                Some(expected_1_0),
10036                "{solver_name}: mach_1_0_distance_m must match the pinned pre-change flat-Vec value"
10037            );
10038
10039            let mach_1_2 = result.mach_1_2_distance_m.expect("crosses 1.2");
10040            let mach_1_0 = result.mach_1_0_distance_m.expect("crosses 1.0");
10041            let mach_0_9 = result
10042                .mach_0_9_distance_m
10043                .expect("this trajectory also goes past 0.9 within 2000 m");
10044            assert!(
10045                mach_1_2 < mach_1_0 && mach_1_0 < mach_0_9,
10046                "{solver_name}: labeled crossings must be strictly increasing downrange \
10047                 (1.2={mach_1_2}, 1.0={mach_1_0}, 0.9={mach_0_9})"
10048            );
10049        }
10050    }
10051
10052    /// A trajectory that terminates while still well above Mach 1.2 must leave all three
10053    /// labeled crossings `None` (there is nothing to cross), across all three solver paths.
10054    #[test]
10055    fn labeled_mach_crossings_are_none_for_a_fully_supersonic_trajectory() {
10056        for (solver_name, use_rk4, use_adaptive_rk45) in [
10057            ("Euler", false, false),
10058            ("RK4", true, false),
10059            ("RK45", true, true),
10060        ] {
10061            let inputs = BallisticInputs {
10062                muzzle_velocity: 850.0,
10063                bc_value: 0.2,
10064                bc_type: DragModel::G7,
10065                muzzle_angle: 0.03,
10066                use_rk4,
10067                use_adaptive_rk45,
10068                ..BallisticInputs::default()
10069            };
10070            let mut solver = TrajectorySolver::new(
10071                inputs,
10072                WindConditions::default(),
10073                AtmosphericConditions::default(),
10074            );
10075            // Well short of the ~671 m downward 1.2 crossing measured for this load.
10076            solver.set_max_range(200.0);
10077            let result = solver.solve().expect("solve should succeed");
10078
10079            assert_eq!(
10080                result.mach_1_2_distance_m, None,
10081                "{solver_name}: must not report a 1.2 crossing that never happens"
10082            );
10083            assert_eq!(
10084                result.mach_1_0_distance_m, None,
10085                "{solver_name}: must not report a 1.0 crossing that never happens"
10086            );
10087            assert_eq!(
10088                result.mach_0_9_distance_m, None,
10089                "{solver_name}: must not report a 0.9 crossing that never happens"
10090            );
10091        }
10092    }
10093}
10094
10095#[cfg(test)]
10096mod cant_tests {
10097    use super::*;
10098
10099    fn base_inputs() -> BallisticInputs {
10100        BallisticInputs {
10101            muzzle_velocity: 800.0,
10102            bc_value: 0.5,
10103            bc_type: DragModel::G7,
10104            bullet_mass: 0.0109,
10105            bullet_diameter: 0.00782,
10106            bullet_length: 0.0309,
10107            sight_height: 0.05,
10108            twist_rate: 10.0,
10109            use_rk4: true,
10110            ..BallisticInputs::default()
10111        }
10112    }
10113
10114    fn solve_with(inputs: BallisticInputs, max_range: f64) -> TrajectoryResult {
10115        let mut s = TrajectorySolver::new(
10116            inputs,
10117            WindConditions::default(),
10118            AtmosphericConditions::default(),
10119        );
10120        s.set_max_range(max_range);
10121        s.solve().expect("solve")
10122    }
10123
10124    /// Interpolate (y, z) at downrange x.
10125    fn yz_at(result: &TrajectoryResult, x: f64) -> (f64, f64) {
10126        let pts = &result.points;
10127        for i in 1..pts.len() {
10128            if pts[i].position.x >= x {
10129                let (p1, p2) = (&pts[i - 1], &pts[i]);
10130                let dx = p2.position.x - p1.position.x;
10131                let t = if dx.abs() < 1e-12 {
10132                    0.0
10133                } else {
10134                    (x - p1.position.x) / dx
10135                };
10136                return (
10137                    p1.position.y + t * (p2.position.y - p1.position.y),
10138                    p1.position.z + t * (p2.position.z - p1.position.z),
10139                );
10140            }
10141        }
10142        panic!("trajectory never reached {x} m");
10143    }
10144
10145    #[test]
10146    fn cant_sign_clockwise_up_offset_goes_right_and_low() {
10147        // Upward zero offset + clockwise cant => POI right (+z) and low vs un-canted.
10148        let mut level = base_inputs();
10149        level.muzzle_angle = 0.003; // ~10 MOA up
10150        let mut canted = level.clone();
10151        canted.cant_angle = 10f64.to_radians();
10152
10153        let (y0, z0) = yz_at(&solve_with(level, 400.0), 300.0);
10154        let (y1, z1) = yz_at(&solve_with(canted, 400.0), 300.0);
10155        assert!(
10156            z1 > z0 + 0.01,
10157            "clockwise cant must move POI right: z0={z0} z1={z1}"
10158        );
10159        assert!(
10160            y1 < y0 - 0.001,
10161            "clockwise cant must move POI low: y0={y0} y1={y1}"
10162        );
10163    }
10164
10165    #[test]
10166    fn pure_cant_shows_bore_offset_near_range() {
10167        // No aim offset: the only lateral source near the muzzle is the swung bore,
10168        // z0 = -sight_height*sin(cant) (left of the aim plane for clockwise cant).
10169        let mut i = base_inputs();
10170        i.muzzle_angle = 0.0;
10171        i.cant_angle = 10f64.to_radians();
10172        let sh = i.sight_height;
10173        let r = solve_with(i, 60.0);
10174        let first = &r.points[1]; // just past the muzzle
10175        let expected = -sh * 10f64.to_radians().sin();
10176        assert!(
10177            (first.position.z - expected).abs() < 0.005,
10178            "near-muzzle lateral {} should be ~bore offset {expected}",
10179            first.position.z
10180        );
10181    }
10182
10183    #[test]
10184    fn zero_angle_is_independent_of_cant() {
10185        let a = base_inputs();
10186        let mut b = base_inputs();
10187        b.cant_angle = 15f64.to_radians();
10188        let za = calculate_zero_angle(a.clone(), 100.0, 0.0).expect("zero a");
10189        let zb = calculate_zero_angle(b.clone(), 100.0, 0.0).expect("zero b");
10190        assert_eq!(
10191            za.to_bits(),
10192            zb.to_bits(),
10193            "zeroing must ignore cant: {za} vs {zb}"
10194        );
10195        // silence unused warnings
10196        let _ = (a.cant_angle, b.cant_angle);
10197    }
10198
10199    #[test]
10200    fn nonfinite_cant_is_rejected() {
10201        let mut i = base_inputs();
10202        i.cant_angle = f64::NAN;
10203        let s = TrajectorySolver::new(
10204            i,
10205            WindConditions::default(),
10206            AtmosphericConditions::default(),
10207        );
10208        assert!(s.solve().is_err());
10209    }
10210
10211    #[test]
10212    fn incline_and_cant_compose_without_breaking() {
10213        // 15-degree incline + 10-degree cant: finite result, cant still pushes right.
10214        let mut flat = base_inputs();
10215        flat.muzzle_angle = 0.003;
10216        flat.shooting_angle = 15f64.to_radians();
10217        let mut canted = flat.clone();
10218        canted.cant_angle = 10f64.to_radians();
10219        let (_, z_flat) = yz_at(&solve_with(flat, 400.0), 300.0);
10220        let (_, z_cant) = yz_at(&solve_with(canted, 400.0), 300.0);
10221        assert!(
10222            z_cant > z_flat,
10223            "cant must still deflect right on an incline"
10224        );
10225    }
10226}
10227
10228#[cfg(test)]
10229mod vertical_wind_tests {
10230    use super::*;
10231
10232    fn base_inputs() -> BallisticInputs {
10233        BallisticInputs {
10234            muzzle_velocity: 800.0,
10235            bc_value: 0.5,
10236            bc_type: DragModel::G7,
10237            bullet_mass: 0.0109,
10238            bullet_diameter: 0.00782,
10239            bullet_length: 0.0309,
10240            sight_height: 0.05,
10241            twist_rate: 10.0,
10242            use_rk4: true,
10243            ..BallisticInputs::default()
10244        }
10245    }
10246
10247    /// Interpolate trajectory height (McCoy Y) at downrange distance `x`.
10248    fn y_at(result: &TrajectoryResult, x: f64) -> f64 {
10249        let pts = &result.points;
10250        for i in 1..pts.len() {
10251            if pts[i].position.x >= x {
10252                let (p1, p2) = (&pts[i - 1], &pts[i]);
10253                let dx = p2.position.x - p1.position.x;
10254                let t = if dx.abs() < 1e-12 {
10255                    0.0
10256                } else {
10257                    (x - p1.position.x) / dx
10258                };
10259                return p1.position.y + t * (p2.position.y - p1.position.y);
10260            }
10261        }
10262        panic!("trajectory never reached {x} m");
10263    }
10264
10265    fn solve_with(
10266        inputs: BallisticInputs,
10267        wind: WindConditions,
10268        max_range: f64,
10269    ) -> TrajectoryResult {
10270        let mut s = TrajectorySolver::new(inputs, wind, AtmosphericConditions::default());
10271        s.set_max_range(max_range);
10272        s.solve().expect("solve")
10273    }
10274
10275    #[test]
10276    fn updraft_raises_poi_downrange() {
10277        // No shear, no segments: this exercises the constant-wind sites in
10278        // solve_euler/solve_rk4/solve_rk45 directly (MBA-728).
10279        let calm_inputs = base_inputs();
10280        let calm_wind = WindConditions::default();
10281        let updraft = WindConditions {
10282            vertical_speed: 5.0,
10283            ..Default::default()
10284        };
10285
10286        let calm = solve_with(calm_inputs.clone(), calm_wind, 500.0);
10287        let updraft_result = solve_with(calm_inputs, updraft, 500.0);
10288
10289        let y_calm = y_at(&calm, 400.0);
10290        let y_updraft = y_at(&updraft_result, 400.0);
10291        assert!(
10292            y_updraft > y_calm,
10293            "5 m/s updraft must raise POI at 400m: calm={y_calm}, updraft={y_updraft}"
10294        );
10295    }
10296
10297    #[test]
10298    fn zero_vertical_is_default_and_finite_required() {
10299        assert_eq!(WindConditions::default().vertical_speed, 0.0);
10300
10301        let inputs = base_inputs();
10302        let wind = WindConditions {
10303            vertical_speed: f64::NAN,
10304            ..Default::default()
10305        };
10306        let s = TrajectorySolver::new(inputs, wind, AtmosphericConditions::default());
10307        assert!(
10308            s.solve().is_err(),
10309            "NaN wind.vertical_speed must be rejected by validate_for_solve"
10310        );
10311    }
10312}
10313
10314/// MBA-1365: declare whether a BC is ICAO- or Army-Standard-Metro-referenced.
10315#[cfg(test)]
10316mod bc_reference_standard_tests {
10317    use super::*;
10318
10319    fn base_inputs() -> BallisticInputs {
10320        BallisticInputs {
10321            muzzle_velocity: 800.0,
10322            bc_value: 0.5,
10323            bc_type: DragModel::G7,
10324            bullet_mass: 0.0109,
10325            bullet_diameter: 0.00782,
10326            bullet_length: 0.0309,
10327            sight_height: 0.05,
10328            twist_rate: 10.0,
10329            use_rk4: true,
10330            ..BallisticInputs::default()
10331        }
10332    }
10333
10334    /// Interpolate trajectory height (McCoy Y) and speed at downrange distance `x`.
10335    fn y_and_speed_at(result: &TrajectoryResult, x: f64) -> (f64, f64) {
10336        let pts = &result.points;
10337        for i in 1..pts.len() {
10338            if pts[i].position.x >= x {
10339                let (p1, p2) = (&pts[i - 1], &pts[i]);
10340                let dx = p2.position.x - p1.position.x;
10341                let t = if dx.abs() < 1e-12 {
10342                    0.0
10343                } else {
10344                    (x - p1.position.x) / dx
10345                };
10346                return (
10347                    p1.position.y + t * (p2.position.y - p1.position.y),
10348                    p1.velocity_magnitude + t * (p2.velocity_magnitude - p1.velocity_magnitude),
10349                );
10350            }
10351        }
10352        panic!("trajectory never reached {x} m");
10353    }
10354
10355    // ---- The constant itself -----------------------------------------------------------
10356
10357    #[test]
10358    fn asm_to_icao_ratio_matches_documented_value() {
10359        assert!(
10360            (crate::constants::ASM_TO_ICAO_BC - 0.98237).abs() < 1e-5,
10361            "ASM_TO_ICAO_BC = {} must equal 0.98237 to 5 decimal places",
10362            crate::constants::ASM_TO_ICAO_BC
10363        );
10364        // Derived from the two named densities, not a bare literal. (The ratio being < 1.0
10365        // — a denser reference atmosphere implying a numerically smaller ICAO-equivalent
10366        // BC — is already established by the 0.98237 check above; a bare `assert!` on a
10367        // const-only comparison here would be compiled away, so it isn't repeated.)
10368        assert_eq!(
10369            crate::constants::ASM_TO_ICAO_BC,
10370            crate::constants::ASM_DENSITY_LB_FT3 / crate::constants::ICAO_DENSITY_LB_FT3
10371        );
10372    }
10373
10374    // ---- Default / byte-identical --------------------------------------------------------
10375
10376    #[test]
10377    fn default_bc_reference_standard_is_icao() {
10378        assert_eq!(
10379            BallisticInputs::default().bc_reference_standard,
10380            BcReferenceStandard::Icao
10381        );
10382    }
10383
10384    /// RED (pre-MBA-1365) would not compile at all — there was no field to normalize.
10385    /// GREEN: with the field defaulted to `Icao`, `TrajectorySolver::new` must never
10386    /// touch `bc_value` — bit-for-bit, not just approximately — so every existing
10387    /// caller that never sets this field is byte-identical to today.
10388    #[test]
10389    fn icao_reference_leaves_bc_value_bit_identical() {
10390        let raw_bc: f64 = 0.4372911; // an arbitrary, non-round value
10391        let inputs = BallisticInputs {
10392            bc_value: raw_bc,
10393            bc_reference_standard: BcReferenceStandard::Icao,
10394            ..base_inputs()
10395        };
10396        let solver = TrajectorySolver::new(
10397            inputs,
10398            WindConditions::default(),
10399            AtmosphericConditions::default(),
10400        );
10401        assert_eq!(solver.inputs.bc_value.to_bits(), raw_bc.to_bits());
10402    }
10403
10404    #[test]
10405    fn default_inputs_solve_is_unaffected_by_the_new_field_existing() {
10406        // A solve built entirely from BallisticInputs::default() (which now carries
10407        // bc_reference_standard: Icao) must match a solve of an equivalent struct that
10408        // never mentions the field at all in its literal (relying on ..default()).
10409        let a = TrajectorySolver::new(
10410            base_inputs(),
10411            WindConditions::default(),
10412            AtmosphericConditions::default(),
10413        )
10414        .solve()
10415        .expect("solve a");
10416        let b = TrajectorySolver::new(
10417            BallisticInputs { ..base_inputs() },
10418            WindConditions::default(),
10419            AtmosphericConditions::default(),
10420        )
10421        .solve()
10422        .expect("solve b");
10423        assert_eq!(a.impact_velocity.to_bits(), b.impact_velocity.to_bits());
10424        assert_eq!(a.max_range.to_bits(), b.max_range.to_bits());
10425    }
10426
10427    // ---- ArmyStandardMetro normalization --------------------------------------------------
10428
10429    #[test]
10430    fn army_standard_metro_scales_bc_value_by_exactly_the_derived_ratio() {
10431        let raw_bc = 0.5;
10432        let inputs = BallisticInputs {
10433            bc_value: raw_bc,
10434            bc_reference_standard: BcReferenceStandard::ArmyStandardMetro,
10435            ..base_inputs()
10436        };
10437        let solver = TrajectorySolver::new(
10438            inputs,
10439            WindConditions::default(),
10440            AtmosphericConditions::default(),
10441        );
10442        assert_eq!(
10443            solver.inputs.bc_value,
10444            raw_bc * crate::constants::ASM_TO_ICAO_BC
10445        );
10446    }
10447
10448    #[test]
10449    fn army_standard_metro_scales_mach_keyed_bc_segments() {
10450        let inputs = BallisticInputs {
10451            bc_reference_standard: BcReferenceStandard::ArmyStandardMetro,
10452            bc_segments: Some(vec![(0.5, 0.40), (1.5, 0.30)]),
10453            ..base_inputs()
10454        };
10455        let solver = TrajectorySolver::new(
10456            inputs,
10457            WindConditions::default(),
10458            AtmosphericConditions::default(),
10459        );
10460        let segments = solver.inputs.bc_segments.as_ref().expect("segments");
10461        assert_eq!(segments[0], (0.5, 0.40 * crate::constants::ASM_TO_ICAO_BC));
10462        assert_eq!(segments[1], (1.5, 0.30 * crate::constants::ASM_TO_ICAO_BC));
10463    }
10464
10465    #[test]
10466    fn army_standard_metro_scales_velocity_keyed_bc_segments_data() {
10467        let inputs = BallisticInputs {
10468            bc_reference_standard: BcReferenceStandard::ArmyStandardMetro,
10469            bc_segments_data: Some(vec![
10470                crate::BCSegmentData {
10471                    velocity_min: 0.0,
10472                    velocity_max: 500.0,
10473                    bc_value: 0.40,
10474                },
10475                crate::BCSegmentData {
10476                    velocity_min: 500.0,
10477                    velocity_max: 900.0,
10478                    bc_value: 0.45,
10479                },
10480            ]),
10481            ..base_inputs()
10482        };
10483        let solver = TrajectorySolver::new(
10484            inputs,
10485            WindConditions::default(),
10486            AtmosphericConditions::default(),
10487        );
10488        let segments = solver.inputs.bc_segments_data.as_ref().expect("segments");
10489        assert_eq!(
10490            segments[0].bc_value,
10491            0.40 * crate::constants::ASM_TO_ICAO_BC
10492        );
10493        assert_eq!(
10494            segments[1].bc_value,
10495            0.45 * crate::constants::ASM_TO_ICAO_BC
10496        );
10497        // Non-BC fields must be untouched.
10498        assert_eq!(segments[0].velocity_min, 0.0);
10499        assert_eq!(segments[1].velocity_max, 900.0);
10500    }
10501
10502    /// Empirically verify the drag DIRECTION (not just the arithmetic): declaring the
10503    /// SAME raw BC number as Army-Standard-Metro must produce MORE drop and LOWER
10504    /// remaining velocity downrange than declaring it ICAO, because the normalized
10505    /// (smaller) BC feeds MORE drag into the ICAO-calibrated retardation formula.
10506    #[test]
10507    fn army_standard_metro_moves_impact_in_the_more_drag_direction() {
10508        let solve_at = |standard: BcReferenceStandard| {
10509            let inputs = BallisticInputs {
10510                bc_value: 0.475,
10511                bc_reference_standard: standard,
10512                ..base_inputs()
10513            };
10514            let mut solver = TrajectorySolver::new(
10515                inputs,
10516                WindConditions::default(),
10517                AtmosphericConditions::default(),
10518            );
10519            solver.set_max_range(500.0);
10520            solver.solve().expect("solve")
10521        };
10522
10523        let icao = solve_at(BcReferenceStandard::Icao);
10524        let asm = solve_at(BcReferenceStandard::ArmyStandardMetro);
10525
10526        let (y_icao, v_icao) = y_and_speed_at(&icao, 400.0);
10527        let (y_asm, v_asm) = y_and_speed_at(&asm, 400.0);
10528
10529        assert!(
10530            y_asm < y_icao,
10531            "ArmyStandardMetro must drop MORE (lower y) at 400m than Icao for the same raw \
10532             bc_value: icao_y={y_icao}, asm_y={y_asm}"
10533        );
10534        assert!(
10535            v_asm < v_icao,
10536            "ArmyStandardMetro must retain LESS velocity at 400m than Icao for the same raw \
10537             bc_value: icao_v={v_icao}, asm_v={v_asm}"
10538        );
10539    }
10540
10541    // ---- Downstream inheritance: Monte Carlo ----------------------------------------------
10542
10543    /// Monte Carlo must inherit the exact same normalization with zero duplicated math:
10544    /// with every std-dev pinned to 0 (so every sample is deterministically the base
10545    /// input, unperturbed), a single-sample MC run must match a plain direct solve of the
10546    /// identical base inputs bit-for-bit.
10547    #[test]
10548    fn monte_carlo_inherits_the_normalized_bc_reference() {
10549        let base_inputs_asm = BallisticInputs {
10550            bc_value: 0.475,
10551            bc_reference_standard: BcReferenceStandard::ArmyStandardMetro,
10552            ..base_inputs()
10553        };
10554        let wind = WindConditions::default();
10555
10556        // Match run_monte_carlo_with_wind_and_direction_std_dev_using_rng's own
10557        // solver_max_range derivation exactly (target_hint.max(1000.0) * 2.0, with
10558        // target_hint = base_inputs.target_distance since MonteCarloParams.target_distance
10559        // is None below) — otherwise the two solves integrate to different caps and a
10560        // level, non-ground-impacting shot legitimately reports a different max_range.
10561        let mut direct_solver = TrajectorySolver::new(
10562            base_inputs_asm.clone(),
10563            wind.clone(),
10564            AtmosphericConditions::default(),
10565        );
10566        direct_solver.set_max_range(base_inputs_asm.target_distance.max(1000.0) * 2.0);
10567        let direct = direct_solver.solve().expect("direct solve");
10568
10569        let mc_params = MonteCarloParams {
10570            num_simulations: 1,
10571            velocity_std_dev: 0.0,
10572            angle_std_dev: 0.0,
10573            bc_std_dev: 0.0,
10574            wind_speed_std_dev: 0.0,
10575            target_distance: None,
10576            base_wind_speed: 0.0,
10577            base_wind_direction: 0.0,
10578            azimuth_std_dev: 0.0,
10579        };
10580        let mc = run_monte_carlo_with_wind_and_direction_std_dev_seeded(
10581            base_inputs_asm,
10582            wind,
10583            mc_params,
10584            0.0,
10585            42,
10586        )
10587        .expect("monte carlo");
10588
10589        assert_eq!(mc.ranges.len(), 1);
10590        assert_eq!(
10591            mc.ranges[0].to_bits(),
10592            direct.max_range.to_bits(),
10593            "a zero-dispersion single MC sample must match a plain solve of the same \
10594             ASM-referenced inputs bit-for-bit"
10595        );
10596        assert_eq!(
10597            mc.impact_velocities[0].to_bits(),
10598            direct.impact_velocity.to_bits()
10599        );
10600    }
10601
10602    // ---- Downstream inheritance: estimate-bc fitting --------------------------------------
10603
10604    /// `estimate_bc_fit` always builds its internal search trials from
10605    /// `BallisticInputs { ..Default::default() }`, which defaults to `Icao` — so a fitted
10606    /// BC is always ICAO-referenced regardless of any other configuration in the process.
10607    /// Prove it by generating synthetic drop data from a KNOWN Icao-referenced bc_value and
10608    /// confirming the fit recovers that same value (not an ASM-shifted one).
10609    #[test]
10610    fn estimate_bc_fit_recovers_an_icao_referenced_bc() {
10611        let known_bc = 0.475;
10612        let velocity = 800.0;
10613        let mass = 0.0109;
10614        let diameter = 0.00782;
10615        let atmosphere = AtmosphericConditions::default();
10616
10617        let synth_inputs = BallisticInputs {
10618            muzzle_velocity: velocity,
10619            bc_value: known_bc,
10620            bc_type: DragModel::G7,
10621            bullet_mass: mass,
10622            bullet_diameter: diameter,
10623            bullet_length: 0.0309,
10624            sight_height: 0.05,
10625            twist_rate: 10.0,
10626            use_rk4: true,
10627            bc_reference_standard: BcReferenceStandard::Icao,
10628            ..BallisticInputs::default()
10629        };
10630        let mut solver =
10631            TrajectorySolver::new(synth_inputs, WindConditions::default(), atmosphere.clone());
10632        solver.set_max_range(500.0);
10633        let trajectory = solver.solve().expect("synthetic solve");
10634
10635        let points: Vec<(f64, f64)> = [100.0, 200.0, 300.0, 400.0]
10636            .iter()
10637            .map(|&d| {
10638                let (y, _) = {
10639                    let pts = &trajectory.points;
10640                    let mut found = None;
10641                    for i in 1..pts.len() {
10642                        if pts[i].position.x >= d {
10643                            let (p1, p2) = (&pts[i - 1], &pts[i]);
10644                            let dx = p2.position.x - p1.position.x;
10645                            let t = if dx.abs() < 1e-12 {
10646                                0.0
10647                            } else {
10648                                (d - p1.position.x) / dx
10649                            };
10650                            found =
10651                                Some((p1.position.y + t * (p2.position.y - p1.position.y), 0.0));
10652                            break;
10653                        }
10654                    }
10655                    found.expect("trajectory reached observation distance")
10656                };
10657                (d, -y) // drop is positive-down
10658            })
10659            .collect();
10660
10661        let estimate = estimate_bc_fit(
10662            velocity,
10663            mass,
10664            diameter,
10665            &points,
10666            DragModel::G7,
10667            BcFitMode::Drop,
10668            atmosphere,
10669            None,
10670            0.05,
10671        )
10672        .expect("fit should converge");
10673
10674        assert!(
10675            (estimate.bc - known_bc).abs() < 0.02,
10676            "fit should recover the known ICAO-referenced bc={known_bc}, got {}",
10677            estimate.bc
10678        );
10679    }
10680
10681    // ---- Custom drag table: documented inert behavior -------------------------------------
10682
10683    #[test]
10684    fn custom_drag_table_makes_bc_reference_standard_numerically_inert() {
10685        let table =
10686            crate::drag::DragTable::try_new(vec![0.5, 1.0, 2.0, 3.0], vec![0.3, 0.4, 0.3, 0.2])
10687                .expect("valid table");
10688
10689        let solve_with = |standard: BcReferenceStandard| {
10690            let inputs = BallisticInputs {
10691                bc_value: 0.5, // irrelevant once a custom drag table is active
10692                bc_reference_standard: standard,
10693                custom_drag_table: Some(table.clone()),
10694                ..base_inputs()
10695            };
10696            let mut solver = TrajectorySolver::new(
10697                inputs,
10698                WindConditions::default(),
10699                AtmosphericConditions::default(),
10700            );
10701            solver.set_max_range(500.0);
10702            solver.solve().expect("solve")
10703        };
10704
10705        let icao = solve_with(BcReferenceStandard::Icao);
10706        let asm = solve_with(BcReferenceStandard::ArmyStandardMetro);
10707
10708        assert_eq!(
10709            icao.impact_velocity.to_bits(),
10710            asm.impact_velocity.to_bits(),
10711            "a custom drag table must make bc_reference_standard fully inert"
10712        );
10713        assert_eq!(icao.max_range.to_bits(), asm.max_range.to_bits());
10714    }
10715
10716    #[test]
10717    fn custom_drag_table_inert_warning_fires_only_for_army_standard_metro_with_a_table() {
10718        let table = crate::drag::DragTable::try_new(vec![0.5, 1.0, 2.0], vec![0.3, 0.4, 0.3])
10719            .expect("valid table");
10720
10721        // No table: never warns, regardless of the declared standard.
10722        let no_table_icao = base_inputs();
10723        assert!(no_table_icao
10724            .bc_reference_standard_inert_warning()
10725            .is_none());
10726        let no_table_asm = BallisticInputs {
10727            bc_reference_standard: BcReferenceStandard::ArmyStandardMetro,
10728            ..base_inputs()
10729        };
10730        assert!(no_table_asm.bc_reference_standard_inert_warning().is_none());
10731
10732        // Table + Icao: no conversion was ever going to apply, so no warning either.
10733        let table_icao = BallisticInputs {
10734            custom_drag_table: Some(table.clone()),
10735            ..base_inputs()
10736        };
10737        assert!(table_icao.bc_reference_standard_inert_warning().is_none());
10738
10739        // Table + ArmyStandardMetro: the one case that is actually inert -> warn.
10740        let table_asm = BallisticInputs {
10741            custom_drag_table: Some(table),
10742            bc_reference_standard: BcReferenceStandard::ArmyStandardMetro,
10743            ..base_inputs()
10744        };
10745        let warning = table_asm
10746            .bc_reference_standard_inert_warning()
10747            .expect("must warn");
10748        assert!(warning.contains("--bc-reference"));
10749        assert!(warning.contains("--drag-table"));
10750    }
10751}
10752
10753/// MBA-1423: the per-point effective drag coefficient.
10754///
10755/// These pin the DEFINITION (the projectile's own Cd, form-factor scaled) rather than the
10756/// plumbing, because the plumbing being present is not the risk — reporting a plausible number
10757/// that is not the one flown is.
10758#[cfg(test)]
10759mod effective_drag_coefficient_tests {
10760    use super::*;
10761
10762    fn inputs_175gr_g7() -> BallisticInputs {
10763        let mut inputs = BallisticInputs {
10764            bc_value: 0.243,
10765            bc_type: DragModel::G7,
10766            muzzle_velocity: 823.0,
10767            ..Default::default()
10768        };
10769        // Set the SI fields: TrajectorySolver::new re-derives caliber_inches/weight_grains
10770        // from bullet_diameter/bullet_mass, so setting only the imperial pair would be
10771        // silently overwritten by the SI defaults (a 154gr .30, not a 175gr .308).
10772        inputs.bullet_mass = 175.0 * crate::constants::GRAINS_TO_KG;
10773        inputs.bullet_diameter = 0.308 * 0.0254;
10774        inputs.weight_grains = 175.0;
10775        inputs.caliber_inches = 0.308;
10776        inputs
10777    }
10778
10779    fn solver(inputs: BallisticInputs) -> TrajectorySolver {
10780        TrajectorySolver::new(
10781            inputs,
10782            WindConditions::default(),
10783            AtmosphericConditions::default(),
10784        )
10785    }
10786
10787    /// The reported Cd is the projectile's own, i.e. the reference Cd scaled by the form factor
10788    /// SD/BC — not the reference table value itself, which would be identical for every bullet
10789    /// sharing a drag model and so useless for charting a specific load.
10790    #[test]
10791    fn reports_the_projectiles_own_cd_not_the_reference_tables() {
10792        let inputs = inputs_175gr_g7();
10793        let sd = inputs.sectional_density_lb_in2().expect("SD");
10794        let solver = solver(inputs);
10795
10796        let sos = 340.0;
10797        let velocity = 800.0;
10798        let mach = velocity / sos;
10799
10800        let reference = crate::drag::get_drag_coefficient(mach, &DragModel::G7);
10801        let reported = solver
10802            .effective_drag_coefficient(velocity, sos)
10803            .expect("mass and diameter are set");
10804
10805        let expected = reference * sd / 0.243;
10806        assert!(
10807            (reported - expected).abs() < 1e-12,
10808            "reported {reported} != Cd_ref * SD / BC {expected}"
10809        );
10810        // The form factor here is > 1, so the two genuinely differ: a test that passed with
10811        // `reported == reference` would not be testing anything.
10812        assert!(
10813            (reported - reference).abs() > 1e-6,
10814            "form factor collapsed to 1; this fixture no longer distinguishes the two values"
10815        );
10816    }
10817
10818    /// A custom drag table already supplies the projectile's actual Cd and divides by sectional
10819    /// density, so the form-factor scale must collapse to exactly 1 and pass the curve through.
10820    #[test]
10821    fn a_custom_drag_table_passes_through_unscaled() {
10822        let mut inputs = inputs_175gr_g7();
10823        inputs.custom_drag_table = Some(crate::drag::DragTable::new(
10824            vec![0.5, 3.0],
10825            vec![0.15, 0.40],
10826        ));
10827        let solver = solver(inputs);
10828
10829        let sos = 340.0;
10830        let velocity = 0.9 * sos;
10831        let table_value = solver
10832            .inputs
10833            .custom_drag_table
10834            .as_ref()
10835            .expect("table")
10836            .interpolate(0.9);
10837
10838        let reported = solver
10839            .effective_drag_coefficient(velocity, sos)
10840            .expect("mass and diameter are set");
10841        assert!(
10842            (reported - table_value).abs() < 1e-12,
10843            "custom table Cd {table_value} was rescaled to {reported}"
10844        );
10845    }
10846
10847    /// The band step a segmented BC produces is the whole reason this field exists: it is the
10848    /// one feature of a real load's drag curve a consumer cannot reconstruct from a published BC.
10849    #[test]
10850    fn a_velocity_segmented_bc_steps_the_reported_cd() {
10851        let mut inputs = inputs_175gr_g7();
10852        inputs.use_bc_segments = true;
10853        inputs.bc_segments_data = Some(vec![
10854            crate::BCSegmentData {
10855                velocity_min: 2400.0,
10856                velocity_max: 4000.0,
10857                bc_value: 0.243,
10858            },
10859            crate::BCSegmentData {
10860                velocity_min: 0.0,
10861                velocity_max: 2400.0,
10862                bc_value: 0.200,
10863            },
10864        ]);
10865        let solver = solver(inputs);
10866
10867        let sos = 340.0;
10868        // Straddle the 2400 fps boundary (fps -> m/s).
10869        let above = solver
10870            .effective_drag_coefficient(2500.0 / 3.28084, sos)
10871            .expect("cd");
10872        let below = solver
10873            .effective_drag_coefficient(2300.0 / 3.28084, sos)
10874            .expect("cd");
10875
10876        // Lower BC below the boundary means MORE drag for the same reference curve.
10877        assert!(
10878            below > above,
10879            "expected the 0.200 band to report a higher Cd than the 0.243 band; got {below} vs {above}"
10880        );
10881    }
10882
10883    /// Sectional density is undefined without both mass and diameter, and so is the projectile's
10884    /// own Cd. Reporting the reference value there would be a silently wrong number.
10885    #[test]
10886    fn is_absent_when_sectional_density_is_unknown() {
10887        let mut inputs = inputs_175gr_g7();
10888        inputs.weight_grains = 0.0;
10889        inputs.bullet_mass = 0.0;
10890        let solver = solver(inputs);
10891        assert!(solver.effective_drag_coefficient(800.0, 340.0).is_none());
10892    }
10893
10894    /// MBA-1427: the emit rule the browser terminal applies, pinned natively because the WASM
10895    /// emit site is cfg-gated out of every native build and so is never executed by CI. Three
10896    /// cases, and the third is the contract: absent — not null — when sectional density is
10897    /// unknown, even with the flag set.
10898    #[test]
10899    fn the_json_emit_rule_is_flag_gated_and_absent_when_cd_is_unknown() {
10900        let mut point = TrajectoryPoint {
10901            time: 0.0,
10902            position: nalgebra::Vector3::new(0.0, 0.0, 0.0),
10903            velocity_magnitude: 800.0,
10904            kinetic_energy: 3000.0,
10905            drag_coefficient: Some(0.31),
10906        };
10907        assert_eq!(point.drag_coefficient_json_value(true), Some(0.31));
10908        assert_eq!(
10909            point.drag_coefficient_json_value(false),
10910            None,
10911            "without the flag the key must not exist, so default JSON stays byte-identical"
10912        );
10913        point.drag_coefficient = None;
10914        assert_eq!(
10915            point.drag_coefficient_json_value(true),
10916            None,
10917            "unknown sectional density must yield an ABSENT key, not null"
10918        );
10919    }
10920
10921    /// Every solver family runs the same post-pass, so none may leave the field empty.
10922    #[test]
10923    fn every_point_of_a_solved_trajectory_carries_the_value() {
10924        let mut solver = solver(inputs_175gr_g7());
10925        solver.set_max_range(300.0);
10926        let result = solver.solve().expect("solve");
10927        assert!(!result.points.is_empty());
10928        assert!(
10929            result.points.iter().all(|p| p.drag_coefficient.is_some()),
10930            "the post-integration pass missed at least one point"
10931        );
10932    }
10933}
10934
10935#[cfg(test)]
10936mod boundary_layer_shear_height_tests {
10937    //! Boundary-layer shear keys off the bullet's height above the GROUND. `TrajectorySolver`
10938    //! measures y from the level `muzzle_height` is measured from and integrates an inclined
10939    //! shot in the shot frame, whose ground runs parallel to the line of sight (the plane its
10940    //! ground_threshold check uses), so the shear height is the shot-frame y plus the height of
10941    //! y = 0 above the ground: nothing when `muzzle_height` is set (y = 0 is the ground it was
10942    //! measured from), the assumed 1.5 m bore height when it is left at 0 (y = 0 is the bore).
10943    use super::*;
10944
10945    const MODELS: [&str; 2] = ["logarithmic", "power_law"];
10946
10947    fn ratio_rel_launch(height_rel_launch_m: f64, model: &str) -> f64 {
10948        crate::wind_shear::boundary_layer_speed_ratio(
10949            height_rel_launch_m,
10950            crate::wind_shear::boundary_layer_model_from_name(model),
10951        )
10952    }
10953
10954    /// Asserts that the boundary-layer multiplier `TrajectorySolver` applies at `position` is
10955    /// `expected_ratio`: its acceleration with shear on equals its acceleration with shear off
10956    /// and the wind scaled by that ratio. Both are evaluated at the same point, so the local
10957    /// atmosphere is the same in each.
10958    fn assert_solver_shear_ratio(
10959        model: &str,
10960        muzzle_height: f64,
10961        shooting_angle: f64,
10962        position: Vector3<f64>,
10963        expected_ratio: f64,
10964    ) {
10965        let inputs = BallisticInputs {
10966            muzzle_height,
10967            shooting_angle,
10968            enable_wind_shear: true,
10969            wind_shear_model: model.to_string(),
10970            ..BallisticInputs::default()
10971        };
10972        let wind = WindConditions {
10973            speed: 10.0,
10974            direction: std::f64::consts::FRAC_PI_2,
10975            vertical_speed: 0.0,
10976        };
10977        let sheared = TrajectorySolver::new(
10978            inputs.clone(),
10979            wind.clone(),
10980            AtmosphericConditions::default(),
10981        );
10982        let uniform = TrajectorySolver::new(
10983            BallisticInputs {
10984                enable_wind_shear: false,
10985                ..inputs
10986            },
10987            wind.clone(),
10988            AtmosphericConditions::default(),
10989        );
10990        let (density, _, temp_c, pressure_hpa) = sheared.resolved_atmosphere();
10991        let resolved_atmo = (temp_c, pressure_hpa, density / 1.225);
10992        let velocity = Vector3::new(700.0, 5.0, 0.0);
10993        let scaled_wind =
10994            crate::wind::wind_vector(wind.speed, wind.direction, 0.0) * expected_ratio;
10995
10996        let with_shear =
10997            sheared.calculate_acceleration(&position, &velocity, &Vector3::zeros(), resolved_atmo);
10998        let with_scaled_wind =
10999            uniform.calculate_acceleration(&position, &velocity, &scaled_wind, resolved_atmo);
11000        assert!(
11001            (with_shear - with_scaled_wind).norm() <= 1e-12,
11002            "{model}, muzzle_height {muzzle_height} m, shooting angle {shooting_angle} rad, \
11003             position {position:?}: the shear multiplier is not {expected_ratio} \
11004             (acceleration {with_shear:?}, with the wind scaled by it {with_scaled_wind:?})"
11005        );
11006    }
11007
11008    /// (a) One height above the ground reads one multiplier, whatever `muzzle_height` says.
11009    /// With `muzzle_height` 1.5 m, y = 60 m is 60 m above the ground; with it left at 0, y = 0
11010    /// is the bore, taken as 1.5 m up, so y = 58.5 m is. The bore height used to be counted
11011    /// twice in the first case (61.5 m).
11012    #[test]
11013    fn solver_shear_reads_one_multiplier_at_one_height_above_ground() {
11014        for model in MODELS {
11015            // Height relative to the bore plus the assumed 1.5 m: 60 m above the ground.
11016            let expected = ratio_rel_launch(58.5, model);
11017            assert!(
11018                expected > 1.2,
11019                "{model}: the fixture must be well above the floor"
11020            );
11021            assert_solver_shear_ratio(model, 1.5, 0.0, Vector3::new(1_000.0, 60.0, 0.0), expected);
11022            assert_solver_shear_ratio(model, 0.0, 0.0, Vector3::new(1_000.0, 58.5, 0.0), expected);
11023        }
11024    }
11025
11026    /// (b) On an inclined shot the shear height is the shot-frame y plus the height of y = 0
11027    /// above the ground: the ground runs parallel to the line of sight, as it does for the
11028    /// ground_threshold check, so the shooting angle does not enter. The world height above
11029    /// the shooter's level, x·sin θ + y·cos θ, would read this point as hundreds of metres up
11030    /// on the uphill shot and below the ground on the downhill one. The fast kernel and the
11031    /// RK45 kernel read the same multiplier at the same point:
11032    /// `fast_trajectory::tests::inclined_fast_shear_measures_height_along_the_line_of_sight`
11033    /// and `trajectory_integration::tests::inclined_kernel_shear_measures_height_along_the_line_of_sight`
11034    /// pin it on this fixture.
11035    #[test]
11036    fn inclined_solver_shear_measures_height_along_the_line_of_sight() {
11037        let (x, y) = (800.0, 60.0);
11038        for angle in [0.3_f64, -0.3] {
11039            let world_height = crate::atmosphere::shot_frame_altitude(0.0, x, y, angle);
11040            for model in MODELS {
11041                // muzzle_height 0: y = 0 is the bore, taken as 1.5 m up, which is the public
11042                // function's height-relative-to-launch reading.
11043                let bore_frame = ratio_rel_launch(y, model);
11044                // muzzle_height 1.5 m: y = 0 is the ground.
11045                let ground_frame = ratio_rel_launch(y - 1.5, model);
11046                assert!(
11047                    bore_frame > 1.25 && ground_frame > 1.25,
11048                    "{model}: fixture too low"
11049                );
11050                assert!(
11051                    (ratio_rel_launch(world_height, model) - bore_frame).abs() > 0.1,
11052                    "{model}, angle {angle}: the fixture must tell the world height apart"
11053                );
11054                assert_solver_shear_ratio(model, 0.0, angle, Vector3::new(x, y, 0.0), bore_frame);
11055                assert_solver_shear_ratio(model, 1.5, angle, Vector3::new(x, y, 0.0), ground_frame);
11056            }
11057        }
11058    }
11059
11060    fn solve_end(inputs: BallisticInputs, max_range: f64) -> (Vector3<f64>, f64) {
11061        let mut solver = TrajectorySolver::new(
11062            inputs,
11063            WindConditions {
11064                speed: 10.0,
11065                direction: std::f64::consts::FRAC_PI_2,
11066                vertical_speed: 0.0,
11067            },
11068            AtmosphericConditions::default(),
11069        );
11070        solver.set_max_range(max_range);
11071        let result = solver.solve().unwrap();
11072        let peak_y = result
11073            .points
11074            .iter()
11075            .map(|p| p.position.y)
11076            .fold(f64::NEG_INFINITY, f64::max);
11077        (result.points.last().unwrap().position, peak_y)
11078    }
11079
11080    /// A flat shot fired up or down a slope stays within a few metres of the ground running
11081    /// along its line of sight, below the 10 m reference height, so shear on must leave it
11082    /// exactly where shear off does. Read against level ground at the shooter's elevation, the
11083    /// 15° uphill shot climbed into wind nearly 50% stronger by 600 m (155 m up).
11084    #[test]
11085    fn inclined_flat_fire_keeps_the_operative_wind_with_shear_on() {
11086        for shooting_angle in [15.0_f64.to_radians(), (-10.0_f64).to_radians()] {
11087            let inputs = BallisticInputs {
11088                muzzle_height: 1.5,
11089                muzzle_angle: 0.002,
11090                shooting_angle,
11091                bc_type: DragModel::G7,
11092                ..BallisticInputs::default()
11093            };
11094            let (off, peak_y) = solve_end(inputs.clone(), 600.0);
11095            assert!(
11096                peak_y < 8.0 && off.z.abs() > 0.5,
11097                "the fixture must stay low and drift: peak y {peak_y} m, drift {} m",
11098                off.z
11099            );
11100            for model in MODELS {
11101                let (on, _) = solve_end(
11102                    BallisticInputs {
11103                        enable_wind_shear: true,
11104                        wind_shear_model: model.to_string(),
11105                        ..inputs.clone()
11106                    },
11107                    600.0,
11108                );
11109                assert_eq!(
11110                    (on.x.to_bits(), on.y.to_bits(), on.z.to_bits()),
11111                    (off.x.to_bits(), off.y.to_bits(), off.z.to_bits()),
11112                    "{model}, shooting angle {shooting_angle} rad: shear moved a flat shot \
11113                     ({on:?} vs {off:?})"
11114                );
11115            }
11116        }
11117    }
11118
11119    /// `wind_shear_model` `"none"` is no shear, as it is for `fast_integrate`, the RK45 kernel
11120    /// and `get_wind_at_position`, even with `enable_wind_shear` set (the CLI's
11121    /// `--enable-wind-shear --wind-shear-model none`). It used to fall into the unknown-name
11122    /// fallback and apply the power law.
11123    #[test]
11124    fn model_none_applies_no_shear_with_the_flag_set() {
11125        let inputs = BallisticInputs {
11126            muzzle_height: 1.5,
11127            muzzle_angle: 0.12,
11128            bc_type: DragModel::G7,
11129            ..BallisticInputs::default()
11130        };
11131        let (off, peak_y) = solve_end(inputs.clone(), 1_000.0);
11132        assert!(
11133            peak_y > 100.0,
11134            "the fixture must be lofted: peak y {peak_y} m"
11135        );
11136        let with_model = |model: &str| {
11137            solve_end(
11138                BallisticInputs {
11139                    enable_wind_shear: true,
11140                    wind_shear_model: model.to_string(),
11141                    ..inputs.clone()
11142                },
11143                1_000.0,
11144            )
11145            .0
11146        };
11147        let none = with_model("none");
11148        assert_eq!(
11149            (none.x.to_bits(), none.y.to_bits(), none.z.to_bits()),
11150            (off.x.to_bits(), off.y.to_bits(), off.z.to_bits()),
11151            "model none moved the shot: {none:?} vs shear off {off:?}"
11152        );
11153        let power_law = with_model("power_law");
11154        assert!(
11155            (power_law.z - off.z).abs() > 0.05,
11156            "the fixture must show shear: power law {} m vs off {} m",
11157            power_law.z,
11158            off.z
11159        );
11160        // An unrecognized name keeps the historical power-law fallback.
11161        assert_eq!(with_model("typo").z.to_bits(), power_law.z.to_bits());
11162    }
11163}