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