Skip to main content

ballistics_engine/
wez.rs

1//! WEZ (Weapon Employment Zone) sweep core -- MBA-1317, extracted MBA-1343 Phase B.
2//!
3//! `monte-carlo --wez` reports hit probability vs range for a fixed target size, treating the
4//! shooter's wind-CALL error (how well they estimate the current wind) as a source of dispersion
5//! distinct from the ballistic --wind-std (gust-to-gust physical variability). See the "WEZ" doc
6//! section in CLI_USAGE.md for a worked example.
7//!
8//! Extracted from the CLI binary so non-CLI front ends (e.g. the WASM terminal) can reuse the
9//! exact compute path. All rendering (summary table / statistics CSV / full JSON) stays with the
10//! front ends; this module goes as far as building a [`WezResult`].
11
12use std::error::Error;
13
14use nalgebra::Vector3;
15use serde::Serialize;
16
17use crate::cli_api::UnitSystem;
18use crate::drag::DragTable;
19use crate::perturbation::{central_difference, InputAxis, KernelError};
20use crate::solve_json::{
21    DragModelV1, ResolvedAtmosphereV1, ResolvedConstantWindV1, ResolvedEffectsV1,
22    ResolvedProjectileV1, ResolvedRifleV1, ResolvedSamplingV1, ResolvedShotV1,
23    ResolvedSolveRequestV1, ResolvedSolverV1, ResolvedWindV1, SchemaVersionV1, SolverMethodV1,
24    TwistDirectionV1,
25};
26use crate::{
27    AtmosphericConditions, BallisticInputs, BallisticsError, DragModel, MonteCarloParams,
28    MonteCarloResults, TrajectorySolver, WindConditions,
29};
30
31/// A parsed `--target-size` value, still in the CLI's chosen unit (inches imperial / cm
32/// metric) -- call [`TargetSize::to_metric`] before using it.
33#[derive(Debug, Clone, Copy, PartialEq)]
34pub enum TargetSize {
35    /// Full width (lateral) x height (vertical), e.g. an 18"x30" plate.
36    Rect { width: f64, height: f64 },
37    /// A circular radius, matching `--target-radius`'s existing hit semantics but expressed in
38    /// target-size units instead of range units.
39    Radius(f64),
40}
41
42/// Parse a `--target-size` argument: `WIDTHxHEIGHT` (e.g. `18x30`) for a rectangle, or a bare
43/// number (e.g. `12`) for a circular radius fallback. Case-insensitive on the `x` separator.
44pub fn parse_target_size(spec: &str) -> Result<TargetSize, String> {
45    let trimmed = spec.trim();
46    if trimmed.is_empty() {
47        return Err("expected a size like \"18x30\" or a single radius like \"12\"".to_string());
48    }
49
50    let x_positions: Vec<usize> = trimmed
51        .char_indices()
52        .filter(|(_, c)| *c == 'x' || *c == 'X')
53        .map(|(i, _)| i)
54        .collect();
55
56    match x_positions.len() {
57        0 => {
58            let radius: f64 = trimmed
59                .parse()
60                .map_err(|_| format!("\"{trimmed}\" is not a number or a WIDTHxHEIGHT pair"))?;
61            if !(radius.is_finite() && radius > 0.0) {
62                return Err(format!(
63                    "radius must be a positive, finite number, got {radius}"
64                ));
65            }
66            Ok(TargetSize::Radius(radius))
67        }
68        1 => {
69            let idx = x_positions[0];
70            let width_str = &trimmed[..idx];
71            let height_str = &trimmed[idx + 1..];
72            let width: f64 = width_str
73                .trim()
74                .parse()
75                .map_err(|_| format!("\"{}\" is not a valid width", width_str.trim()))?;
76            let height: f64 = height_str
77                .trim()
78                .parse()
79                .map_err(|_| format!("\"{}\" is not a valid height", height_str.trim()))?;
80            if !(width.is_finite() && width > 0.0 && height.is_finite() && height > 0.0) {
81                return Err(format!(
82                    "width and height must be positive, finite numbers, got {width}x{height}"
83                ));
84            }
85            Ok(TargetSize::Rect { width, height })
86        }
87        _ => Err(format!(
88            "\"{trimmed}\" has more than one 'x' separator; expected WIDTHxHEIGHT or a single radius"
89        )),
90    }
91}
92
93/// A [`TargetSize`] converted to meters, ready for [`MonteCarloResults`]'s
94/// hit-probability methods.
95#[derive(Debug, Clone, Copy, PartialEq)]
96pub enum TargetSizeMetric {
97    Rect { width_m: f64, height_m: f64 },
98    Radius { radius_m: f64 },
99}
100
101/// WEZ target-size length (MBA-1317): inches under imperial, CENTIMETERS (not mm -- target
102/// sizes like an 18"x30" plate are naturally cm-scale under metric) under metric.
103fn target_size_to_metric(val: f64, units: UnitSystem) -> f64 {
104    match units {
105        UnitSystem::Metric => val * 0.01,     // cm to meters
106        UnitSystem::Imperial => val * 0.0254, // inches to meters
107    }
108}
109
110impl TargetSize {
111    pub fn to_metric(self, units: UnitSystem) -> TargetSizeMetric {
112        match self {
113            TargetSize::Rect { width, height } => TargetSizeMetric::Rect {
114                width_m: target_size_to_metric(width, units),
115                height_m: target_size_to_metric(height, units),
116            },
117            TargetSize::Radius(radius) => TargetSizeMetric::Radius {
118                radius_m: target_size_to_metric(radius, units),
119            },
120        }
121    }
122}
123
124/// Which WEZ variance-attribution bucket a miss-variance source belongs to.
125#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
126#[serde(rename_all = "snake_case")]
127pub enum WezErrorBucket {
128    /// The shooter's wind-call error (`--wind-call-error`).
129    WindCall,
130    /// Muzzle-velocity standard deviation (`--velocity-std`).
131    MvSd,
132    /// Everything else: mechanical/ammo group dispersion (angle, azimuth, BC) plus the
133    /// *ballistic* (non-call) share of wind uncertainty (`--wind-std`, `--wind-direction-std`).
134    Other,
135}
136
137impl std::fmt::Display for WezErrorBucket {
138    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
139        // MBA-1337 w2: one spelling everywhere. These must match the serde
140        // rename_all = "snake_case" values the -o full JSON contract shipped with
141        // (0.25.0), so summary/CSV/JSON all agree on the same strings.
142        let label = match self {
143            WezErrorBucket::WindCall => "wind_call",
144            WezErrorBucket::MvSd => "mv_sd",
145            WezErrorBucket::Other => "other",
146        };
147        write!(f, "{label}")
148    }
149}
150
151/// Per-range WEZ miss-variance attribution shares. `wind_call + mv_sd + other` sums to ~1.0
152/// whenever at least one modeled source has nonzero uncertainty; all fields are exactly 0.0 in
153/// the fully deterministic (zero-uncertainty) case, where there is no dominant source.
154#[derive(Debug, Clone, Copy, Default, Serialize)]
155struct WezVarianceShares {
156    wind_call: f64,
157    mv_sd: f64,
158    other: f64,
159}
160
161impl WezVarianceShares {
162    /// The largest nonzero share, or `None` if every share is zero (nothing to attribute).
163    fn dominant(&self) -> Option<WezErrorBucket> {
164        [
165            (WezErrorBucket::WindCall, self.wind_call),
166            (WezErrorBucket::MvSd, self.mv_sd),
167            (WezErrorBucket::Other, self.other),
168        ]
169        .into_iter()
170        .filter(|(_, share)| *share > 0.0)
171        .max_by(|a, b| a.1.total_cmp(&b.1))
172        .map(|(bucket, _)| bucket)
173    }
174}
175
176/// Solve a single deterministic trajectory and return its target-plane impact position.
177///
178/// The caller is responsible for setting `inputs.muzzle_velocity` to whatever value it wants
179/// solved (e.g. baseline + one sigma for the MV-SD sensitivity). The real Monte Carlo sampler
180/// instead applies a sampled velocity *delta* after `TrajectorySolver::new` resolves any
181/// powder-temperature curve (MBA-1176), because `TrajectorySolver` doesn't expose that resolved
182/// value to callers outside `cli_api`. That distinction is a no-op here: `monte-carlo` (and so
183/// `--wez`) never sets `powder_temp_curve` on its `BallisticInputs`, so there is no curve for
184/// `TrajectorySolver::new` to resolve and a plain pre-construction velocity assignment is
185/// equivalent to the sampler's post-construction delta.
186///
187/// Propagates `solve()`'s error instead of unwrapping it: neither the baseline solve (whose
188/// inputs are the CLI's raw, not-yet-validated `-v`/`-a`/etc. values -- clap's own range checks
189/// permit e.g. `-v 0`, which `solve()` rejects) nor a perturbed one-sigma solve (MV/BC/angle/wind
190/// nudged off a valid baseline) is guaranteed to stay within `solve()`'s validity gate (MBA-1317).
191fn wez_solve_target_plane(
192    inputs: BallisticInputs,
193    wind: WindConditions,
194    atmosphere: AtmosphericConditions,
195    solver_max_range: f64,
196    target_distance_m: f64,
197) -> Result<Vector3<f64>, BallisticsError> {
198    let mut solver = TrajectorySolver::new(inputs, wind, atmosphere);
199    solver.set_max_range(solver_max_range);
200    let result = solver.solve()?;
201    // A successful `solve()` always produces a non-empty trajectory (every solve path errors
202    // out on an empty point list before returning `Ok`), so `position_at_range` -- which only
203    // returns `None` for an empty trajectory, clamping to the last point otherwise -- cannot
204    // fail here. See cli_api::TrajectoryResult::position_at_range.
205    Ok(result
206        .position_at_range(target_distance_m)
207        .expect("WEZ attribution solve: non-empty trajectory always has a last point"))
208}
209
210/// Map every built-in engine `DragModel` to its solve-json v1 counterpart.
211///
212/// The exhaustive match deliberately has no wildcard arm: a future engine model must gain an
213/// explicit solve-json representation before WEZ attribution can compile, rather than silently
214/// differentiating under the wrong drag physics.
215fn wez_kernel_drag_model(model: DragModel) -> DragModelV1 {
216    match model {
217        DragModel::G1 => DragModelV1::G1,
218        DragModel::G2 => DragModelV1::G2,
219        DragModel::G5 => DragModelV1::G5,
220        DragModel::G6 => DragModelV1::G6,
221        DragModel::G7 => DragModelV1::G7,
222        DragModel::G8 => DragModelV1::G8,
223        DragModel::GI => DragModelV1::GI,
224        DragModel::GS => DragModelV1::GS,
225        DragModel::RA4 => DragModelV1::RA4,
226    }
227}
228
229/// Build the [`ResolvedSolveRequestV1`] the shared perturbation kernel needs to attribute
230/// variance for one WEZ range step (0.33.0 decision-support D2), mirroring `base_inputs`/
231/// `base_wind`'s already-fully-resolved values field for field -- same units, same defaults --
232/// rather than re-deriving them through solve-json's own defaulting a second time. Every field
233/// `compute_wez` does not expose as a sweep parameter is left at exactly the value
234/// `BallisticInputs::default()`/`WindConditions::default()` already carry, which is also the
235/// value `solve_v1::prepare_request`'s own hardcoded mappings for engine-only fields (powder
236/// sensitivity, tipoff, wind shear, pitch damping, ...) always resolve to regardless of the
237/// request -- so the two paths agree everywhere the kernel has no way to differ, and this
238/// function only needs to carry the fields WEZ actually threads through.
239///
240/// `solver_max_range` is threaded through separately rather than read off `base_inputs` because
241/// it is not a `BallisticInputs` field at all: `compute_wez` derives it per range step
242/// (`range_m.max(1000.0) * 2.0`) and applies it via `TrajectorySolver::set_max_range`, so the
243/// resolved request's `shot.max_range_m` must match that same per-step value for the kernel's
244/// perturbed solves to see the identical trajectory truncation the row's own baseline solve
245/// used.
246///
247/// Returns `None` only for a loaded custom drag table (`base_inputs.custom_drag_table`), which
248/// replaces G-model+BC drag entirely and has no solve-json v1 field. Differentiating under a
249/// built-in fallback model would silently misattribute variance, so custom-deck attribution
250/// remains explicitly unavailable. Every built-in [`DragModel`] is represented exactly by
251/// [`wez_kernel_drag_model`].
252fn wez_resolved_request(
253    base_inputs: &BallisticInputs,
254    base_wind: &WindConditions,
255    solver_max_range: f64,
256) -> Option<ResolvedSolveRequestV1> {
257    if base_inputs.custom_drag_table.is_some() {
258        return None;
259    }
260    let drag_model = wez_kernel_drag_model(base_inputs.bc_type);
261
262    Some(ResolvedSolveRequestV1 {
263        schema_version: SchemaVersionV1,
264        projectile: ResolvedProjectileV1 {
265            mass_kg: base_inputs.bullet_mass,
266            diameter_m: base_inputs.bullet_diameter,
267            length_m: Some(base_inputs.bullet_length),
268            drag_model,
269            ballistic_coefficient: base_inputs.bc_value,
270        },
271        rifle: ResolvedRifleV1 {
272            muzzle_velocity_mps: base_inputs.muzzle_velocity,
273            sight_height_m: base_inputs.sight_height,
274            muzzle_height_m: base_inputs.muzzle_height,
275            // BallisticInputs stores twist rate in inches/turn; solve-json v1 is SI.
276            twist_rate_m_per_turn: base_inputs.twist_rate * 0.0254,
277            twist_direction: if base_inputs.is_twist_right {
278                TwistDirectionV1::Right
279            } else {
280                TwistDirectionV1::Left
281            },
282            sight_offset_lateral_m: Some(base_inputs.sight_offset_lateral_m),
283        },
284        shot: ResolvedShotV1 {
285            max_range_m: solver_max_range,
286            // compute_wez always supplies an explicit muzzle angle directly and never zeroes
287            // (see its own doc comment) -- no re-zero search may run on any perturbed solve.
288            zero_distance_m: None,
289            muzzle_angle_rad: base_inputs.muzzle_angle,
290            aim_azimuth_rad: base_inputs.azimuth_angle,
291            shot_azimuth_rad: base_inputs.shot_azimuth,
292            shooting_angle_rad: base_inputs.shooting_angle,
293            cant_angle_rad: base_inputs.cant_angle,
294            target_height_m: base_inputs.target_height,
295            ground_threshold_m: base_inputs.ground_threshold,
296            zero_poi_up_m: Some(base_inputs.zero_poi_vertical_m),
297            zero_poi_right_m: Some(base_inputs.zero_poi_horizontal_m),
298            drops_reference: None,
299        },
300        atmosphere: ResolvedAtmosphereV1 {
301            altitude_m: base_inputs.altitude,
302            temperature_k: base_inputs.temperature + 273.15,
303            pressure_pa: base_inputs.pressure * 100.0,
304            relative_humidity: base_inputs.humidity,
305            latitude_rad: base_inputs.latitude.map(f64::to_radians),
306            pressure_reference: None,
307        },
308        wind: ResolvedWindV1::Constant(ResolvedConstantWindV1 {
309            speed_mps: base_wind.speed,
310            direction_from_rad: base_wind.direction,
311            vertical_speed_mps: base_wind.vertical_speed,
312            wind_reference: None,
313        }),
314        solver: ResolvedSolverV1 {
315            // BallisticInputs::default() runs adaptive RK45 (use_rk4 && use_adaptive_rk45,
316            // both true, and compute_wez never overrides either), so the kernel's perturbed
317            // solves must use the same method to match the row's own baseline solve.
318            method: SolverMethodV1::Rk45,
319            time_step_s: 0.001, // ignored by RK45; any positive finite value is inert here.
320        },
321        effects: ResolvedEffectsV1 {
322            magnus: false,
323            coriolis: false,
324            enhanced_spin_drift: false,
325        },
326        // Unused by `perturbation::evaluate` (it queries a specific range off the solved
327        // trajectory directly, never the regular sampling grid); any positive value is inert.
328        sampling: ResolvedSamplingV1 { interval_m: 10.0 },
329        reticle: None,
330    })
331}
332
333/// Central-difference WEZ miss-variance attribution at a single range (0.33.0 decision-support
334/// D2), replacing the retired `wez_source_variance`'s one-sided, one-sigma solves with the
335/// shared perturbation kernel's central differences -- see the module doc's opening paragraph
336/// for why the two numerical methods needed to converge onto one.
337///
338/// For each source with standard deviation `sigma` and kernel axis `axis`, the squared
339/// one-sigma displacement is `(d.d_drop_d_x * sigma)^2 + (d.d_windage_d_x * sigma)^2`, where `d`
340/// is [`central_difference`]`(base_resolved, axis, &[target_distance_m], None)`'s single
341/// [`crate::perturbation::Derivative`] -- the same delta-method approximation
342/// `crate::error_budget` uses, applied here to WEZ's three collapsed buckets instead of
343/// `error_budget`'s per-source ranking. Treating the sources as independent gives
344/// `Var(total) ~= sum_i displacement_i^2`, exactly as the retired one-sided version did; only
345/// how each `displacement_i` is measured has changed. A source whose `sigma` is non-positive or
346/// NaN short-circuits to a `0.0` contribution WITHOUT calling the kernel at all (mirroring
347/// `wez_source_variance`'s own guard), so a genuinely-disabled source costs nothing rather than
348/// one wasted pair of solves.
349///
350/// Bucket assignment is unchanged from the retired one-sided version: `MvSd` <- muzzle velocity
351/// ([`InputAxis::MuzzleVelocityMps`]); `WindCall` <- the wind-call error channel
352/// ([`InputAxis::WindSpeed`], `wind_call_error_std_dev`); `Other` <- the accumulated sum of
353/// muzzle angle ([`InputAxis::MuzzleAngle`]), ballistic coefficient
354/// ([`InputAxis::BallisticCoefficient`]), aim azimuth ([`InputAxis::AimAzimuth`] -- WEZ's
355/// `azimuth_angle` is the small horizontal AIMING offset, not the compass-referenced
356/// `InputAxis::ShotAzimuth` Coriolis uses), wind direction ([`InputAxis::WindDirection`]), and
357/// the ballistic (non-call) share of wind speed ([`InputAxis::WindSpeed`] again,
358/// `wind_speed_std_dev`).
359///
360/// `WindSpeed` is the ONE axis two different sources share (`wind_speed_std_dev` into `Other`,
361/// `wind_call_error_std_dev` into `WindCall`), and unlike every other pair of sources here they
362/// are not independently measured: the SAME [`Derivative`](crate::perturbation::Derivative) is
363/// computed at most once (see the code below) and both displacements are scaled off it. A real,
364/// worth-stating consequence (review finding, not part of the original D2 design intent): the
365/// wind-call share and the ballistic-wind PORTION of `Other` are now in an EXACT
366/// `(wind_call_error_std_dev / wind_speed_std_dev)^2` ratio whenever both are positive --
367/// `displacement_call^2 / displacement_wind^2 = (d * sigma_call)^2 / (d * sigma_wind)^2 =
368/// (sigma_call / sigma_wind)^2`, since the shared `d` cancels out of the ratio entirely, leaving
369/// pure sigma-squared algebra with no ballistic response left in it. The retired one-sided
370/// version did NOT have this property: it perturbed `wind.speed` twice, independently, at two
371/// different absolute deltas (`+sigma_call` and `+sigma_wind`), so its two displacements were
372/// two genuinely different finite differences, only APPROXIMATELY proportional to
373/// `sigma^2` in the locally-linear regime -- see
374/// `wind_call_and_ballistic_wind_shares_are_exactly_proportional_to_sigma_squared` in this
375/// module's tests.
376///
377/// Returns `Ok(None)` when ANY of the seven sources hits a structural kernel refusal --
378/// [`KernelError::AxisUnsupportedForRequest`], [`KernelError::AxisAbsent`],
379/// [`KernelError::CategoricalAxis`], or [`KernelError::StepOutOfDomain`], classified via
380/// `crate::error_budget::unavailable_reason` (the same four-way split `error_budget` and
381/// `crate::tolerance::tolerance_envelope` already share) -- rather than silently normalizing
382/// over whichever sources DID evaluate, which would misattribute the missing source's variance
383/// to the other two buckets with no visible sign anything was skipped. `compute_wez` maps this
384/// `None` onto [`WezRow::attribution_unavailable`], its existing "nothing to attribute here"
385/// contract -- the same flag already used when the baseline does not reach this range. In
386/// practice none of the seven axes above carries an `AxisUnsupportedForRequest` guard (those
387/// are `Altitude` under QNH pressure and `ShotAzimuth` under compass wind; [`wez_resolved_request`]
388/// never sets either reference mode) and the resolved wind is always `Constant` (never
389/// `AxisAbsent`'s segmented-wind trigger), so this path is defensive rather than routinely hit --
390/// see the task report for the specific fixtures that DO reach it. Short-circuits on the FIRST
391/// such refusal rather than evaluating the remaining sources, since the row's attribution is
392/// already going to be reported unavailable either way.
393///
394/// # Errors
395/// Propagates any other [`KernelError`] (`Solve`, `Observation`, `TypeMismatch`, `NonFinite`) --
396/// a genuine solver or trajectory failure, not a normal "this input cannot be perturbed here"
397/// fact -- exactly as a failed perturbed solve propagated out of the retired one-sided
398/// `wez_source_variance` (MBA-1317).
399#[allow(
400    clippy::too_many_arguments,
401    reason = "flat sigma list mirrors the Monte Carlo sampler's own parameter set (MBA-1317)"
402)]
403fn wez_variance_shares(
404    base_resolved: &ResolvedSolveRequestV1,
405    target_distance_m: f64,
406    velocity_std_dev: f64,
407    angle_std_dev_rad: f64,
408    bc_std_dev: f64,
409    azimuth_std_dev_rad: f64,
410    wind_speed_std_dev: f64,
411    wind_call_error_std_dev: f64,
412    wind_direction_std_dev_rad: f64,
413) -> Result<Option<WezVarianceShares>, KernelError> {
414    // One source's squared one-sigma displacement from an already-computed derivative, or
415    // `0.0` when `sigma` is non-positive or NaN (that source is disabled) -- mirrors
416    // `wez_source_variance`'s own guard, without needing the kernel call that produced `d` to
417    // know anything about `sigma`.
418    let displacement_sq = |d: &crate::perturbation::Derivative, sigma: f64| -> f64 {
419        if sigma.is_nan() || sigma <= 0.0 {
420            0.0
421        } else {
422            (d.d_drop_d_x * sigma).powi(2) + (d.d_windage_d_x * sigma).powi(2)
423        }
424    };
425
426    // One axis's central-difference derivative, independent of any particular source's sigma.
427    // `Ok(None)` signals a structural refusal the caller must treat as attribution-unavailable
428    // rather than a genuine error (see the doc comment above).
429    let derivative_for =
430        |axis: InputAxis| -> Result<Option<crate::perturbation::Derivative>, KernelError> {
431            match central_difference(base_resolved, axis, &[target_distance_m], None) {
432                Ok(d) => Ok(Some(d[0])),
433                Err(e) if crate::error_budget::unavailable_reason(&e).is_some() => Ok(None),
434                Err(e) => Err(e),
435            }
436        };
437
438    // A single source with sigma `sigma` and kernel axis `axis`: `Ok(Some(0.0))` without
439    // calling the kernel at all when `sigma` is non-positive or NaN (that source is disabled),
440    // so a genuinely-disabled source costs nothing rather than one wasted pair of solves.
441    let contribution = |axis: InputAxis, sigma: f64| -> Result<Option<f64>, KernelError> {
442        if sigma.is_nan() || sigma <= 0.0 {
443            return Ok(Some(0.0));
444        }
445        Ok(derivative_for(axis)?.map(|d| displacement_sq(&d, sigma)))
446    };
447
448    // MV SD bucket: muzzle-velocity dispersion.
449    let Some(mv_sd_var) = contribution(InputAxis::MuzzleVelocityMps, velocity_std_dev)? else {
450        return Ok(None);
451    };
452
453    // Other/group bucket: elevation, aim azimuth, and BC dispersion (mechanical/ammo "group").
454    // The ballistic (non-call) share of wind uncertainty is folded in just below, alongside the
455    // WindCall bucket, since both need the SAME WindSpeed derivative (review finding M1).
456    let mut other_var = 0.0;
457    for (axis, sigma) in [
458        (InputAxis::MuzzleAngle, angle_std_dev_rad),
459        (InputAxis::BallisticCoefficient, bc_std_dev),
460        (InputAxis::AimAzimuth, azimuth_std_dev_rad),
461        (InputAxis::WindDirection, wind_direction_std_dev_rad),
462    ] {
463        let Some(v) = contribution(axis, sigma)? else {
464            return Ok(None);
465        };
466        other_var += v;
467    }
468
469    // Wind speed and wind-call bucket: `wind_speed_std_dev` (ballistic, folded into `other_var`
470    // above) and `wind_call_error_std_dev` (the WindCall bucket) perturb the SAME
471    // `InputAxis::WindSpeed` channel -- kept as separate attribution buckets (the shooter's own
472    // wind-call estimation error vs. physical gust-to-gust variability) but sharing one
473    // derivative computed AT MOST ONCE (review finding M1: this used to call the kernel twice
474    // with identical arguments), only when at least one of the two sigmas is actually active.
475    // See the doc comment above for the exact-proportionality consequence this has.
476    let wind_call_var = if (wind_speed_std_dev.is_nan() || wind_speed_std_dev <= 0.0)
477        && (wind_call_error_std_dev.is_nan() || wind_call_error_std_dev <= 0.0)
478    {
479        0.0
480    } else {
481        let Some(d) = derivative_for(InputAxis::WindSpeed)? else {
482            return Ok(None);
483        };
484        other_var += displacement_sq(&d, wind_speed_std_dev);
485        displacement_sq(&d, wind_call_error_std_dev)
486    };
487
488    let total = wind_call_var + mv_sd_var + other_var;
489    if total.is_nan() || total <= 0.0 {
490        return Ok(Some(WezVarianceShares::default()));
491    }
492    Ok(Some(WezVarianceShares {
493        wind_call: wind_call_var / total,
494        mv_sd: mv_sd_var / total,
495        other: other_var / total,
496    }))
497}
498
499/// WEZ hit probability: the fraction of `results`' samples whose ABSOLUTE target-plane position
500/// -- reconstructed as `baseline + (that sample's deviation from baseline)`, since
501/// [`MonteCarloResults::impact_positions`] stores only the deviation -- falls
502/// within `target_size`, centered on the fixed line of sight (`line_of_sight_height_m` vertically,
503/// `z = 0` laterally).
504///
505/// This is deliberately NOT [`MonteCarloResults::hit_probability`] /
506/// `rect_hit_probability`, which measure the miss distance from that SAME range's own baseline
507/// (i.e. assume the shooter re-dials elevation perfectly for every range). A WEZ sweep instead
508/// answers "how far can I hit this target size with ONE hold", so it must also count the
509/// systematic ballistic drop below the fixed line of sight as a source of misses, not just random
510/// dispersion -- see the module doc comment above `compute_wez`.
511///
512/// A sample that never reached the target plane keeps `MonteCarloResults`'s sentinel deviation
513/// (`TARGET_NOT_REACHED_SENTINEL_M`, roughly -1e9 m). Added to any finite baseline that stays a
514/// miss by a vast margin, so it is correctly excluded here without a separate check.
515fn wez_p_hit(
516    results: &MonteCarloResults,
517    baseline: &Vector3<f64>,
518    line_of_sight_height_m: f64,
519    target_size: TargetSizeMetric,
520) -> f64 {
521    if results.impact_positions.is_empty() {
522        return 0.0;
523    }
524    let hits = results
525        .impact_positions
526        .iter()
527        .filter(|deviation| {
528            let absolute_y = baseline.y + deviation.y;
529            let absolute_z = baseline.z + deviation.z;
530            let drop_from_los = absolute_y - line_of_sight_height_m;
531            match target_size {
532                TargetSizeMetric::Rect { width_m, height_m } => {
533                    drop_from_los.abs() <= height_m / 2.0 && absolute_z.abs() <= width_m / 2.0
534                }
535                TargetSizeMetric::Radius { radius_m } => {
536                    (drop_from_los * drop_from_los + absolute_z * absolute_z).sqrt() <= radius_m
537                }
538            }
539        })
540        .count();
541    hits as f64 / results.impact_positions.len() as f64
542}
543
544/// One range step of a WEZ sweep.
545#[derive(Debug, Clone, Serialize)]
546pub struct WezRow {
547    pub range_m: f64,
548    pub p_hit: f64,
549    pub dominant_error_source: Option<WezErrorBucket>,
550    pub wind_call_share: f64,
551    pub mv_sd_share: f64,
552    pub other_share: f64,
553    /// `*_share` and `dominant_error_source` above are not meaningful (left at their
554    /// zero/`None` default) for any of THREE reasons, and this flag does not distinguish them:
555    /// (1) the undispersed baseline trajectory did not reach this range; (2) central-difference
556    /// attribution (0.33.0 decision-support D2) hit a structural kernel refusal on one of its
557    /// seven sources (`crate::perturbation::KernelError::AxisUnsupportedForRequest`/
558    /// `AxisAbsent`/`CategoricalAxis`/`StepOutOfDomain`); or (3) this configuration cannot be
559    /// represented on the shared kernel's solve-json v1 wire contract at all -- currently only
560    /// a loaded custom drag table (see `wez_resolved_request`). Reason (3) is the one most likely
561    /// to surprise a caller: every row of a `--drag-table` sweep reads `n/a` here, which is NOT a
562    /// claim that the bullet fails to reach that range. All nine built-in reference drag models
563    /// retain attribution. `p_hit` is unaffected in every case -- it comes from the
564    /// fully-dispersed Monte Carlo run directly, never from the kernel.
565    pub attribution_unavailable: bool,
566}
567
568#[derive(Debug, Clone, Serialize)]
569pub struct WezTargetSizeJson {
570    #[serde(skip_serializing_if = "Option::is_none")]
571    pub width_m: Option<f64>,
572    #[serde(skip_serializing_if = "Option::is_none")]
573    pub height_m: Option<f64>,
574    #[serde(skip_serializing_if = "Option::is_none")]
575    pub radius_m: Option<f64>,
576}
577
578#[derive(Debug, Clone, Serialize)]
579pub struct WezResult {
580    pub target_size: WezTargetSizeJson,
581    pub wind_speed_std_mps: f64,
582    pub wind_call_error_mps: f64,
583    /// `sqrt(wind_speed_std_mps^2 + wind_call_error_mps^2)`: the effective wind-speed standard
584    /// deviation actually fed to the underlying Monte Carlo sampler at each range step.
585    pub combined_wind_speed_std_mps: f64,
586    pub num_sims_per_step: usize,
587    pub rows: Vec<WezRow>,
588}
589
590/// Run a WEZ sweep and return its per-range rows plus the sweep-level parameters as a
591/// [`WezResult`], leaving all rendering (summary table / statistics CSV / full JSON) to the
592/// caller. All inputs are metric (the CLI converts from user units before calling).
593///
594/// # Parameters
595///
596/// NOTE the angle-like parameters (`angle`, `cant`, `wind_direction`, `angle_std`,
597/// `wind_direction_std`) are in DEGREES — this function converts to radians itself,
598/// unlike [`BallisticInputs`]/[`WindConditions`] elsewhere in the crate, which carry
599/// radians. This mirrors the CLI flag set the sweep was extracted from (MBA-1317).
600///
601/// * `velocity` — muzzle velocity, m/s.
602/// * `angle` — launch (elevation) angle held for every sweep step, DEGREES.
603/// * `bc` — ballistic coefficient (dimensionless; referenced to `drag_model`).
604/// * `mass` — bullet mass, kg.
605/// * `diameter` — bullet diameter, m.
606/// * `num_sims` — Monte Carlo samples per range step.
607/// * `velocity_std` — muzzle-velocity standard deviation, m/s.
608/// * `angle_std` — elevation-angle standard deviation, DEGREES (the derived
609///   azimuth dispersion is half of it, matching the base Monte Carlo command).
610/// * `bc_std` — BC standard deviation (dimensionless).
611/// * `wind_std` — ballistic (gust-to-gust) wind-speed standard deviation, m/s.
612/// * `wind_direction_std` — wind-direction standard deviation, DEGREES.
613/// * `wind_speed` — base wind speed, m/s.
614/// * `wind_direction` — base wind direction, DEGREES (wind-FROM: 0 = headwind,
615///   90 = from the right).
616/// * `wind_vertical` — base vertical wind, m/s, positive = updraft.
617/// * `wind_call_error` — the shooter's wind-CALL error, m/s; composed with
618///   `wind_std` in quadrature (see [`WezResult::combined_wind_speed_std_mps`]).
619/// * `target_size` — the target box/radius, already in meters
620///   ([`TargetSize::to_metric`]).
621/// * `wez_start` / `wez_end` / `wez_step` — sweep bounds and step, meters
622///   (`wez_end` inclusive).
623/// * `drag_model` — the G-model `bc` is referenced to ([`DragModel::G1`] /
624///   [`DragModel::G7`]); ignored for drag whenever `custom_drag_table` is set.
625/// * `custom_drag_table` — optional Mach-keyed Cd deck replacing the G-model +
626///   BC drag entirely.
627/// * `cd_scale` — whole-curve multiplier on `custom_drag_table`'s interpolated Cd (MBA-1356);
628///   `1.0` = neutral. Inert when `custom_drag_table` is `None`.
629/// * `cant` — rifle cant, DEGREES, positive = clockwise from the shooter.
630/// * `sight_offset_lateral_m` — lateral sight-to-bore mount offset, METERS, positive =
631///   sight right of bore (MBA-1396); displaces every sample's initial lateral position
632///   exactly like the trajectory command. `0.0` = neutral (byte-identical).
633///
634/// A fixed, distinct seed per range step (`0x57_45_5A_00 ^ step_index`) keeps a sweep
635/// reproducible run-to-run while still drawing independent samples at each range.
636#[allow(
637    clippy::too_many_arguments,
638    reason = "flat arguments mirror the stable Monte Carlo CLI command shape (MBA-1317)"
639)]
640pub fn compute_wez(
641    velocity: f64,
642    angle: f64,
643    bc: f64,
644    mass: f64,
645    diameter: f64,
646    num_sims: usize,
647    velocity_std: f64,
648    angle_std: f64,
649    bc_std: f64,
650    wind_std: f64,
651    wind_direction_std: f64,
652    wind_speed: f64,
653    wind_direction: f64,
654    wind_vertical: f64,
655    wind_call_error: f64,
656    target_size: TargetSizeMetric,
657    wez_start: f64,
658    wez_end: f64,
659    wez_step: f64,
660    drag_model: DragModel,
661    custom_drag_table: Option<DragTable>,
662    cd_scale: f64,
663    cant: f64,
664    sight_offset_lateral_m: f64,
665) -> Result<WezResult, Box<dyn Error>> {
666    if !(wez_step > 0.0 && wez_step.is_finite()) {
667        return Err("--wez-step must be a positive, finite distance".into());
668    }
669    if !wez_start.is_finite() || !wez_end.is_finite() || wez_end < wez_start {
670        return Err("--wez-end must be finite and >= --wez-start".into());
671    }
672
673    // Same bore-height/ground convention as the base `monte-carlo` command (MBA-967).
674    let bore_height_metric = 1.5_f64;
675    let base_inputs = BallisticInputs {
676        muzzle_velocity: velocity,
677        muzzle_angle: angle.to_radians(),
678        bc_value: bc,
679        bc_type: drag_model,
680        bullet_mass: mass,
681        bullet_diameter: diameter,
682        muzzle_height: bore_height_metric,
683        ground_threshold: 0.0,
684        custom_drag_table,
685        cd_scale,
686        cant_angle: cant.to_radians(),
687        sight_offset_lateral_m,
688        ..Default::default()
689    };
690    let base_wind = WindConditions {
691        speed: wind_speed,
692        direction: wind_direction.to_radians(),
693        vertical_speed: wind_vertical,
694    };
695
696    // The shooter's wind-call error is a dispersion source distinct from the ballistic
697    // (gust-to-gust) wind-speed uncertainty --wind-std already models, but both perturb the same
698    // physical channel (wind speed fed to the solve). As independent random errors they compose
699    // in quadrature -- not by simple addition -- into the effective standard deviation the
700    // underlying Monte Carlo sampler uses.
701    let combined_wind_speed_std = wind_std.hypot(wind_call_error);
702
703    // Matches run_monte_carlo's own convention: horizontal (azimuth) aim dispersion defaults to
704    // half of the vertical (elevation) dispersion.
705    let angle_std_rad = angle_std.to_radians();
706    let azimuth_std_dev = angle_std_rad * 0.5;
707    let wind_direction_std_rad = wind_direction_std.to_radians();
708
709    // The fixed reference the WEZ target box is centered on: the horizontal line of sight,
710    // extended straight (not the curved bullet path). This is what makes the zero-uncertainty
711    // case a genuine step function -- ballistic drop below this fixed line, not just random
712    // dispersion, can carry the bullet outside the box as range grows. It does NOT change per
713    // range step: a WEZ sweep answers "how far can I engage this target size with ONE hold",
714    // the classic point-blank-range question, not "assuming I re-dial for every range".
715    let atmosphere = AtmosphericConditions {
716        temperature: base_inputs.temperature,
717        pressure: base_inputs.pressure,
718        humidity: base_inputs.humidity_percent(),
719        altitude: base_inputs.altitude,
720    };
721    let line_of_sight_height_m = base_inputs.muzzle_height + base_inputs.sight_height;
722
723    let mut ranges_m = Vec::new();
724    let mut next = wez_start;
725    // Guard against an unbounded loop from a step so small that floating-point addition never
726    // advances `next` past `wez_end`.
727    for _ in 0..100_000 {
728        if next > wez_end + wez_step * 1e-9 {
729            break;
730        }
731        ranges_m.push(next);
732        next += wez_step;
733    }
734
735    let mut rows = Vec::with_capacity(ranges_m.len());
736    for (step_index, &range_m) in ranges_m.iter().enumerate() {
737        let solver_max_range = range_m.max(1000.0) * 2.0;
738        let baseline = wez_solve_target_plane(
739            base_inputs.clone(),
740            base_wind.clone(),
741            atmosphere.clone(),
742            solver_max_range,
743            range_m,
744        )?;
745        let baseline_reached = baseline.x >= range_m - 1e-6;
746
747        let mc_params = MonteCarloParams {
748            num_simulations: num_sims,
749            velocity_std_dev: velocity_std,
750            angle_std_dev: angle_std_rad,
751            bc_std_dev: bc_std,
752            wind_speed_std_dev: combined_wind_speed_std,
753            target_distance: Some(range_m),
754            base_wind_speed: wind_speed,
755            base_wind_direction: wind_direction.to_radians(),
756            azimuth_std_dev,
757        };
758
759        // A fixed, distinct seed per range step keeps a sweep reproducible run-to-run while
760        // still drawing independent samples at each range.
761        let seed = 0x57_45_5A_00_u64 ^ (step_index as u64);
762        let p_hit = match crate::run_monte_carlo_with_wind_and_direction_std_dev_seeded(
763            base_inputs.clone(),
764            base_wind.clone(),
765            mc_params,
766            wind_direction_std_rad,
767            seed,
768        ) {
769            Ok(results) => {
770                wez_p_hit(&results, &baseline, line_of_sight_height_m, target_size)
771            }
772            // The baseline never reached this range plane at all -> every sample is a definite
773            // miss for it.
774            Err(_) => 0.0,
775        };
776
777        let (shares, attribution_unavailable) = if baseline_reached {
778            match wez_resolved_request(&base_inputs, &base_wind, solver_max_range) {
779                Some(resolved) => match wez_variance_shares(
780                    &resolved,
781                    range_m,
782                    velocity_std,
783                    angle_std_rad,
784                    bc_std,
785                    azimuth_std_dev,
786                    wind_std,
787                    wind_call_error,
788                    wind_direction_std_rad,
789                )? {
790                    Some(shares) => (shares, false),
791                    // A structural kernel refusal (see wez_variance_shares's doc) -- treated
792                    // the same as the baseline-not-reached case just below: nothing to
793                    // attribute, but p_hit is unaffected.
794                    None => (WezVarianceShares::default(), true),
795                },
796                // A custom drag table cannot be represented on the solve-json v1 wire contract
797                // at all -- see wez_resolved_request's doc. Every built-in model is represented.
798                None => (WezVarianceShares::default(), true),
799            }
800        } else {
801            (WezVarianceShares::default(), true)
802        };
803
804        rows.push(WezRow {
805            range_m,
806            p_hit,
807            dominant_error_source: shares.dominant(),
808            wind_call_share: shares.wind_call,
809            mv_sd_share: shares.mv_sd,
810            other_share: shares.other,
811            attribution_unavailable,
812        });
813    }
814
815    Ok(WezResult {
816        target_size: match target_size {
817            TargetSizeMetric::Rect { width_m, height_m } => WezTargetSizeJson {
818                width_m: Some(width_m),
819                height_m: Some(height_m),
820                radius_m: None,
821            },
822            TargetSizeMetric::Radius { radius_m } => WezTargetSizeJson {
823                width_m: None,
824                height_m: None,
825                radius_m: Some(radius_m),
826            },
827        },
828        wind_speed_std_mps: wind_std,
829        wind_call_error_mps: wind_call_error,
830        combined_wind_speed_std_mps: combined_wind_speed_std,
831        num_sims_per_step: num_sims,
832        rows,
833    })
834}
835
836#[cfg(test)]
837mod wez_tests {
838    use super::*;
839
840    // A modest .308/168gr load, zeroed at 300 m with a shallow elevation that keeps the
841    // trajectory well above ground for the whole 50-600 m range these tests sweep -- chosen with
842    // `ballistics zero` (see CLI_USAGE.md's WEZ worked example for the imperial equivalent).
843    fn test_base_inputs() -> BallisticInputs {
844        BallisticInputs {
845            muzzle_velocity: 823.0, // ~2700 fps
846            muzzle_angle: 0.001274, // ~0.073 degrees: a 300 m zero for this load
847            bc_value: 0.475,
848            bullet_mass: 0.010_886, // 168 gr
849            bullet_diameter: 0.007_82, // .308 in
850            muzzle_height: 1.5,
851            ground_threshold: 0.0,
852            ..Default::default()
853        }
854    }
855
856    fn test_atmosphere(inputs: &BallisticInputs) -> AtmosphericConditions {
857        AtmosphericConditions {
858            temperature: inputs.temperature,
859            pressure: inputs.pressure,
860            humidity: inputs.humidity_percent(),
861            altitude: inputs.altitude,
862        }
863    }
864
865    #[test]
866    fn every_engine_drag_model_maps_to_its_exact_wire_variant() {
867        for (engine, wire) in [
868            (DragModel::G1, DragModelV1::G1),
869            (DragModel::G2, DragModelV1::G2),
870            (DragModel::G5, DragModelV1::G5),
871            (DragModel::G6, DragModelV1::G6),
872            (DragModel::G7, DragModelV1::G7),
873            (DragModel::G8, DragModelV1::G8),
874            (DragModel::GI, DragModelV1::GI),
875            (DragModel::GS, DragModelV1::GS),
876            (DragModel::RA4, DragModelV1::RA4),
877        ] {
878            assert_eq!(wez_kernel_drag_model(engine), wire);
879        }
880    }
881
882    // ---- parse_target_size --------------------------------------------------------------
883
884    #[test]
885    fn parse_target_size_accepts_a_wxh_rectangle() {
886        assert_eq!(
887            parse_target_size("18x30").unwrap(),
888            TargetSize::Rect {
889                width: 18.0,
890                height: 30.0
891            }
892        );
893        // Case-insensitive separator and surrounding whitespace.
894        assert_eq!(
895            parse_target_size(" 18.5X30.25 ").unwrap(),
896            TargetSize::Rect {
897                width: 18.5,
898                height: 30.25
899            }
900        );
901    }
902
903    #[test]
904    fn parse_target_size_accepts_a_single_radius() {
905        assert_eq!(parse_target_size("12").unwrap(), TargetSize::Radius(12.0));
906        assert_eq!(parse_target_size(" 0.5 ").unwrap(), TargetSize::Radius(0.5));
907    }
908
909    #[test]
910    fn parse_target_size_rejects_garbage() {
911        for bad in [
912            "",
913            "   ",
914            "abc",
915            "18xthirty",
916            "eighteenx30",
917            "18x30x40",
918            "0",
919            "-5",
920            "18x-5",
921            "18x0",
922            "NaN",
923        ] {
924            assert!(
925                parse_target_size(bad).is_err(),
926                "expected an error for {bad:?}"
927            );
928        }
929    }
930
931    // ---- WEZ hit-probability step function -----------------------------------------------
932
933    #[test]
934    fn zero_uncertainty_is_a_step_function_in_range() {
935        let inputs = test_base_inputs();
936        let wind = WindConditions::default();
937        let atmosphere = test_atmosphere(&inputs);
938        // 18x30 box: 0.4572 m x 0.762 m.
939        let target = TargetSizeMetric::Rect {
940            width_m: 0.4572,
941            height_m: 0.762,
942        };
943        let los_height_m = inputs.muzzle_height + inputs.sight_height;
944
945        let mc_params = MonteCarloParams {
946            num_simulations: 20,
947            velocity_std_dev: 0.0,
948            angle_std_dev: 0.0,
949            bc_std_dev: 0.0,
950            wind_speed_std_dev: 0.0,
951            target_distance: None,
952            base_wind_speed: 0.0,
953            base_wind_direction: 0.0,
954            azimuth_std_dev: 0.0,
955        };
956
957        let mut p_hits = Vec::new();
958        for &range_m in &[50.0_f64, 100.0, 150.0, 200.0, 250.0, 300.0, 350.0, 400.0] {
959            let solver_max_range = range_m.max(1000.0) * 2.0;
960            let baseline = wez_solve_target_plane(
961                inputs.clone(),
962                wind.clone(),
963                atmosphere.clone(),
964                solver_max_range,
965                range_m,
966            )
967            .expect("valid test baseline solve");
968            let mut params = mc_params.clone();
969            params.target_distance = Some(range_m);
970            let results = crate::run_monte_carlo_with_wind_and_direction_std_dev_seeded(
971                inputs.clone(),
972                wind.clone(),
973                params,
974                0.0,
975                0xA11CE,
976            )
977            .expect("zero-uncertainty solve");
978            let p_hit = wez_p_hit(&results, &baseline, los_height_m, target);
979            // Every one of the (identical, undispersed) samples must agree: exactly a hit or
980            // exactly a miss, never a fractional probability.
981            assert!(
982                p_hit == 0.0 || p_hit == 1.0,
983                "range {range_m} m: expected a step (0.0 or 1.0), got {p_hit}"
984            );
985            p_hits.push((range_m, p_hit));
986        }
987
988        assert!(
989            p_hits.iter().any(|&(_, p)| p == 1.0),
990            "expected at least one in-box range close to the muzzle: {p_hits:?}"
991        );
992        assert!(
993            p_hits.iter().any(|&(_, p)| p == 0.0),
994            "expected at least one out-of-box range far downrange: {p_hits:?}"
995        );
996        // Once it steps down to a miss, a plain (unheld) trajectory that has already passed its
997        // zero does not come back into a fixed-size box further downrange.
998        let first_miss = p_hits.iter().position(|&(_, p)| p == 0.0);
999        if let Some(idx) = first_miss {
1000            assert!(
1001                p_hits[idx..].iter().all(|&(_, p)| p == 0.0),
1002                "expected the box exit to be permanent for the rest of the sweep: {p_hits:?}"
1003            );
1004        }
1005    }
1006
1007    // ---- P(hit) monotonicity with real dispersion -----------------------------------------
1008
1009    #[test]
1010    fn p_hit_is_monotone_non_increasing_with_range() {
1011        let inputs = test_base_inputs();
1012        let wind = WindConditions::default();
1013        let atmosphere = test_atmosphere(&inputs);
1014        let target = TargetSizeMetric::Rect {
1015            width_m: 0.4572,
1016            height_m: 0.762,
1017        };
1018        let los_height_m = inputs.muzzle_height + inputs.sight_height;
1019        let wind_call_error = 1.5_f64; // m/s
1020        let wind_std = 0.5_f64; // m/s
1021        let combined_wind_std = wind_std.hypot(wind_call_error);
1022
1023        let mc_params = MonteCarloParams {
1024            num_simulations: 500, // a fixed seed keeps this run-to-run deterministic
1025            velocity_std_dev: 1.0,
1026            angle_std_dev: 0.001,
1027            bc_std_dev: 0.01,
1028            wind_speed_std_dev: combined_wind_std,
1029            target_distance: None,
1030            base_wind_speed: 0.0,
1031            base_wind_direction: 0.0,
1032            azimuth_std_dev: 0.0005,
1033        };
1034
1035        let ranges_m = [100.0_f64, 200.0, 300.0, 400.0, 500.0, 600.0];
1036        let mut p_hits = Vec::new();
1037        for (step_index, &range_m) in ranges_m.iter().enumerate() {
1038            let solver_max_range = range_m.max(1000.0) * 2.0;
1039            let baseline = wez_solve_target_plane(
1040                inputs.clone(),
1041                wind.clone(),
1042                atmosphere.clone(),
1043                solver_max_range,
1044                range_m,
1045            )
1046            .expect("valid test baseline solve");
1047            let mut params = mc_params.clone();
1048            params.target_distance = Some(range_m);
1049            let seed = 0x57_45_5A_00_u64 ^ (step_index as u64);
1050            let results = crate::run_monte_carlo_with_wind_and_direction_std_dev_seeded(
1051                inputs.clone(),
1052                wind.clone(),
1053                params,
1054                0.0,
1055                seed,
1056            )
1057            .expect("dispersed solve");
1058            p_hits.push(wez_p_hit(&results, &baseline, los_height_m, target));
1059        }
1060
1061        // A large sample count plus a fixed seed makes this close to the noiseless limit, but a
1062        // finite Monte Carlo estimate can still tick up by a hair at the boundary between two
1063        // adjacent steps -- allow a small generous tolerance rather than asserting exact
1064        // non-increase (MBA-1317 test spec).
1065        let tolerance = 0.03;
1066        for pair in p_hits.windows(2) {
1067            assert!(
1068                pair[1] <= pair[0] + tolerance,
1069                "P(hit) rose more than the allowed jitter: {p_hits:?}"
1070            );
1071        }
1072        // The overall trend across the full sweep must be a clear decline.
1073        assert!(
1074            p_hits.first().unwrap() - p_hits.last().unwrap() > 0.2,
1075            "expected a clear overall decline across the sweep: {p_hits:?}"
1076        );
1077    }
1078
1079    // ---- Variance-attribution shares --------------------------------------------------------
1080
1081    #[test]
1082    fn variance_shares_sum_to_one_when_multiple_sources_are_active() {
1083        let inputs = test_base_inputs();
1084        let wind = WindConditions::default();
1085        let range_m: f64 = 300.0;
1086        let solver_max_range = range_m.max(1000.0) * 2.0;
1087        let resolved = wez_resolved_request(&inputs, &wind, solver_max_range)
1088            .expect("test_base_inputs's default G1 drag model is always kernel-representable");
1089
1090        let shares = wez_variance_shares(
1091            &resolved,
1092            range_m,
1093            /* velocity_std_dev */ 1.0,
1094            /* angle_std_dev_rad */ 0.001,
1095            /* bc_std_dev */ 0.01,
1096            /* azimuth_std_dev_rad */ 0.0005,
1097            /* wind_speed_std_dev */ 0.4,
1098            /* wind_call_error_std_dev */ 1.2,
1099            /* wind_direction_std_dev_rad */ 0.02,
1100        )
1101        .expect("valid test attribution solve")
1102        .expect("attribution must be available for this fixture");
1103
1104        let sum = shares.wind_call + shares.mv_sd + shares.other;
1105        assert!(
1106            (sum - 1.0).abs() < 1e-9,
1107            "shares should sum to ~1.0, got {sum} ({shares:?})"
1108        );
1109        for share in [shares.wind_call, shares.mv_sd, shares.other] {
1110            assert!((0.0..=1.0).contains(&share), "share out of range: {share}");
1111        }
1112        assert!(shares.dominant().is_some());
1113    }
1114
1115    #[test]
1116    fn variance_shares_are_all_zero_with_no_dispersion_sources() {
1117        let inputs = test_base_inputs();
1118        let wind = WindConditions::default();
1119        let range_m: f64 = 300.0;
1120        let solver_max_range = range_m.max(1000.0) * 2.0;
1121        let resolved = wez_resolved_request(&inputs, &wind, solver_max_range)
1122            .expect("test_base_inputs's default G1 drag model is always kernel-representable");
1123
1124        let shares = wez_variance_shares(
1125            &resolved, range_m, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
1126        )
1127        .expect("valid test attribution solve")
1128        .expect("attribution must be available for this fixture");
1129
1130        assert_eq!(shares.wind_call, 0.0);
1131        assert_eq!(shares.mv_sd, 0.0);
1132        assert_eq!(shares.other, 0.0);
1133        assert!(shares.dominant().is_none());
1134    }
1135
1136    #[test]
1137    fn wind_call_bucket_dominates_when_it_is_the_only_active_source() {
1138        let inputs = test_base_inputs();
1139        let wind = WindConditions::default();
1140        let range_m: f64 = 300.0;
1141        let solver_max_range = range_m.max(1000.0) * 2.0;
1142        let resolved = wez_resolved_request(&inputs, &wind, solver_max_range)
1143            .expect("test_base_inputs's default G1 drag model is always kernel-representable");
1144
1145        let shares = wez_variance_shares(
1146            &resolved,
1147            range_m,
1148            0.0,
1149            0.0,
1150            0.0,
1151            0.0,
1152            0.0,
1153            /* wind_call_error_std_dev */ 3.0,
1154            0.0,
1155        )
1156        .expect("valid test attribution solve")
1157        .expect("attribution must be available for this fixture");
1158
1159        assert!((shares.wind_call - 1.0).abs() < 1e-9);
1160        assert_eq!(shares.mv_sd, 0.0);
1161        assert_eq!(shares.other, 0.0);
1162        assert_eq!(shares.dominant(), Some(WezErrorBucket::WindCall));
1163    }
1164
1165    /// Review finding M1: `WindSpeed`'s derivative is now computed AT MOST ONCE and reused for
1166    /// both `wind_speed_std_dev` (folded into `Other`) and `wind_call_error_std_dev`
1167    /// (`WindCall`). With every OTHER source's sigma at zero, `other_share` is ENTIRELY the
1168    /// ballistic wind-speed contribution, so the ratio between the two buckets isolates exactly
1169    /// `(wind_call_error_std_dev / wind_speed_std_dev)^2` -- the shared derivative cancels out
1170    /// of the ratio entirely, leaving pure sigma-squared algebra with no ballistic response left
1171    /// in it. This is a real, stated behavior change from the retired one-sided version, which
1172    /// perturbed `wind.speed` independently at two different absolute deltas (`+sigma_call` and
1173    /// `+sigma_wind`) and so was only approximately proportional to `sigma^2` in the
1174    /// locally-linear regime, not exactly.
1175    #[test]
1176    fn wind_call_and_ballistic_wind_shares_are_exactly_proportional_to_sigma_squared() {
1177        let inputs = test_base_inputs();
1178        let wind = WindConditions::default();
1179        let range_m: f64 = 300.0;
1180        let solver_max_range = range_m.max(1000.0) * 2.0;
1181        let resolved = wez_resolved_request(&inputs, &wind, solver_max_range)
1182            .expect("test_base_inputs's default G1 drag model is always kernel-representable");
1183
1184        let wind_speed_std_dev = 0.4_f64;
1185        let wind_call_error_std_dev = 1.2_f64;
1186        let shares = wez_variance_shares(
1187            &resolved,
1188            range_m,
1189            0.0,
1190            0.0,
1191            0.0,
1192            0.0,
1193            wind_speed_std_dev,
1194            wind_call_error_std_dev,
1195            0.0,
1196        )
1197        .expect("valid test attribution solve")
1198        .expect("attribution must be available for this fixture");
1199
1200        assert!(shares.other > 0.0, "fixture must produce a nonzero ballistic-wind share");
1201        let expected_ratio = (wind_call_error_std_dev / wind_speed_std_dev).powi(2);
1202        let actual_ratio = shares.wind_call / shares.other;
1203        assert!(
1204            (actual_ratio - expected_ratio).abs() < 1e-9,
1205            "expected wind_call/other == (sigma_call/sigma_wind)^2 == {expected_ratio}, got \
1206             {actual_ratio}"
1207        );
1208    }
1209
1210    /// `wez_resolved_request`'s output must describe the SAME physical trajectory
1211    /// `wez_solve_target_plane` computes directly from `BallisticInputs` -- otherwise
1212    /// central-difference attribution would measure sensitivity against a subtly different
1213    /// configuration than the one WEZ actually simulates for `p_hit`. Independent oracle:
1214    /// `TrajectoryObservation::drop_m` is LOS-perpendicular and positive BELOW the line of
1215    /// sight (`line_of_sight_height_m - position.y`, `trajectory_observation.rs`);
1216    /// `wez_solve_target_plane`'s raw `position.y` is world-frame height, so
1217    /// `line_of_sight_height_m - baseline.y` is the independently-derived equivalent.
1218    /// `windage_m` is `position.z` directly (no sign flip, no scale change), so it compares to
1219    /// `baseline.z` with no transform at all.
1220    ///
1221    /// M3 review fix: `cant_angle`, `sight_offset_lateral_m`, and `azimuth_angle` are
1222    /// deliberately nonzero here (the first two are directly user-settable on `monte-carlo
1223    /// --wez`, `--cant`/`--sight-offset`; `azimuth_angle` is not exposed as a baseline there but
1224    /// `wez_resolved_request` must still map it correctly for any `BallisticInputs`). With all
1225    /// three left at `test_base_inputs()`'s shared 0.0 default, a mis-mapping -- e.g.
1226    /// `cant_angle_rad` accidentally written to `shooting_angle_rad`, a DIFFERENT `ResolvedShotV1`
1227    /// field -- would leave every assertion in this file green anyway, since both fields agree
1228    /// at the neutral value; nonzero values are the only way this oracle can actually catch that
1229    /// class of bug.
1230    #[test]
1231    fn resolved_request_matches_wez_solve_target_plane_baseline() {
1232        let wind = WindConditions::default();
1233        let range_m: f64 = 300.0;
1234        let solver_max_range = range_m.max(1000.0) * 2.0;
1235
1236        for (name, model) in [
1237            ("G1", DragModel::G1),
1238            ("G2", DragModel::G2),
1239            ("G5", DragModel::G5),
1240            ("G6", DragModel::G6),
1241            ("G7", DragModel::G7),
1242            ("G8", DragModel::G8),
1243            ("GI", DragModel::GI),
1244            ("GS", DragModel::GS),
1245            ("RA4", DragModel::RA4),
1246        ] {
1247            let mut inputs = test_base_inputs();
1248            inputs.bc_type = model;
1249            inputs.cant_angle = 5.0_f64.to_radians();
1250            inputs.sight_offset_lateral_m = 0.02;
1251            inputs.azimuth_angle = 0.001;
1252            let atmosphere = test_atmosphere(&inputs);
1253
1254            let baseline = wez_solve_target_plane(
1255                inputs.clone(),
1256                wind.clone(),
1257                atmosphere,
1258                solver_max_range,
1259                range_m,
1260            )
1261            .expect("valid test baseline solve");
1262
1263            let resolved = wez_resolved_request(&inputs, &wind, solver_max_range)
1264                .expect("every built-in drag model is kernel-representable");
1265            let req: crate::solve_json::SolveRequestV1 = (&resolved).into();
1266            let obs = crate::perturbation::evaluate(&req, &[range_m]).expect("kernel evaluate");
1267            assert_eq!(obs.len(), 1);
1268
1269            let line_of_sight_height_m = inputs.muzzle_height + inputs.sight_height;
1270            let expected_drop_m = line_of_sight_height_m - baseline.y;
1271            assert!(
1272                (obs[0].drop_m - expected_drop_m).abs() < 1e-6,
1273                "{name}: kernel drop_m {} disagrees with wez_solve_target_plane-derived {} -- \
1274                 wez_resolved_request likely mismaps a field",
1275                obs[0].drop_m,
1276                expected_drop_m
1277            );
1278            assert!(
1279                (obs[0].windage_m - baseline.z).abs() < 1e-6,
1280                "{name}: kernel windage_m {} disagrees with wez_solve_target_plane baseline.z \
1281                 {} -- wez_resolved_request likely mismaps a field",
1282                obs[0].windage_m,
1283                baseline.z
1284            );
1285        }
1286    }
1287
1288    fn assert_builtin_drag_model_attribution_available(model: DragModel) {
1289        let result = compute_wez(
1290            823.0,
1291            0.0,
1292            0.243,
1293            0.0113,
1294            0.00782,
1295            20,
1296            5.0,
1297            0.0001,
1298            0.005,
1299            1.0,
1300            0.05,
1301            3.0,
1302            90.0,
1303            0.0,
1304            1.5,
1305            TargetSizeMetric::Rect { width_m: 0.5, height_m: 0.75 },
1306            300.0,
1307            300.0,
1308            100.0,
1309            model,
1310            None,
1311            1.0,
1312            0.0,
1313            0.0,
1314        )
1315        .expect("compute_wez");
1316        let row = result.rows.first().expect("one row");
1317        assert!(
1318            !row.attribution_unavailable,
1319            "{model} has a solve-json v1 counterpart; attribution must run"
1320        );
1321        assert!(row.p_hit.is_finite(), "p_hit comes from the real Monte Carlo run, unaffected");
1322        for (name, share) in [
1323            ("wind_call", row.wind_call_share),
1324            ("mv_sd", row.mv_sd_share),
1325            ("other", row.other_share),
1326        ] {
1327            assert!(share.is_finite(), "{model} {name} share must be finite");
1328            assert!(
1329                (0.0..=1.0).contains(&share),
1330                "{model} {name} share must be within [0, 1], got {share}"
1331            );
1332        }
1333        let sum = row.wind_call_share + row.mv_sd_share + row.other_share;
1334        assert!(
1335            (sum - 1.0).abs() < 1e-9,
1336            "{model} attribution shares must sum to one, got {sum}"
1337        );
1338    }
1339
1340    /// MBA-1442: every built-in model now has an exact solve-json v1 representation, so the
1341    /// shared perturbation kernel can attribute WEZ variance without changing drag physics.
1342    #[test]
1343    fn g2_attribution_is_available() {
1344        assert_builtin_drag_model_attribution_available(DragModel::G2);
1345    }
1346
1347    #[test]
1348    fn g5_attribution_is_available() {
1349        assert_builtin_drag_model_attribution_available(DragModel::G5);
1350    }
1351
1352    #[test]
1353    fn gi_attribution_is_available() {
1354        assert_builtin_drag_model_attribution_available(DragModel::GI);
1355    }
1356
1357    #[test]
1358    fn gs_attribution_is_available() {
1359        assert_builtin_drag_model_attribution_available(DragModel::GS);
1360    }
1361
1362    #[test]
1363    fn ra4_attribution_is_available() {
1364        assert_builtin_drag_model_attribution_available(DragModel::RA4);
1365    }
1366
1367    /// A loaded custom drag table replaces G-model+BC drag entirely, and solve-json v1 has no
1368    /// field for a custom deck at all -- the kernel cannot represent this configuration, so
1369    /// attribution is unavailable rather than silently computed under the wrong (G-model+BC)
1370    /// physics. Reachable via `--drag-table`/`--cd-scale` on `monte-carlo --wez`.
1371    #[test]
1372    fn attribution_unavailable_with_a_custom_drag_table() {
1373        let table = crate::drag::DragTable::new(
1374            vec![0.0, 0.5, 1.0, 1.5, 2.0, 3.0],
1375            vec![0.5, 0.5, 0.5, 0.5, 0.5, 0.5],
1376        );
1377        let result = compute_wez(
1378            823.0,
1379            0.0,
1380            0.243,
1381            0.0113,
1382            0.00782,
1383            20,
1384            5.0,
1385            0.0001,
1386            0.005,
1387            1.0,
1388            0.05,
1389            3.0,
1390            90.0,
1391            0.0,
1392            1.5,
1393            TargetSizeMetric::Rect { width_m: 0.5, height_m: 0.75 },
1394            300.0,
1395            300.0,
1396            100.0,
1397            DragModel::G7,
1398            Some(table),
1399            1.0,
1400            0.0,
1401            0.0,
1402        )
1403        .expect("compute_wez");
1404        let row = result.rows.first().expect("one row");
1405        assert!(
1406            row.attribution_unavailable,
1407            "a custom drag table has no solve-json v1 representation; attribution cannot run"
1408        );
1409        assert!(row.p_hit.is_finite(), "p_hit comes from the real Monte Carlo run, unaffected");
1410    }
1411
1412    /// Characterization: pins the attribution shares for one fixed configuration.
1413    /// D2 changes these values (one-sided -> central differences). When this test
1414    /// fails during the upgrade, record the before/after in the commit message and
1415    /// update the expected constants -- do not silently re-baseline.
1416    ///
1417    /// Deliberately written against the public compute_wez so the refactor of the
1418    /// private attributor cannot change what this test measures.
1419    ///
1420    /// Range deviation from the task-13 brief's literal worked example: the brief's own
1421    /// numbers (angle 0.0, a single row at 600 m) are not reachable -- a perfectly level
1422    /// (angle = 0) shot from a 1.5 m bore height with the default ground_threshold hits the
1423    /// ground at ~365 m (verified via `trajectory -v 823 -a 0 -b 0.243 -m 11.3 -d 7.82
1424    /// --units metric --bore-height 1500 --max-range 700 -o json` => `"max_range":
1425    /// 364.756..."`), so `attribution_unavailable` is `true` at 600 m for TODAY's code too,
1426    /// before any D2 change. 300 m keeps every other parameter from the brief unchanged and
1427    /// sits comfortably inside that ~365 m ceiling.
1428    #[test]
1429    fn attribution_shares_for_a_fixed_configuration() {
1430        let result = compute_wez(
1431            823.0,      // velocity
1432            0.0,        // angle
1433            0.243,      // bc
1434            0.0113,     // mass
1435            0.00782,    // diameter
1436            20,         // num_sims
1437            5.0,        // velocity_std
1438            0.0001,     // angle_std
1439            0.005,      // bc_std
1440            1.0,        // wind_std
1441            0.05,       // wind_direction_std
1442            3.0,        // wind_speed
1443            90.0,       // wind_direction
1444            0.0,        // wind_vertical
1445            1.5,        // wind_call_error
1446            TargetSizeMetric::Rect { width_m: 0.5, height_m: 0.75 },
1447            300.0,      // wez_start (brief specifies 600.0; unreachable at angle=0, see above)
1448            300.0,      // wez_end
1449            100.0,      // wez_step
1450            DragModel::G7,
1451            None,       // custom_drag_table
1452            1.0,        // cd_scale
1453            0.0,        // cant
1454            0.0,        // sight_offset_lateral_m
1455        ).expect("compute_wez");
1456
1457        let row = result.rows.first().expect("one row");
1458        assert!(!row.attribution_unavailable, "attribution must be available here");
1459        eprintln!("CHARACTERIZATION wind_call={} mv_sd={} other={}",
1460                  row.wind_call_share, row.mv_sd_share, row.other_share);
1461        assert!((row.wind_call_share + row.mv_sd_share + row.other_share - 1.0).abs() < 1e-9);
1462
1463        // AFTER (central differences, D2). BEFORE (one-sided, pre-D2) was:
1464        //   wind_call=0.6820206535206624 mv_sd=0.012457193963072112 other=0.30552215251626547
1465        // Measured gap between the two methods on this fixture: wind_call ~4.91e-4,
1466        // mv_sd ~5.12e-4, other ~2.06e-5 (the tightest of the three). The 1e-6 tolerance below
1467        // is ~20x tighter than that smallest gap, so reverting the central-difference calls in
1468        // wez_variance_shares back to one-sided solves fails this test on EVERY bucket, not
1469        // just the two with a larger gap.
1470        assert!((row.wind_call_share - 0.681_529_624_943_378).abs() < 1e-6,
1471                 "update the characterization constant");
1472        assert!((row.mv_sd_share - 0.012_968_843_319_743_649).abs() < 1e-6,
1473                 "update the characterization constant");
1474        assert!((row.other_share - 0.305_501_531_736_878_2).abs() < 1e-6,
1475                 "update the characterization constant");
1476    }
1477}