Skip to main content

ballistics_engine/
cli_api.rs

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