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        // WEZ rows are rebuilt from BallisticInputs, which carry no table path — the same
331        // pre-existing "no velocity-keyed schedule in the WEZ kernel" limitation as
332        // use_bc_segments itself (scalar bc_value only).
333        corrections: None,
334    })
335}
336
337/// Central-difference WEZ miss-variance attribution at a single range (0.33.0 decision-support
338/// D2), replacing the retired `wez_source_variance`'s one-sided, one-sigma solves with the
339/// shared perturbation kernel's central differences -- see the module doc's opening paragraph
340/// for why the two numerical methods needed to converge onto one.
341///
342/// For each source with standard deviation `sigma` and kernel axis `axis`, the squared
343/// one-sigma displacement is `(d.d_drop_d_x * sigma)^2 + (d.d_windage_d_x * sigma)^2`, where `d`
344/// is [`central_difference`]`(base_resolved, axis, &[target_distance_m], None)`'s single
345/// [`crate::perturbation::Derivative`] -- the same delta-method approximation
346/// `crate::error_budget` uses, applied here to WEZ's three collapsed buckets instead of
347/// `error_budget`'s per-source ranking. Treating the sources as independent gives
348/// `Var(total) ~= sum_i displacement_i^2`, exactly as the retired one-sided version did; only
349/// how each `displacement_i` is measured has changed. A source whose `sigma` is non-positive or
350/// NaN short-circuits to a `0.0` contribution WITHOUT calling the kernel at all (mirroring
351/// `wez_source_variance`'s own guard), so a genuinely-disabled source costs nothing rather than
352/// one wasted pair of solves.
353///
354/// Bucket assignment is unchanged from the retired one-sided version: `MvSd` <- muzzle velocity
355/// ([`InputAxis::MuzzleVelocityMps`]); `WindCall` <- the wind-call error channel
356/// ([`InputAxis::WindSpeed`], `wind_call_error_std_dev`); `Other` <- the accumulated sum of
357/// muzzle angle ([`InputAxis::MuzzleAngle`]), ballistic coefficient
358/// ([`InputAxis::BallisticCoefficient`]), aim azimuth ([`InputAxis::AimAzimuth`] -- WEZ's
359/// `azimuth_angle` is the small horizontal AIMING offset, not the compass-referenced
360/// `InputAxis::ShotAzimuth` Coriolis uses), wind direction ([`InputAxis::WindDirection`]), and
361/// the ballistic (non-call) share of wind speed ([`InputAxis::WindSpeed`] again,
362/// `wind_speed_std_dev`).
363///
364/// `WindSpeed` is the ONE axis two different sources share (`wind_speed_std_dev` into `Other`,
365/// `wind_call_error_std_dev` into `WindCall`), and unlike every other pair of sources here they
366/// are not independently measured: the SAME [`Derivative`](crate::perturbation::Derivative) is
367/// computed at most once (see the code below) and both displacements are scaled off it. A real,
368/// worth-stating consequence (review finding, not part of the original D2 design intent): the
369/// wind-call share and the ballistic-wind PORTION of `Other` are now in an EXACT
370/// `(wind_call_error_std_dev / wind_speed_std_dev)^2` ratio whenever both are positive --
371/// `displacement_call^2 / displacement_wind^2 = (d * sigma_call)^2 / (d * sigma_wind)^2 =
372/// (sigma_call / sigma_wind)^2`, since the shared `d` cancels out of the ratio entirely, leaving
373/// pure sigma-squared algebra with no ballistic response left in it. The retired one-sided
374/// version did NOT have this property: it perturbed `wind.speed` twice, independently, at two
375/// different absolute deltas (`+sigma_call` and `+sigma_wind`), so its two displacements were
376/// two genuinely different finite differences, only APPROXIMATELY proportional to
377/// `sigma^2` in the locally-linear regime -- see
378/// `wind_call_and_ballistic_wind_shares_are_exactly_proportional_to_sigma_squared` in this
379/// module's tests.
380///
381/// Returns `Ok(None)` when ANY of the seven sources hits a structural kernel refusal --
382/// [`KernelError::AxisUnsupportedForRequest`], [`KernelError::AxisAbsent`],
383/// [`KernelError::CategoricalAxis`], or [`KernelError::StepOutOfDomain`], classified via
384/// `crate::error_budget::unavailable_reason` (the same four-way split `error_budget` and
385/// `crate::tolerance::tolerance_envelope` already share) -- rather than silently normalizing
386/// over whichever sources DID evaluate, which would misattribute the missing source's variance
387/// to the other two buckets with no visible sign anything was skipped. `compute_wez` maps this
388/// `None` onto [`WezRow::attribution_unavailable`], its existing "nothing to attribute here"
389/// contract -- the same flag already used when the baseline does not reach this range. In
390/// practice none of the seven axes above carries an `AxisUnsupportedForRequest` guard (those
391/// are `Altitude` under QNH pressure and `ShotAzimuth` under compass wind; [`wez_resolved_request`]
392/// never sets either reference mode) and the resolved wind is always `Constant` (never
393/// `AxisAbsent`'s segmented-wind trigger), so this path is defensive rather than routinely hit --
394/// see the task report for the specific fixtures that DO reach it. Short-circuits on the FIRST
395/// such refusal rather than evaluating the remaining sources, since the row's attribution is
396/// already going to be reported unavailable either way.
397///
398/// # Errors
399/// Propagates any other [`KernelError`] (`Solve`, `Observation`, `TypeMismatch`, `NonFinite`) --
400/// a genuine solver or trajectory failure, not a normal "this input cannot be perturbed here"
401/// fact -- exactly as a failed perturbed solve propagated out of the retired one-sided
402/// `wez_source_variance` (MBA-1317).
403#[allow(
404    clippy::too_many_arguments,
405    reason = "flat sigma list mirrors the Monte Carlo sampler's own parameter set (MBA-1317)"
406)]
407fn wez_variance_shares(
408    base_resolved: &ResolvedSolveRequestV1,
409    target_distance_m: f64,
410    velocity_std_dev: f64,
411    angle_std_dev_rad: f64,
412    bc_std_dev: f64,
413    azimuth_std_dev_rad: f64,
414    wind_speed_std_dev: f64,
415    wind_call_error_std_dev: f64,
416    wind_direction_std_dev_rad: f64,
417) -> Result<Option<WezVarianceShares>, KernelError> {
418    // One source's squared one-sigma displacement from an already-computed derivative, or
419    // `0.0` when `sigma` is non-positive or NaN (that source is disabled) -- mirrors
420    // `wez_source_variance`'s own guard, without needing the kernel call that produced `d` to
421    // know anything about `sigma`.
422    let displacement_sq = |d: &crate::perturbation::Derivative, sigma: f64| -> f64 {
423        if sigma.is_nan() || sigma <= 0.0 {
424            0.0
425        } else {
426            (d.d_drop_d_x * sigma).powi(2) + (d.d_windage_d_x * sigma).powi(2)
427        }
428    };
429
430    // One axis's central-difference derivative, independent of any particular source's sigma.
431    // `Ok(None)` signals a structural refusal the caller must treat as attribution-unavailable
432    // rather than a genuine error (see the doc comment above).
433    let derivative_for =
434        |axis: InputAxis| -> Result<Option<crate::perturbation::Derivative>, KernelError> {
435            match central_difference(base_resolved, axis, &[target_distance_m], None) {
436                Ok(d) => Ok(Some(d[0])),
437                Err(e) if crate::error_budget::unavailable_reason(&e).is_some() => Ok(None),
438                Err(e) => Err(e),
439            }
440        };
441
442    // A single source with sigma `sigma` and kernel axis `axis`: `Ok(Some(0.0))` without
443    // calling the kernel at all when `sigma` is non-positive or NaN (that source is disabled),
444    // so a genuinely-disabled source costs nothing rather than one wasted pair of solves.
445    let contribution = |axis: InputAxis, sigma: f64| -> Result<Option<f64>, KernelError> {
446        if sigma.is_nan() || sigma <= 0.0 {
447            return Ok(Some(0.0));
448        }
449        Ok(derivative_for(axis)?.map(|d| displacement_sq(&d, sigma)))
450    };
451
452    // MV SD bucket: muzzle-velocity dispersion.
453    let Some(mv_sd_var) = contribution(InputAxis::MuzzleVelocityMps, velocity_std_dev)? else {
454        return Ok(None);
455    };
456
457    // Other/group bucket: elevation, aim azimuth, and BC dispersion (mechanical/ammo "group").
458    // The ballistic (non-call) share of wind uncertainty is folded in just below, alongside the
459    // WindCall bucket, since both need the SAME WindSpeed derivative (review finding M1).
460    let mut other_var = 0.0;
461    for (axis, sigma) in [
462        (InputAxis::MuzzleAngle, angle_std_dev_rad),
463        (InputAxis::BallisticCoefficient, bc_std_dev),
464        (InputAxis::AimAzimuth, azimuth_std_dev_rad),
465        (InputAxis::WindDirection, wind_direction_std_dev_rad),
466    ] {
467        let Some(v) = contribution(axis, sigma)? else {
468            return Ok(None);
469        };
470        other_var += v;
471    }
472
473    // Wind speed and wind-call bucket: `wind_speed_std_dev` (ballistic, folded into `other_var`
474    // above) and `wind_call_error_std_dev` (the WindCall bucket) perturb the SAME
475    // `InputAxis::WindSpeed` channel -- kept as separate attribution buckets (the shooter's own
476    // wind-call estimation error vs. physical gust-to-gust variability) but sharing one
477    // derivative computed AT MOST ONCE (review finding M1: this used to call the kernel twice
478    // with identical arguments), only when at least one of the two sigmas is actually active.
479    // See the doc comment above for the exact-proportionality consequence this has.
480    let wind_call_var = if (wind_speed_std_dev.is_nan() || wind_speed_std_dev <= 0.0)
481        && (wind_call_error_std_dev.is_nan() || wind_call_error_std_dev <= 0.0)
482    {
483        0.0
484    } else {
485        let Some(d) = derivative_for(InputAxis::WindSpeed)? else {
486            return Ok(None);
487        };
488        other_var += displacement_sq(&d, wind_speed_std_dev);
489        displacement_sq(&d, wind_call_error_std_dev)
490    };
491
492    let total = wind_call_var + mv_sd_var + other_var;
493    if total.is_nan() || total <= 0.0 {
494        return Ok(Some(WezVarianceShares::default()));
495    }
496    Ok(Some(WezVarianceShares {
497        wind_call: wind_call_var / total,
498        mv_sd: mv_sd_var / total,
499        other: other_var / total,
500    }))
501}
502
503/// WEZ hit probability: the fraction of `results`' samples whose ABSOLUTE target-plane position
504/// -- reconstructed as `baseline + (that sample's deviation from baseline)`, since
505/// [`MonteCarloResults::impact_positions`] stores only the deviation -- falls
506/// within `target_size`, centered on the fixed line of sight (`line_of_sight_height_m` vertically,
507/// `z = 0` laterally).
508///
509/// This is deliberately NOT [`MonteCarloResults::hit_probability`] /
510/// `rect_hit_probability`, which measure the miss distance from that SAME range's own baseline
511/// (i.e. assume the shooter re-dials elevation perfectly for every range). A WEZ sweep instead
512/// answers "how far can I hit this target size with ONE hold", so it must also count the
513/// systematic ballistic drop below the fixed line of sight as a source of misses, not just random
514/// dispersion -- see the module doc comment above `compute_wez`.
515///
516/// A sample that never reached the target plane keeps `MonteCarloResults`'s sentinel deviation
517/// (`TARGET_NOT_REACHED_SENTINEL_M`, roughly -1e9 m). Added to any finite baseline that stays a
518/// miss by a vast margin, so it is correctly excluded here without a separate check.
519fn wez_p_hit(
520    results: &MonteCarloResults,
521    baseline: &Vector3<f64>,
522    line_of_sight_height_m: f64,
523    target_size: TargetSizeMetric,
524) -> f64 {
525    if results.impact_positions.is_empty() {
526        return 0.0;
527    }
528    let hits = results
529        .impact_positions
530        .iter()
531        .filter(|deviation| {
532            let absolute_y = baseline.y + deviation.y;
533            let absolute_z = baseline.z + deviation.z;
534            let drop_from_los = absolute_y - line_of_sight_height_m;
535            match target_size {
536                TargetSizeMetric::Rect { width_m, height_m } => {
537                    drop_from_los.abs() <= height_m / 2.0 && absolute_z.abs() <= width_m / 2.0
538                }
539                TargetSizeMetric::Radius { radius_m } => {
540                    (drop_from_los * drop_from_los + absolute_z * absolute_z).sqrt() <= radius_m
541                }
542            }
543        })
544        .count();
545    hits as f64 / results.impact_positions.len() as f64
546}
547
548/// One range step of a WEZ sweep.
549#[derive(Debug, Clone, Serialize)]
550pub struct WezRow {
551    pub range_m: f64,
552    pub p_hit: f64,
553    pub dominant_error_source: Option<WezErrorBucket>,
554    pub wind_call_share: f64,
555    pub mv_sd_share: f64,
556    pub other_share: f64,
557    /// `*_share` and `dominant_error_source` above are not meaningful (left at their
558    /// zero/`None` default) for any of THREE reasons, and this flag does not distinguish them:
559    /// (1) the undispersed baseline trajectory did not reach this range; (2) central-difference
560    /// attribution (0.33.0 decision-support D2) hit a structural kernel refusal on one of its
561    /// seven sources (`crate::perturbation::KernelError::AxisUnsupportedForRequest`/
562    /// `AxisAbsent`/`CategoricalAxis`/`StepOutOfDomain`); or (3) this configuration cannot be
563    /// represented on the shared kernel's solve-json v1 wire contract at all -- currently only
564    /// a loaded custom drag table (see `wez_resolved_request`). Reason (3) is the one most likely
565    /// to surprise a caller: every row of a `--drag-table` sweep reads `n/a` here, which is NOT a
566    /// claim that the bullet fails to reach that range. All nine built-in reference drag models
567    /// retain attribution. `p_hit` is unaffected in every case -- it comes from the
568    /// fully-dispersed Monte Carlo run directly, never from the kernel.
569    pub attribution_unavailable: bool,
570}
571
572#[derive(Debug, Clone, Serialize)]
573pub struct WezTargetSizeJson {
574    #[serde(skip_serializing_if = "Option::is_none")]
575    pub width_m: Option<f64>,
576    #[serde(skip_serializing_if = "Option::is_none")]
577    pub height_m: Option<f64>,
578    #[serde(skip_serializing_if = "Option::is_none")]
579    pub radius_m: Option<f64>,
580}
581
582#[derive(Debug, Clone, Serialize)]
583pub struct WezResult {
584    pub target_size: WezTargetSizeJson,
585    pub wind_speed_std_mps: f64,
586    pub wind_call_error_mps: f64,
587    /// `sqrt(wind_speed_std_mps^2 + wind_call_error_mps^2)`: the effective wind-speed standard
588    /// deviation actually fed to the underlying Monte Carlo sampler at each range step.
589    pub combined_wind_speed_std_mps: f64,
590    pub num_sims_per_step: usize,
591    pub rows: Vec<WezRow>,
592}
593
594/// Run a WEZ sweep and return its per-range rows plus the sweep-level parameters as a
595/// [`WezResult`], leaving all rendering (summary table / statistics CSV / full JSON) to the
596/// caller. All inputs are metric (the CLI converts from user units before calling).
597///
598/// # Parameters
599///
600/// NOTE the angle-like parameters (`angle`, `cant`, `wind_direction`, `angle_std`,
601/// `wind_direction_std`) are in DEGREES — this function converts to radians itself,
602/// unlike [`BallisticInputs`]/[`WindConditions`] elsewhere in the crate, which carry
603/// radians. This mirrors the CLI flag set the sweep was extracted from (MBA-1317).
604///
605/// * `velocity` — muzzle velocity, m/s.
606/// * `angle` — launch (elevation) angle held for every sweep step, DEGREES.
607/// * `bc` — ballistic coefficient (dimensionless; referenced to `drag_model`).
608/// * `mass` — bullet mass, kg.
609/// * `diameter` — bullet diameter, m.
610/// * `num_sims` — Monte Carlo samples per range step.
611/// * `velocity_std` — muzzle-velocity standard deviation, m/s.
612/// * `angle_std` — elevation-angle standard deviation, DEGREES (the derived
613///   azimuth dispersion is half of it, matching the base Monte Carlo command).
614/// * `bc_std` — BC standard deviation (dimensionless).
615/// * `wind_std` — ballistic (gust-to-gust) wind-speed standard deviation, m/s.
616/// * `wind_direction_std` — wind-direction standard deviation, DEGREES.
617/// * `wind_speed` — base wind speed, m/s.
618/// * `wind_direction` — base wind direction, DEGREES (wind-FROM: 0 = headwind,
619///   90 = from the right).
620/// * `wind_vertical` — base vertical wind, m/s, positive = updraft.
621/// * `wind_call_error` — the shooter's wind-CALL error, m/s; composed with
622///   `wind_std` in quadrature (see [`WezResult::combined_wind_speed_std_mps`]).
623/// * `target_size` — the target box/radius, already in meters
624///   ([`TargetSize::to_metric`]).
625/// * `wez_start` / `wez_end` / `wez_step` — sweep bounds and step, meters
626///   (`wez_end` inclusive).
627/// * `drag_model` — the G-model `bc` is referenced to ([`DragModel::G1`] /
628///   [`DragModel::G7`]); ignored for drag whenever `custom_drag_table` is set.
629/// * `custom_drag_table` — optional Mach-keyed Cd deck replacing the G-model +
630///   BC drag entirely.
631/// * `cd_scale` — whole-curve multiplier on `custom_drag_table`'s interpolated Cd (MBA-1356);
632///   `1.0` = neutral. Inert when `custom_drag_table` is `None`.
633/// * `cant` — rifle cant, DEGREES, positive = clockwise from the shooter.
634/// * `sight_offset_lateral_m` — lateral sight-to-bore mount offset, METERS, positive =
635///   sight right of bore (MBA-1396); displaces every sample's initial lateral position
636///   exactly like the trajectory command. `0.0` = neutral (byte-identical).
637///
638/// A fixed, distinct seed per range step (`0x57_45_5A_00 ^ step_index`) keeps a sweep
639/// reproducible run-to-run while still drawing independent samples at each range.
640#[allow(
641    clippy::too_many_arguments,
642    reason = "flat arguments mirror the stable Monte Carlo CLI command shape (MBA-1317)"
643)]
644pub fn compute_wez(
645    velocity: f64,
646    angle: f64,
647    bc: f64,
648    mass: f64,
649    diameter: f64,
650    num_sims: usize,
651    velocity_std: f64,
652    angle_std: f64,
653    bc_std: f64,
654    wind_std: f64,
655    wind_direction_std: f64,
656    wind_speed: f64,
657    wind_direction: f64,
658    wind_vertical: f64,
659    wind_call_error: f64,
660    target_size: TargetSizeMetric,
661    wez_start: f64,
662    wez_end: f64,
663    wez_step: f64,
664    drag_model: DragModel,
665    custom_drag_table: Option<DragTable>,
666    cd_scale: f64,
667    cant: f64,
668    sight_offset_lateral_m: f64,
669) -> Result<WezResult, Box<dyn Error>> {
670    if !(wez_step > 0.0 && wez_step.is_finite()) {
671        return Err("--wez-step must be a positive, finite distance".into());
672    }
673    if !wez_start.is_finite() || !wez_end.is_finite() || wez_end < wez_start {
674        return Err("--wez-end must be finite and >= --wez-start".into());
675    }
676
677    // Same bore-height/ground convention as the base `monte-carlo` command (MBA-967).
678    let bore_height_metric = 1.5_f64;
679    let base_inputs = BallisticInputs {
680        muzzle_velocity: velocity,
681        muzzle_angle: angle.to_radians(),
682        bc_value: bc,
683        bc_type: drag_model,
684        bullet_mass: mass,
685        bullet_diameter: diameter,
686        muzzle_height: bore_height_metric,
687        ground_threshold: 0.0,
688        custom_drag_table,
689        cd_scale,
690        cant_angle: cant.to_radians(),
691        sight_offset_lateral_m,
692        ..Default::default()
693    };
694    let base_wind = WindConditions {
695        speed: wind_speed,
696        direction: wind_direction.to_radians(),
697        vertical_speed: wind_vertical,
698    };
699
700    // The shooter's wind-call error is a dispersion source distinct from the ballistic
701    // (gust-to-gust) wind-speed uncertainty --wind-std already models, but both perturb the same
702    // physical channel (wind speed fed to the solve). As independent random errors they compose
703    // in quadrature -- not by simple addition -- into the effective standard deviation the
704    // underlying Monte Carlo sampler uses.
705    let combined_wind_speed_std = wind_std.hypot(wind_call_error);
706
707    // Matches run_monte_carlo's own convention: horizontal (azimuth) aim dispersion defaults to
708    // half of the vertical (elevation) dispersion.
709    let angle_std_rad = angle_std.to_radians();
710    let azimuth_std_dev = angle_std_rad * 0.5;
711    let wind_direction_std_rad = wind_direction_std.to_radians();
712
713    // The fixed reference the WEZ target box is centered on: the horizontal line of sight,
714    // extended straight (not the curved bullet path). This is what makes the zero-uncertainty
715    // case a genuine step function -- ballistic drop below this fixed line, not just random
716    // dispersion, can carry the bullet outside the box as range grows. It does NOT change per
717    // range step: a WEZ sweep answers "how far can I engage this target size with ONE hold",
718    // the classic point-blank-range question, not "assuming I re-dial for every range".
719    let atmosphere = AtmosphericConditions {
720        temperature: base_inputs.temperature,
721        pressure: base_inputs.pressure,
722        humidity: base_inputs.humidity_percent(),
723        altitude: base_inputs.altitude,
724    };
725    let line_of_sight_height_m = base_inputs.muzzle_height + base_inputs.sight_height;
726
727    let mut ranges_m = Vec::new();
728    let mut next = wez_start;
729    // Guard against an unbounded loop from a step so small that floating-point addition never
730    // advances `next` past `wez_end`.
731    for _ in 0..100_000 {
732        if next > wez_end + wez_step * 1e-9 {
733            break;
734        }
735        ranges_m.push(next);
736        next += wez_step;
737    }
738
739    let mut rows = Vec::with_capacity(ranges_m.len());
740    for (step_index, &range_m) in ranges_m.iter().enumerate() {
741        let solver_max_range = range_m.max(1000.0) * 2.0;
742        let baseline = wez_solve_target_plane(
743            base_inputs.clone(),
744            base_wind.clone(),
745            atmosphere.clone(),
746            solver_max_range,
747            range_m,
748        )?;
749        let baseline_reached = baseline.x >= range_m - 1e-6;
750
751        let mc_params = MonteCarloParams {
752            num_simulations: num_sims,
753            velocity_std_dev: velocity_std,
754            angle_std_dev: angle_std_rad,
755            bc_std_dev: bc_std,
756            wind_speed_std_dev: combined_wind_speed_std,
757            target_distance: Some(range_m),
758            base_wind_speed: wind_speed,
759            base_wind_direction: wind_direction.to_radians(),
760            azimuth_std_dev,
761        };
762
763        // A fixed, distinct seed per range step keeps a sweep reproducible run-to-run while
764        // still drawing independent samples at each range.
765        let seed = 0x57_45_5A_00_u64 ^ (step_index as u64);
766        let p_hit = match crate::run_monte_carlo_with_wind_and_direction_std_dev_seeded(
767            base_inputs.clone(),
768            base_wind.clone(),
769            mc_params,
770            wind_direction_std_rad,
771            seed,
772        ) {
773            Ok(results) => {
774                wez_p_hit(&results, &baseline, line_of_sight_height_m, target_size)
775            }
776            // The baseline never reached this range plane at all -> every sample is a definite
777            // miss for it.
778            Err(_) => 0.0,
779        };
780
781        let (shares, attribution_unavailable) = if baseline_reached {
782            match wez_resolved_request(&base_inputs, &base_wind, solver_max_range) {
783                Some(resolved) => match wez_variance_shares(
784                    &resolved,
785                    range_m,
786                    velocity_std,
787                    angle_std_rad,
788                    bc_std,
789                    azimuth_std_dev,
790                    wind_std,
791                    wind_call_error,
792                    wind_direction_std_rad,
793                )? {
794                    Some(shares) => (shares, false),
795                    // A structural kernel refusal (see wez_variance_shares's doc) -- treated
796                    // the same as the baseline-not-reached case just below: nothing to
797                    // attribute, but p_hit is unaffected.
798                    None => (WezVarianceShares::default(), true),
799                },
800                // A custom drag table cannot be represented on the solve-json v1 wire contract
801                // at all -- see wez_resolved_request's doc. Every built-in model is represented.
802                None => (WezVarianceShares::default(), true),
803            }
804        } else {
805            (WezVarianceShares::default(), true)
806        };
807
808        rows.push(WezRow {
809            range_m,
810            p_hit,
811            dominant_error_source: shares.dominant(),
812            wind_call_share: shares.wind_call,
813            mv_sd_share: shares.mv_sd,
814            other_share: shares.other,
815            attribution_unavailable,
816        });
817    }
818
819    Ok(WezResult {
820        target_size: match target_size {
821            TargetSizeMetric::Rect { width_m, height_m } => WezTargetSizeJson {
822                width_m: Some(width_m),
823                height_m: Some(height_m),
824                radius_m: None,
825            },
826            TargetSizeMetric::Radius { radius_m } => WezTargetSizeJson {
827                width_m: None,
828                height_m: None,
829                radius_m: Some(radius_m),
830            },
831        },
832        wind_speed_std_mps: wind_std,
833        wind_call_error_mps: wind_call_error,
834        combined_wind_speed_std_mps: combined_wind_speed_std,
835        num_sims_per_step: num_sims,
836        rows,
837    })
838}
839
840#[cfg(test)]
841mod wez_tests {
842    use super::*;
843
844    // A modest .308/168gr load, zeroed at 300 m with a shallow elevation that keeps the
845    // trajectory well above ground for the whole 50-600 m range these tests sweep -- chosen with
846    // `ballistics zero` (see CLI_USAGE.md's WEZ worked example for the imperial equivalent).
847    fn test_base_inputs() -> BallisticInputs {
848        BallisticInputs {
849            muzzle_velocity: 823.0, // ~2700 fps
850            muzzle_angle: 0.001274, // ~0.073 degrees: a 300 m zero for this load
851            bc_value: 0.475,
852            bullet_mass: 0.010_886, // 168 gr
853            bullet_diameter: 0.007_82, // .308 in
854            muzzle_height: 1.5,
855            ground_threshold: 0.0,
856            ..Default::default()
857        }
858    }
859
860    fn test_atmosphere(inputs: &BallisticInputs) -> AtmosphericConditions {
861        AtmosphericConditions {
862            temperature: inputs.temperature,
863            pressure: inputs.pressure,
864            humidity: inputs.humidity_percent(),
865            altitude: inputs.altitude,
866        }
867    }
868
869    #[test]
870    fn every_engine_drag_model_maps_to_its_exact_wire_variant() {
871        for (engine, wire) in [
872            (DragModel::G1, DragModelV1::G1),
873            (DragModel::G2, DragModelV1::G2),
874            (DragModel::G5, DragModelV1::G5),
875            (DragModel::G6, DragModelV1::G6),
876            (DragModel::G7, DragModelV1::G7),
877            (DragModel::G8, DragModelV1::G8),
878            (DragModel::GI, DragModelV1::GI),
879            (DragModel::GS, DragModelV1::GS),
880            (DragModel::RA4, DragModelV1::RA4),
881        ] {
882            assert_eq!(wez_kernel_drag_model(engine), wire);
883        }
884    }
885
886    // ---- parse_target_size --------------------------------------------------------------
887
888    #[test]
889    fn parse_target_size_accepts_a_wxh_rectangle() {
890        assert_eq!(
891            parse_target_size("18x30").unwrap(),
892            TargetSize::Rect {
893                width: 18.0,
894                height: 30.0
895            }
896        );
897        // Case-insensitive separator and surrounding whitespace.
898        assert_eq!(
899            parse_target_size(" 18.5X30.25 ").unwrap(),
900            TargetSize::Rect {
901                width: 18.5,
902                height: 30.25
903            }
904        );
905    }
906
907    #[test]
908    fn parse_target_size_accepts_a_single_radius() {
909        assert_eq!(parse_target_size("12").unwrap(), TargetSize::Radius(12.0));
910        assert_eq!(parse_target_size(" 0.5 ").unwrap(), TargetSize::Radius(0.5));
911    }
912
913    #[test]
914    fn parse_target_size_rejects_garbage() {
915        for bad in [
916            "",
917            "   ",
918            "abc",
919            "18xthirty",
920            "eighteenx30",
921            "18x30x40",
922            "0",
923            "-5",
924            "18x-5",
925            "18x0",
926            "NaN",
927        ] {
928            assert!(
929                parse_target_size(bad).is_err(),
930                "expected an error for {bad:?}"
931            );
932        }
933    }
934
935    // ---- WEZ hit-probability step function -----------------------------------------------
936
937    #[test]
938    fn zero_uncertainty_is_a_step_function_in_range() {
939        let inputs = test_base_inputs();
940        let wind = WindConditions::default();
941        let atmosphere = test_atmosphere(&inputs);
942        // 18x30 box: 0.4572 m x 0.762 m.
943        let target = TargetSizeMetric::Rect {
944            width_m: 0.4572,
945            height_m: 0.762,
946        };
947        let los_height_m = inputs.muzzle_height + inputs.sight_height;
948
949        let mc_params = MonteCarloParams {
950            num_simulations: 20,
951            velocity_std_dev: 0.0,
952            angle_std_dev: 0.0,
953            bc_std_dev: 0.0,
954            wind_speed_std_dev: 0.0,
955            target_distance: None,
956            base_wind_speed: 0.0,
957            base_wind_direction: 0.0,
958            azimuth_std_dev: 0.0,
959        };
960
961        let mut p_hits = Vec::new();
962        for &range_m in &[50.0_f64, 100.0, 150.0, 200.0, 250.0, 300.0, 350.0, 400.0] {
963            let solver_max_range = range_m.max(1000.0) * 2.0;
964            let baseline = wez_solve_target_plane(
965                inputs.clone(),
966                wind.clone(),
967                atmosphere.clone(),
968                solver_max_range,
969                range_m,
970            )
971            .expect("valid test baseline solve");
972            let mut params = mc_params.clone();
973            params.target_distance = Some(range_m);
974            let results = crate::run_monte_carlo_with_wind_and_direction_std_dev_seeded(
975                inputs.clone(),
976                wind.clone(),
977                params,
978                0.0,
979                0xA11CE,
980            )
981            .expect("zero-uncertainty solve");
982            let p_hit = wez_p_hit(&results, &baseline, los_height_m, target);
983            // Every one of the (identical, undispersed) samples must agree: exactly a hit or
984            // exactly a miss, never a fractional probability.
985            assert!(
986                p_hit == 0.0 || p_hit == 1.0,
987                "range {range_m} m: expected a step (0.0 or 1.0), got {p_hit}"
988            );
989            p_hits.push((range_m, p_hit));
990        }
991
992        assert!(
993            p_hits.iter().any(|&(_, p)| p == 1.0),
994            "expected at least one in-box range close to the muzzle: {p_hits:?}"
995        );
996        assert!(
997            p_hits.iter().any(|&(_, p)| p == 0.0),
998            "expected at least one out-of-box range far downrange: {p_hits:?}"
999        );
1000        // Once it steps down to a miss, a plain (unheld) trajectory that has already passed its
1001        // zero does not come back into a fixed-size box further downrange.
1002        let first_miss = p_hits.iter().position(|&(_, p)| p == 0.0);
1003        if let Some(idx) = first_miss {
1004            assert!(
1005                p_hits[idx..].iter().all(|&(_, p)| p == 0.0),
1006                "expected the box exit to be permanent for the rest of the sweep: {p_hits:?}"
1007            );
1008        }
1009    }
1010
1011    // ---- P(hit) monotonicity with real dispersion -----------------------------------------
1012
1013    #[test]
1014    fn p_hit_is_monotone_non_increasing_with_range() {
1015        let inputs = test_base_inputs();
1016        let wind = WindConditions::default();
1017        let atmosphere = test_atmosphere(&inputs);
1018        let target = TargetSizeMetric::Rect {
1019            width_m: 0.4572,
1020            height_m: 0.762,
1021        };
1022        let los_height_m = inputs.muzzle_height + inputs.sight_height;
1023        let wind_call_error = 1.5_f64; // m/s
1024        let wind_std = 0.5_f64; // m/s
1025        let combined_wind_std = wind_std.hypot(wind_call_error);
1026
1027        let mc_params = MonteCarloParams {
1028            num_simulations: 500, // a fixed seed keeps this run-to-run deterministic
1029            velocity_std_dev: 1.0,
1030            angle_std_dev: 0.001,
1031            bc_std_dev: 0.01,
1032            wind_speed_std_dev: combined_wind_std,
1033            target_distance: None,
1034            base_wind_speed: 0.0,
1035            base_wind_direction: 0.0,
1036            azimuth_std_dev: 0.0005,
1037        };
1038
1039        let ranges_m = [100.0_f64, 200.0, 300.0, 400.0, 500.0, 600.0];
1040        let mut p_hits = Vec::new();
1041        for (step_index, &range_m) in ranges_m.iter().enumerate() {
1042            let solver_max_range = range_m.max(1000.0) * 2.0;
1043            let baseline = wez_solve_target_plane(
1044                inputs.clone(),
1045                wind.clone(),
1046                atmosphere.clone(),
1047                solver_max_range,
1048                range_m,
1049            )
1050            .expect("valid test baseline solve");
1051            let mut params = mc_params.clone();
1052            params.target_distance = Some(range_m);
1053            let seed = 0x57_45_5A_00_u64 ^ (step_index as u64);
1054            let results = crate::run_monte_carlo_with_wind_and_direction_std_dev_seeded(
1055                inputs.clone(),
1056                wind.clone(),
1057                params,
1058                0.0,
1059                seed,
1060            )
1061            .expect("dispersed solve");
1062            p_hits.push(wez_p_hit(&results, &baseline, los_height_m, target));
1063        }
1064
1065        // A large sample count plus a fixed seed makes this close to the noiseless limit, but a
1066        // finite Monte Carlo estimate can still tick up by a hair at the boundary between two
1067        // adjacent steps -- allow a small generous tolerance rather than asserting exact
1068        // non-increase (MBA-1317 test spec).
1069        let tolerance = 0.03;
1070        for pair in p_hits.windows(2) {
1071            assert!(
1072                pair[1] <= pair[0] + tolerance,
1073                "P(hit) rose more than the allowed jitter: {p_hits:?}"
1074            );
1075        }
1076        // The overall trend across the full sweep must be a clear decline.
1077        assert!(
1078            p_hits.first().unwrap() - p_hits.last().unwrap() > 0.2,
1079            "expected a clear overall decline across the sweep: {p_hits:?}"
1080        );
1081    }
1082
1083    // ---- Variance-attribution shares --------------------------------------------------------
1084
1085    #[test]
1086    fn variance_shares_sum_to_one_when_multiple_sources_are_active() {
1087        let inputs = test_base_inputs();
1088        let wind = WindConditions::default();
1089        let range_m: f64 = 300.0;
1090        let solver_max_range = range_m.max(1000.0) * 2.0;
1091        let resolved = wez_resolved_request(&inputs, &wind, solver_max_range)
1092            .expect("test_base_inputs's default G1 drag model is always kernel-representable");
1093
1094        let shares = wez_variance_shares(
1095            &resolved,
1096            range_m,
1097            /* velocity_std_dev */ 1.0,
1098            /* angle_std_dev_rad */ 0.001,
1099            /* bc_std_dev */ 0.01,
1100            /* azimuth_std_dev_rad */ 0.0005,
1101            /* wind_speed_std_dev */ 0.4,
1102            /* wind_call_error_std_dev */ 1.2,
1103            /* wind_direction_std_dev_rad */ 0.02,
1104        )
1105        .expect("valid test attribution solve")
1106        .expect("attribution must be available for this fixture");
1107
1108        let sum = shares.wind_call + shares.mv_sd + shares.other;
1109        assert!(
1110            (sum - 1.0).abs() < 1e-9,
1111            "shares should sum to ~1.0, got {sum} ({shares:?})"
1112        );
1113        for share in [shares.wind_call, shares.mv_sd, shares.other] {
1114            assert!((0.0..=1.0).contains(&share), "share out of range: {share}");
1115        }
1116        assert!(shares.dominant().is_some());
1117    }
1118
1119    #[test]
1120    fn variance_shares_are_all_zero_with_no_dispersion_sources() {
1121        let inputs = test_base_inputs();
1122        let wind = WindConditions::default();
1123        let range_m: f64 = 300.0;
1124        let solver_max_range = range_m.max(1000.0) * 2.0;
1125        let resolved = wez_resolved_request(&inputs, &wind, solver_max_range)
1126            .expect("test_base_inputs's default G1 drag model is always kernel-representable");
1127
1128        let shares = wez_variance_shares(
1129            &resolved, range_m, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
1130        )
1131        .expect("valid test attribution solve")
1132        .expect("attribution must be available for this fixture");
1133
1134        assert_eq!(shares.wind_call, 0.0);
1135        assert_eq!(shares.mv_sd, 0.0);
1136        assert_eq!(shares.other, 0.0);
1137        assert!(shares.dominant().is_none());
1138    }
1139
1140    #[test]
1141    fn wind_call_bucket_dominates_when_it_is_the_only_active_source() {
1142        let inputs = test_base_inputs();
1143        let wind = WindConditions::default();
1144        let range_m: f64 = 300.0;
1145        let solver_max_range = range_m.max(1000.0) * 2.0;
1146        let resolved = wez_resolved_request(&inputs, &wind, solver_max_range)
1147            .expect("test_base_inputs's default G1 drag model is always kernel-representable");
1148
1149        let shares = wez_variance_shares(
1150            &resolved,
1151            range_m,
1152            0.0,
1153            0.0,
1154            0.0,
1155            0.0,
1156            0.0,
1157            /* wind_call_error_std_dev */ 3.0,
1158            0.0,
1159        )
1160        .expect("valid test attribution solve")
1161        .expect("attribution must be available for this fixture");
1162
1163        assert!((shares.wind_call - 1.0).abs() < 1e-9);
1164        assert_eq!(shares.mv_sd, 0.0);
1165        assert_eq!(shares.other, 0.0);
1166        assert_eq!(shares.dominant(), Some(WezErrorBucket::WindCall));
1167    }
1168
1169    /// Review finding M1: `WindSpeed`'s derivative is now computed AT MOST ONCE and reused for
1170    /// both `wind_speed_std_dev` (folded into `Other`) and `wind_call_error_std_dev`
1171    /// (`WindCall`). With every OTHER source's sigma at zero, `other_share` is ENTIRELY the
1172    /// ballistic wind-speed contribution, so the ratio between the two buckets isolates exactly
1173    /// `(wind_call_error_std_dev / wind_speed_std_dev)^2` -- the shared derivative cancels out
1174    /// of the ratio entirely, leaving pure sigma-squared algebra with no ballistic response left
1175    /// in it. This is a real, stated behavior change from the retired one-sided version, which
1176    /// perturbed `wind.speed` independently at two different absolute deltas (`+sigma_call` and
1177    /// `+sigma_wind`) and so was only approximately proportional to `sigma^2` in the
1178    /// locally-linear regime, not exactly.
1179    #[test]
1180    fn wind_call_and_ballistic_wind_shares_are_exactly_proportional_to_sigma_squared() {
1181        let inputs = test_base_inputs();
1182        let wind = WindConditions::default();
1183        let range_m: f64 = 300.0;
1184        let solver_max_range = range_m.max(1000.0) * 2.0;
1185        let resolved = wez_resolved_request(&inputs, &wind, solver_max_range)
1186            .expect("test_base_inputs's default G1 drag model is always kernel-representable");
1187
1188        let wind_speed_std_dev = 0.4_f64;
1189        let wind_call_error_std_dev = 1.2_f64;
1190        let shares = wez_variance_shares(
1191            &resolved,
1192            range_m,
1193            0.0,
1194            0.0,
1195            0.0,
1196            0.0,
1197            wind_speed_std_dev,
1198            wind_call_error_std_dev,
1199            0.0,
1200        )
1201        .expect("valid test attribution solve")
1202        .expect("attribution must be available for this fixture");
1203
1204        assert!(shares.other > 0.0, "fixture must produce a nonzero ballistic-wind share");
1205        let expected_ratio = (wind_call_error_std_dev / wind_speed_std_dev).powi(2);
1206        let actual_ratio = shares.wind_call / shares.other;
1207        assert!(
1208            (actual_ratio - expected_ratio).abs() < 1e-9,
1209            "expected wind_call/other == (sigma_call/sigma_wind)^2 == {expected_ratio}, got \
1210             {actual_ratio}"
1211        );
1212    }
1213
1214    /// `wez_resolved_request`'s output must describe the SAME physical trajectory
1215    /// `wez_solve_target_plane` computes directly from `BallisticInputs` -- otherwise
1216    /// central-difference attribution would measure sensitivity against a subtly different
1217    /// configuration than the one WEZ actually simulates for `p_hit`. Independent oracle:
1218    /// `TrajectoryObservation::drop_m` is LOS-perpendicular and positive BELOW the line of
1219    /// sight (`line_of_sight_height_m - position.y`, `trajectory_observation.rs`);
1220    /// `wez_solve_target_plane`'s raw `position.y` is world-frame height, so
1221    /// `line_of_sight_height_m - baseline.y` is the independently-derived equivalent.
1222    /// `windage_m` is `position.z` directly (no sign flip, no scale change), so it compares to
1223    /// `baseline.z` with no transform at all.
1224    ///
1225    /// M3 review fix: `cant_angle`, `sight_offset_lateral_m`, and `azimuth_angle` are
1226    /// deliberately nonzero here (the first two are directly user-settable on `monte-carlo
1227    /// --wez`, `--cant`/`--sight-offset`; `azimuth_angle` is not exposed as a baseline there but
1228    /// `wez_resolved_request` must still map it correctly for any `BallisticInputs`). With all
1229    /// three left at `test_base_inputs()`'s shared 0.0 default, a mis-mapping -- e.g.
1230    /// `cant_angle_rad` accidentally written to `shooting_angle_rad`, a DIFFERENT `ResolvedShotV1`
1231    /// field -- would leave every assertion in this file green anyway, since both fields agree
1232    /// at the neutral value; nonzero values are the only way this oracle can actually catch that
1233    /// class of bug.
1234    #[test]
1235    fn resolved_request_matches_wez_solve_target_plane_baseline() {
1236        let wind = WindConditions::default();
1237        let range_m: f64 = 300.0;
1238        let solver_max_range = range_m.max(1000.0) * 2.0;
1239
1240        for (name, model) in [
1241            ("G1", DragModel::G1),
1242            ("G2", DragModel::G2),
1243            ("G5", DragModel::G5),
1244            ("G6", DragModel::G6),
1245            ("G7", DragModel::G7),
1246            ("G8", DragModel::G8),
1247            ("GI", DragModel::GI),
1248            ("GS", DragModel::GS),
1249            ("RA4", DragModel::RA4),
1250        ] {
1251            let mut inputs = test_base_inputs();
1252            inputs.bc_type = model;
1253            inputs.cant_angle = 5.0_f64.to_radians();
1254            inputs.sight_offset_lateral_m = 0.02;
1255            inputs.azimuth_angle = 0.001;
1256            let atmosphere = test_atmosphere(&inputs);
1257
1258            let baseline = wez_solve_target_plane(
1259                inputs.clone(),
1260                wind.clone(),
1261                atmosphere,
1262                solver_max_range,
1263                range_m,
1264            )
1265            .expect("valid test baseline solve");
1266
1267            let resolved = wez_resolved_request(&inputs, &wind, solver_max_range)
1268                .expect("every built-in drag model is kernel-representable");
1269            let req: crate::solve_json::SolveRequestV1 = (&resolved).into();
1270            let obs = crate::perturbation::evaluate(&req, &[range_m]).expect("kernel evaluate");
1271            assert_eq!(obs.len(), 1);
1272
1273            let line_of_sight_height_m = inputs.muzzle_height + inputs.sight_height;
1274            let expected_drop_m = line_of_sight_height_m - baseline.y;
1275            assert!(
1276                (obs[0].drop_m - expected_drop_m).abs() < 1e-6,
1277                "{name}: kernel drop_m {} disagrees with wez_solve_target_plane-derived {} -- \
1278                 wez_resolved_request likely mismaps a field",
1279                obs[0].drop_m,
1280                expected_drop_m
1281            );
1282            assert!(
1283                (obs[0].windage_m - baseline.z).abs() < 1e-6,
1284                "{name}: kernel windage_m {} disagrees with wez_solve_target_plane baseline.z \
1285                 {} -- wez_resolved_request likely mismaps a field",
1286                obs[0].windage_m,
1287                baseline.z
1288            );
1289        }
1290    }
1291
1292    fn assert_builtin_drag_model_attribution_available(model: DragModel) {
1293        let result = compute_wez(
1294            823.0,
1295            0.0,
1296            0.243,
1297            0.0113,
1298            0.00782,
1299            20,
1300            5.0,
1301            0.0001,
1302            0.005,
1303            1.0,
1304            0.05,
1305            3.0,
1306            90.0,
1307            0.0,
1308            1.5,
1309            TargetSizeMetric::Rect { width_m: 0.5, height_m: 0.75 },
1310            300.0,
1311            300.0,
1312            100.0,
1313            model,
1314            None,
1315            1.0,
1316            0.0,
1317            0.0,
1318        )
1319        .expect("compute_wez");
1320        let row = result.rows.first().expect("one row");
1321        assert!(
1322            !row.attribution_unavailable,
1323            "{model} has a solve-json v1 counterpart; attribution must run"
1324        );
1325        assert!(row.p_hit.is_finite(), "p_hit comes from the real Monte Carlo run, unaffected");
1326        for (name, share) in [
1327            ("wind_call", row.wind_call_share),
1328            ("mv_sd", row.mv_sd_share),
1329            ("other", row.other_share),
1330        ] {
1331            assert!(share.is_finite(), "{model} {name} share must be finite");
1332            assert!(
1333                (0.0..=1.0).contains(&share),
1334                "{model} {name} share must be within [0, 1], got {share}"
1335            );
1336        }
1337        let sum = row.wind_call_share + row.mv_sd_share + row.other_share;
1338        assert!(
1339            (sum - 1.0).abs() < 1e-9,
1340            "{model} attribution shares must sum to one, got {sum}"
1341        );
1342    }
1343
1344    /// MBA-1442: every built-in model now has an exact solve-json v1 representation, so the
1345    /// shared perturbation kernel can attribute WEZ variance without changing drag physics.
1346    #[test]
1347    fn g2_attribution_is_available() {
1348        assert_builtin_drag_model_attribution_available(DragModel::G2);
1349    }
1350
1351    #[test]
1352    fn g5_attribution_is_available() {
1353        assert_builtin_drag_model_attribution_available(DragModel::G5);
1354    }
1355
1356    #[test]
1357    fn gi_attribution_is_available() {
1358        assert_builtin_drag_model_attribution_available(DragModel::GI);
1359    }
1360
1361    #[test]
1362    fn gs_attribution_is_available() {
1363        assert_builtin_drag_model_attribution_available(DragModel::GS);
1364    }
1365
1366    #[test]
1367    fn ra4_attribution_is_available() {
1368        assert_builtin_drag_model_attribution_available(DragModel::RA4);
1369    }
1370
1371    /// A loaded custom drag table replaces G-model+BC drag entirely, and solve-json v1 has no
1372    /// field for a custom deck at all -- the kernel cannot represent this configuration, so
1373    /// attribution is unavailable rather than silently computed under the wrong (G-model+BC)
1374    /// physics. Reachable via `--drag-table`/`--cd-scale` on `monte-carlo --wez`.
1375    #[test]
1376    fn attribution_unavailable_with_a_custom_drag_table() {
1377        let table = crate::drag::DragTable::new(
1378            vec![0.0, 0.5, 1.0, 1.5, 2.0, 3.0],
1379            vec![0.5, 0.5, 0.5, 0.5, 0.5, 0.5],
1380        );
1381        let result = compute_wez(
1382            823.0,
1383            0.0,
1384            0.243,
1385            0.0113,
1386            0.00782,
1387            20,
1388            5.0,
1389            0.0001,
1390            0.005,
1391            1.0,
1392            0.05,
1393            3.0,
1394            90.0,
1395            0.0,
1396            1.5,
1397            TargetSizeMetric::Rect { width_m: 0.5, height_m: 0.75 },
1398            300.0,
1399            300.0,
1400            100.0,
1401            DragModel::G7,
1402            Some(table),
1403            1.0,
1404            0.0,
1405            0.0,
1406        )
1407        .expect("compute_wez");
1408        let row = result.rows.first().expect("one row");
1409        assert!(
1410            row.attribution_unavailable,
1411            "a custom drag table has no solve-json v1 representation; attribution cannot run"
1412        );
1413        assert!(row.p_hit.is_finite(), "p_hit comes from the real Monte Carlo run, unaffected");
1414    }
1415
1416    /// Characterization: pins the attribution shares for one fixed configuration.
1417    /// D2 changes these values (one-sided -> central differences). When this test
1418    /// fails during the upgrade, record the before/after in the commit message and
1419    /// update the expected constants -- do not silently re-baseline.
1420    ///
1421    /// Deliberately written against the public compute_wez so the refactor of the
1422    /// private attributor cannot change what this test measures.
1423    ///
1424    /// Range deviation from the task-13 brief's literal worked example: the brief's own
1425    /// numbers (angle 0.0, a single row at 600 m) are not reachable -- a perfectly level
1426    /// (angle = 0) shot from a 1.5 m bore height with the default ground_threshold hits the
1427    /// ground at ~365 m (verified via `trajectory -v 823 -a 0 -b 0.243 -m 11.3 -d 7.82
1428    /// --units metric --bore-height 1500 --max-range 700 -o json` => `"max_range":
1429    /// 364.756..."`), so `attribution_unavailable` is `true` at 600 m for TODAY's code too,
1430    /// before any D2 change. 300 m keeps every other parameter from the brief unchanged and
1431    /// sits comfortably inside that ~365 m ceiling.
1432    #[test]
1433    fn attribution_shares_for_a_fixed_configuration() {
1434        let result = compute_wez(
1435            823.0,      // velocity
1436            0.0,        // angle
1437            0.243,      // bc
1438            0.0113,     // mass
1439            0.00782,    // diameter
1440            20,         // num_sims
1441            5.0,        // velocity_std
1442            0.0001,     // angle_std
1443            0.005,      // bc_std
1444            1.0,        // wind_std
1445            0.05,       // wind_direction_std
1446            3.0,        // wind_speed
1447            90.0,       // wind_direction
1448            0.0,        // wind_vertical
1449            1.5,        // wind_call_error
1450            TargetSizeMetric::Rect { width_m: 0.5, height_m: 0.75 },
1451            300.0,      // wez_start (brief specifies 600.0; unreachable at angle=0, see above)
1452            300.0,      // wez_end
1453            100.0,      // wez_step
1454            DragModel::G7,
1455            None,       // custom_drag_table
1456            1.0,        // cd_scale
1457            0.0,        // cant
1458            0.0,        // sight_offset_lateral_m
1459        ).expect("compute_wez");
1460
1461        let row = result.rows.first().expect("one row");
1462        assert!(!row.attribution_unavailable, "attribution must be available here");
1463        eprintln!("CHARACTERIZATION wind_call={} mv_sd={} other={}",
1464                  row.wind_call_share, row.mv_sd_share, row.other_share);
1465        assert!((row.wind_call_share + row.mv_sd_share + row.other_share - 1.0).abs() < 1e-9);
1466
1467        // AFTER (central differences, D2). BEFORE (one-sided, pre-D2) was:
1468        //   wind_call=0.6820206535206624 mv_sd=0.012457193963072112 other=0.30552215251626547
1469        // Measured gap between the two methods on this fixture: wind_call ~4.91e-4,
1470        // mv_sd ~5.12e-4, other ~2.06e-5 (the tightest of the three). The 1e-6 tolerance below
1471        // is ~20x tighter than that smallest gap, so reverting the central-difference calls in
1472        // wez_variance_shares back to one-sided solves fails this test on EVERY bucket, not
1473        // just the two with a larger gap.
1474        assert!((row.wind_call_share - 0.681_529_624_943_378).abs() < 1e-6,
1475                 "update the characterization constant");
1476        assert!((row.mv_sd_share - 0.012_968_843_319_743_649).abs() < 1e-6,
1477                 "update the characterization constant");
1478        assert!((row.other_share - 0.305_501_531_736_878_2).abs() < 1e-6,
1479                 "update the characterization constant");
1480    }
1481}