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