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