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::{
20    AtmosphericConditions, BallisticInputs, BallisticsError, DragModel, MonteCarloParams,
21    MonteCarloResults, TrajectorySolver, WindConditions,
22};
23
24/// A parsed `--target-size` value, still in the CLI's chosen unit (inches imperial / cm
25/// metric) -- call [`TargetSize::to_metric`] before using it.
26#[derive(Debug, Clone, Copy, PartialEq)]
27pub enum TargetSize {
28    /// Full width (lateral) x height (vertical), e.g. an 18"x30" plate.
29    Rect { width: f64, height: f64 },
30    /// A circular radius, matching `--target-radius`'s existing hit semantics but expressed in
31    /// target-size units instead of range units.
32    Radius(f64),
33}
34
35/// Parse a `--target-size` argument: `WIDTHxHEIGHT` (e.g. `18x30`) for a rectangle, or a bare
36/// number (e.g. `12`) for a circular radius fallback. Case-insensitive on the `x` separator.
37pub fn parse_target_size(spec: &str) -> Result<TargetSize, String> {
38    let trimmed = spec.trim();
39    if trimmed.is_empty() {
40        return Err("expected a size like \"18x30\" or a single radius like \"12\"".to_string());
41    }
42
43    let x_positions: Vec<usize> = trimmed
44        .char_indices()
45        .filter(|(_, c)| *c == 'x' || *c == 'X')
46        .map(|(i, _)| i)
47        .collect();
48
49    match x_positions.len() {
50        0 => {
51            let radius: f64 = trimmed
52                .parse()
53                .map_err(|_| format!("\"{trimmed}\" is not a number or a WIDTHxHEIGHT pair"))?;
54            if !(radius.is_finite() && radius > 0.0) {
55                return Err(format!(
56                    "radius must be a positive, finite number, got {radius}"
57                ));
58            }
59            Ok(TargetSize::Radius(radius))
60        }
61        1 => {
62            let idx = x_positions[0];
63            let width_str = &trimmed[..idx];
64            let height_str = &trimmed[idx + 1..];
65            let width: f64 = width_str
66                .trim()
67                .parse()
68                .map_err(|_| format!("\"{}\" is not a valid width", width_str.trim()))?;
69            let height: f64 = height_str
70                .trim()
71                .parse()
72                .map_err(|_| format!("\"{}\" is not a valid height", height_str.trim()))?;
73            if !(width.is_finite() && width > 0.0 && height.is_finite() && height > 0.0) {
74                return Err(format!(
75                    "width and height must be positive, finite numbers, got {width}x{height}"
76                ));
77            }
78            Ok(TargetSize::Rect { width, height })
79        }
80        _ => Err(format!(
81            "\"{trimmed}\" has more than one 'x' separator; expected WIDTHxHEIGHT or a single radius"
82        )),
83    }
84}
85
86/// A [`TargetSize`] converted to meters, ready for [`MonteCarloResults`]'s
87/// hit-probability methods.
88#[derive(Debug, Clone, Copy, PartialEq)]
89pub enum TargetSizeMetric {
90    Rect { width_m: f64, height_m: f64 },
91    Radius { radius_m: f64 },
92}
93
94/// WEZ target-size length (MBA-1317): inches under imperial, CENTIMETERS (not mm -- target
95/// sizes like an 18"x30" plate are naturally cm-scale under metric) under metric.
96fn target_size_to_metric(val: f64, units: UnitSystem) -> f64 {
97    match units {
98        UnitSystem::Metric => val * 0.01,     // cm to meters
99        UnitSystem::Imperial => val * 0.0254, // inches to meters
100    }
101}
102
103impl TargetSize {
104    pub fn to_metric(self, units: UnitSystem) -> TargetSizeMetric {
105        match self {
106            TargetSize::Rect { width, height } => TargetSizeMetric::Rect {
107                width_m: target_size_to_metric(width, units),
108                height_m: target_size_to_metric(height, units),
109            },
110            TargetSize::Radius(radius) => TargetSizeMetric::Radius {
111                radius_m: target_size_to_metric(radius, units),
112            },
113        }
114    }
115}
116
117/// Which WEZ variance-attribution bucket a miss-variance source belongs to.
118#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
119#[serde(rename_all = "snake_case")]
120pub enum WezErrorBucket {
121    /// The shooter's wind-call error (`--wind-call-error`).
122    WindCall,
123    /// Muzzle-velocity standard deviation (`--velocity-std`).
124    MvSd,
125    /// Everything else: mechanical/ammo group dispersion (angle, azimuth, BC) plus the
126    /// *ballistic* (non-call) share of wind uncertainty (`--wind-std`, `--wind-direction-std`).
127    Other,
128}
129
130impl std::fmt::Display for WezErrorBucket {
131    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
132        // MBA-1337 w2: one spelling everywhere. These must match the serde
133        // rename_all = "snake_case" values the -o full JSON contract shipped with
134        // (0.25.0), so summary/CSV/JSON all agree on the same strings.
135        let label = match self {
136            WezErrorBucket::WindCall => "wind_call",
137            WezErrorBucket::MvSd => "mv_sd",
138            WezErrorBucket::Other => "other",
139        };
140        write!(f, "{label}")
141    }
142}
143
144/// Per-range WEZ miss-variance attribution shares. `wind_call + mv_sd + other` sums to ~1.0
145/// whenever at least one modeled source has nonzero uncertainty; all fields are exactly 0.0 in
146/// the fully deterministic (zero-uncertainty) case, where there is no dominant source.
147#[derive(Debug, Clone, Copy, Default, Serialize)]
148struct WezVarianceShares {
149    wind_call: f64,
150    mv_sd: f64,
151    other: f64,
152}
153
154impl WezVarianceShares {
155    /// The largest nonzero share, or `None` if every share is zero (nothing to attribute).
156    fn dominant(&self) -> Option<WezErrorBucket> {
157        [
158            (WezErrorBucket::WindCall, self.wind_call),
159            (WezErrorBucket::MvSd, self.mv_sd),
160            (WezErrorBucket::Other, self.other),
161        ]
162        .into_iter()
163        .filter(|(_, share)| *share > 0.0)
164        .max_by(|a, b| a.1.total_cmp(&b.1))
165        .map(|(bucket, _)| bucket)
166    }
167}
168
169/// Solve a single deterministic trajectory and return its target-plane impact position.
170///
171/// The caller is responsible for setting `inputs.muzzle_velocity` to whatever value it wants
172/// solved (e.g. baseline + one sigma for the MV-SD sensitivity). The real Monte Carlo sampler
173/// instead applies a sampled velocity *delta* after `TrajectorySolver::new` resolves any
174/// powder-temperature curve (MBA-1176), because `TrajectorySolver` doesn't expose that resolved
175/// value to callers outside `cli_api`. That distinction is a no-op here: `monte-carlo` (and so
176/// `--wez`) never sets `powder_temp_curve` on its `BallisticInputs`, so there is no curve for
177/// `TrajectorySolver::new` to resolve and a plain pre-construction velocity assignment is
178/// equivalent to the sampler's post-construction delta.
179///
180/// Propagates `solve()`'s error instead of unwrapping it: neither the baseline solve (whose
181/// inputs are the CLI's raw, not-yet-validated `-v`/`-a`/etc. values -- clap's own range checks
182/// permit e.g. `-v 0`, which `solve()` rejects) nor a perturbed one-sigma solve (MV/BC/angle/wind
183/// nudged off a valid baseline) is guaranteed to stay within `solve()`'s validity gate (MBA-1317).
184fn wez_solve_target_plane(
185    inputs: BallisticInputs,
186    wind: WindConditions,
187    atmosphere: AtmosphericConditions,
188    solver_max_range: f64,
189    target_distance_m: f64,
190) -> Result<Vector3<f64>, BallisticsError> {
191    let mut solver = TrajectorySolver::new(inputs, wind, atmosphere);
192    solver.set_max_range(solver_max_range);
193    let result = solver.solve()?;
194    // A successful `solve()` always produces a non-empty trajectory (every solve path errors
195    // out on an empty point list before returning `Ok`), so `position_at_range` -- which only
196    // returns `None` for an empty trajectory, clamping to the last point otherwise -- cannot
197    // fail here. See cli_api::TrajectoryResult::position_at_range.
198    Ok(result
199        .position_at_range(target_distance_m)
200        .expect("WEZ attribution solve: non-empty trajectory always has a last point"))
201}
202
203/// One source's target-plane variance contribution: `sigma`'s one-standard-deviation
204/// displacement from `baseline`, or `0.0` when `sigma` is non-positive (that source is disabled).
205/// `inputs` and `wind` must already have that one sigma applied by the caller.
206///
207/// Propagates a failed perturbed solve rather than unwrapping it (MBA-1317) -- a one-sigma nudge
208/// off a valid baseline is not itself guaranteed to stay within `solve()`'s validity gate.
209fn wez_source_variance(
210    sigma: f64,
211    inputs: BallisticInputs,
212    wind: WindConditions,
213    atmosphere: &AtmosphericConditions,
214    solver_max_range: f64,
215    target_distance_m: f64,
216    baseline: &Vector3<f64>,
217) -> Result<f64, BallisticsError> {
218    if sigma.is_nan() || sigma <= 0.0 {
219        return Ok(0.0);
220    }
221    let perturbed = wez_solve_target_plane(
222        inputs,
223        wind,
224        atmosphere.clone(),
225        solver_max_range,
226        target_distance_m,
227    )?;
228    let dy = perturbed.y - baseline.y;
229    let dz = perturbed.z - baseline.z;
230    Ok(dy * dy + dz * dz)
231}
232
233/// Linearized (one-sigma finite-difference) WEZ miss-variance attribution at a single range.
234///
235/// A full "decomposed re-run" attribution -- zero out each source in turn and re-run the whole
236/// Monte Carlo sample set for it -- would multiply the sweep's cost by the number of buckets,
237/// which does not fit a "finishes in seconds" default sweep. Instead this holds every input at
238/// its baseline value except one, nudges that one input by exactly its own standard deviation,
239/// and measures the resulting shift in the target-plane impact point. The Monte Carlo sampler's
240/// inputs are independent Gaussians and the ballistic response is smooth; at the magnitude of a
241/// single sigma it is locally close to linear, so each source's one-sigma displacement `(dy, dz)`
242/// is a standard first-order estimate of its variance contribution, and treating the sources as
243/// independent gives `Var(total) ~= sum_i (dy_i^2 + dz_i^2)`. This costs a handful of
244/// deterministic trajectory solves per range instead of a full extra Monte Carlo sub-run per
245/// bucket.
246///
247/// Takes the (already solved) undispersed `baseline` position at `target_distance_m` from the
248/// caller rather than solving it again -- the caller needs that same baseline to compute `p_hit`
249/// (see `compute_wez`) and to decide whether attribution is meaningful at all when the
250/// baseline doesn't reach this range.
251///
252/// Errors if any one-sigma perturbed solve fails validation (MBA-1317); the caller only invokes
253/// this once the *un*perturbed baseline solve has already succeeded, but a sigma nudge can still
254/// push an input (e.g. muzzle velocity, BC) out of `solve()`'s valid range.
255#[allow(
256    clippy::too_many_arguments,
257    reason = "flat arguments mirror the Monte Carlo sampler's own parameter set (MBA-1317)"
258)]
259fn wez_variance_shares(
260    base_inputs: &BallisticInputs,
261    base_wind: &WindConditions,
262    atmosphere: &AtmosphericConditions,
263    solver_max_range: f64,
264    target_distance_m: f64,
265    baseline: &Vector3<f64>,
266    velocity_std_dev: f64,
267    angle_std_dev_rad: f64,
268    bc_std_dev: f64,
269    azimuth_std_dev_rad: f64,
270    wind_speed_std_dev: f64,
271    wind_call_error_std_dev: f64,
272    wind_direction_std_dev_rad: f64,
273) -> Result<WezVarianceShares, BallisticsError> {
274    // MV SD bucket: muzzle-velocity dispersion.
275    let mv_sd_var = {
276        let mut inputs = base_inputs.clone();
277        inputs.muzzle_velocity = (inputs.muzzle_velocity + velocity_std_dev).max(0.0);
278        wez_source_variance(
279            velocity_std_dev,
280            inputs,
281            base_wind.clone(),
282            atmosphere,
283            solver_max_range,
284            target_distance_m,
285            baseline,
286        )?
287    };
288
289    // Other/group bucket: elevation, azimuth, and BC dispersion (mechanical/ammo "group"), plus
290    // the ballistic (non-call) share of wind uncertainty.
291    let mut other_var = 0.0;
292    {
293        let mut inputs = base_inputs.clone();
294        inputs.muzzle_angle += angle_std_dev_rad;
295        other_var += wez_source_variance(
296            angle_std_dev_rad,
297            inputs,
298            base_wind.clone(),
299            atmosphere,
300            solver_max_range,
301            target_distance_m,
302            baseline,
303        )?;
304    }
305    {
306        let mut inputs = base_inputs.clone();
307        inputs.bc_value = (inputs.bc_value + bc_std_dev).max(0.01);
308        other_var += wez_source_variance(
309            bc_std_dev,
310            inputs,
311            base_wind.clone(),
312            atmosphere,
313            solver_max_range,
314            target_distance_m,
315            baseline,
316        )?;
317    }
318    {
319        let mut inputs = base_inputs.clone();
320        inputs.azimuth_angle += azimuth_std_dev_rad;
321        other_var += wez_source_variance(
322            azimuth_std_dev_rad,
323            inputs,
324            base_wind.clone(),
325            atmosphere,
326            solver_max_range,
327            target_distance_m,
328            baseline,
329        )?;
330    }
331    {
332        let mut wind = base_wind.clone();
333        wind.direction += wind_direction_std_dev_rad;
334        other_var += wez_source_variance(
335            wind_direction_std_dev_rad,
336            base_inputs.clone(),
337            wind,
338            atmosphere,
339            solver_max_range,
340            target_distance_m,
341            baseline,
342        )?;
343    }
344    {
345        let mut wind = base_wind.clone();
346        wind.speed += wind_speed_std_dev;
347        other_var += wez_source_variance(
348            wind_speed_std_dev,
349            base_inputs.clone(),
350            wind,
351            atmosphere,
352            solver_max_range,
353            target_distance_m,
354            baseline,
355        )?;
356    }
357
358    // Wind-call bucket: the shooter's own wind-speed estimation error, kept separate from the
359    // ballistic wind-speed uncertainty above even though both perturb the same physical channel.
360    let wind_call_var = {
361        let mut wind = base_wind.clone();
362        wind.speed += wind_call_error_std_dev;
363        wez_source_variance(
364            wind_call_error_std_dev,
365            base_inputs.clone(),
366            wind,
367            atmosphere,
368            solver_max_range,
369            target_distance_m,
370            baseline,
371        )?
372    };
373
374    let total = wind_call_var + mv_sd_var + other_var;
375    if total.is_nan() || total <= 0.0 {
376        return Ok(WezVarianceShares::default());
377    }
378    Ok(WezVarianceShares {
379        wind_call: wind_call_var / total,
380        mv_sd: mv_sd_var / total,
381        other: other_var / total,
382    })
383}
384
385/// WEZ hit probability: the fraction of `results`' samples whose ABSOLUTE target-plane position
386/// -- reconstructed as `baseline + (that sample's deviation from baseline)`, since
387/// [`MonteCarloResults::impact_positions`] stores only the deviation -- falls
388/// within `target_size`, centered on the fixed line of sight (`line_of_sight_height_m` vertically,
389/// `z = 0` laterally).
390///
391/// This is deliberately NOT [`MonteCarloResults::hit_probability`] /
392/// `rect_hit_probability`, which measure the miss distance from that SAME range's own baseline
393/// (i.e. assume the shooter re-dials elevation perfectly for every range). A WEZ sweep instead
394/// answers "how far can I hit this target size with ONE hold", so it must also count the
395/// systematic ballistic drop below the fixed line of sight as a source of misses, not just random
396/// dispersion -- see the module doc comment above `compute_wez`.
397///
398/// A sample that never reached the target plane keeps `MonteCarloResults`'s sentinel deviation
399/// (`TARGET_NOT_REACHED_SENTINEL_M`, roughly -1e9 m). Added to any finite baseline that stays a
400/// miss by a vast margin, so it is correctly excluded here without a separate check.
401fn wez_p_hit(
402    results: &MonteCarloResults,
403    baseline: &Vector3<f64>,
404    line_of_sight_height_m: f64,
405    target_size: TargetSizeMetric,
406) -> f64 {
407    if results.impact_positions.is_empty() {
408        return 0.0;
409    }
410    let hits = results
411        .impact_positions
412        .iter()
413        .filter(|deviation| {
414            let absolute_y = baseline.y + deviation.y;
415            let absolute_z = baseline.z + deviation.z;
416            let drop_from_los = absolute_y - line_of_sight_height_m;
417            match target_size {
418                TargetSizeMetric::Rect { width_m, height_m } => {
419                    drop_from_los.abs() <= height_m / 2.0 && absolute_z.abs() <= width_m / 2.0
420                }
421                TargetSizeMetric::Radius { radius_m } => {
422                    (drop_from_los * drop_from_los + absolute_z * absolute_z).sqrt() <= radius_m
423                }
424            }
425        })
426        .count();
427    hits as f64 / results.impact_positions.len() as f64
428}
429
430/// One range step of a WEZ sweep.
431#[derive(Debug, Clone, Serialize)]
432pub struct WezRow {
433    pub range_m: f64,
434    pub p_hit: f64,
435    pub dominant_error_source: Option<WezErrorBucket>,
436    pub wind_call_share: f64,
437    pub mv_sd_share: f64,
438    pub other_share: f64,
439    /// The undispersed baseline trajectory did not reach this range, so `*_share` and
440    /// `dominant_error_source` above are not meaningful (left at their zero/`None` default).
441    /// `p_hit` is unaffected -- it comes from the fully-dispersed Monte Carlo run directly.
442    pub attribution_unavailable: bool,
443}
444
445#[derive(Debug, Clone, Serialize)]
446pub struct WezTargetSizeJson {
447    #[serde(skip_serializing_if = "Option::is_none")]
448    pub width_m: Option<f64>,
449    #[serde(skip_serializing_if = "Option::is_none")]
450    pub height_m: Option<f64>,
451    #[serde(skip_serializing_if = "Option::is_none")]
452    pub radius_m: Option<f64>,
453}
454
455#[derive(Debug, Clone, Serialize)]
456pub struct WezResult {
457    pub target_size: WezTargetSizeJson,
458    pub wind_speed_std_mps: f64,
459    pub wind_call_error_mps: f64,
460    /// `sqrt(wind_speed_std_mps^2 + wind_call_error_mps^2)`: the effective wind-speed standard
461    /// deviation actually fed to the underlying Monte Carlo sampler at each range step.
462    pub combined_wind_speed_std_mps: f64,
463    pub num_sims_per_step: usize,
464    pub rows: Vec<WezRow>,
465}
466
467/// Run a WEZ sweep and return its per-range rows plus the sweep-level parameters as a
468/// [`WezResult`], leaving all rendering (summary table / statistics CSV / full JSON) to the
469/// caller. All inputs are metric (the CLI converts from user units before calling).
470///
471/// # Parameters
472///
473/// NOTE the angle-like parameters (`angle`, `cant`, `wind_direction`, `angle_std`,
474/// `wind_direction_std`) are in DEGREES — this function converts to radians itself,
475/// unlike [`BallisticInputs`]/[`WindConditions`] elsewhere in the crate, which carry
476/// radians. This mirrors the CLI flag set the sweep was extracted from (MBA-1317).
477///
478/// * `velocity` — muzzle velocity, m/s.
479/// * `angle` — launch (elevation) angle held for every sweep step, DEGREES.
480/// * `bc` — ballistic coefficient (dimensionless; referenced to `drag_model`).
481/// * `mass` — bullet mass, kg.
482/// * `diameter` — bullet diameter, m.
483/// * `num_sims` — Monte Carlo samples per range step.
484/// * `velocity_std` — muzzle-velocity standard deviation, m/s.
485/// * `angle_std` — elevation-angle standard deviation, DEGREES (the derived
486///   azimuth dispersion is half of it, matching the base Monte Carlo command).
487/// * `bc_std` — BC standard deviation (dimensionless).
488/// * `wind_std` — ballistic (gust-to-gust) wind-speed standard deviation, m/s.
489/// * `wind_direction_std` — wind-direction standard deviation, DEGREES.
490/// * `wind_speed` — base wind speed, m/s.
491/// * `wind_direction` — base wind direction, DEGREES (wind-FROM: 0 = headwind,
492///   90 = from the right).
493/// * `wind_vertical` — base vertical wind, m/s, positive = updraft.
494/// * `wind_call_error` — the shooter's wind-CALL error, m/s; composed with
495///   `wind_std` in quadrature (see [`WezResult::combined_wind_speed_std_mps`]).
496/// * `target_size` — the target box/radius, already in meters
497///   ([`TargetSize::to_metric`]).
498/// * `wez_start` / `wez_end` / `wez_step` — sweep bounds and step, meters
499///   (`wez_end` inclusive).
500/// * `drag_model` — the G-model `bc` is referenced to ([`DragModel::G1`] /
501///   [`DragModel::G7`]); ignored for drag whenever `custom_drag_table` is set.
502/// * `custom_drag_table` — optional Mach-keyed Cd deck replacing the G-model +
503///   BC drag entirely.
504/// * `cd_scale` — whole-curve multiplier on `custom_drag_table`'s interpolated Cd (MBA-1356);
505///   `1.0` = neutral. Inert when `custom_drag_table` is `None`.
506/// * `cant` — rifle cant, DEGREES, positive = clockwise from the shooter.
507/// * `sight_offset_lateral_m` — lateral sight-to-bore mount offset, METERS, positive =
508///   sight right of bore (MBA-1396); displaces every sample's initial lateral position
509///   exactly like the trajectory command. `0.0` = neutral (byte-identical).
510///
511/// A fixed, distinct seed per range step (`0x57_45_5A_00 ^ step_index`) keeps a sweep
512/// reproducible run-to-run while still drawing independent samples at each range.
513#[allow(
514    clippy::too_many_arguments,
515    reason = "flat arguments mirror the stable Monte Carlo CLI command shape (MBA-1317)"
516)]
517pub fn compute_wez(
518    velocity: f64,
519    angle: f64,
520    bc: f64,
521    mass: f64,
522    diameter: f64,
523    num_sims: usize,
524    velocity_std: f64,
525    angle_std: f64,
526    bc_std: f64,
527    wind_std: f64,
528    wind_direction_std: f64,
529    wind_speed: f64,
530    wind_direction: f64,
531    wind_vertical: f64,
532    wind_call_error: f64,
533    target_size: TargetSizeMetric,
534    wez_start: f64,
535    wez_end: f64,
536    wez_step: f64,
537    drag_model: DragModel,
538    custom_drag_table: Option<DragTable>,
539    cd_scale: f64,
540    cant: f64,
541    sight_offset_lateral_m: f64,
542) -> Result<WezResult, Box<dyn Error>> {
543    if !(wez_step > 0.0 && wez_step.is_finite()) {
544        return Err("--wez-step must be a positive, finite distance".into());
545    }
546    if !wez_start.is_finite() || !wez_end.is_finite() || wez_end < wez_start {
547        return Err("--wez-end must be finite and >= --wez-start".into());
548    }
549
550    // Same bore-height/ground convention as the base `monte-carlo` command (MBA-967).
551    let bore_height_metric = 1.5_f64;
552    let base_inputs = BallisticInputs {
553        muzzle_velocity: velocity,
554        muzzle_angle: angle.to_radians(),
555        bc_value: bc,
556        bc_type: drag_model,
557        bullet_mass: mass,
558        bullet_diameter: diameter,
559        muzzle_height: bore_height_metric,
560        ground_threshold: 0.0,
561        custom_drag_table,
562        cd_scale,
563        cant_angle: cant.to_radians(),
564        sight_offset_lateral_m,
565        ..Default::default()
566    };
567    let base_wind = WindConditions {
568        speed: wind_speed,
569        direction: wind_direction.to_radians(),
570        vertical_speed: wind_vertical,
571    };
572
573    // The shooter's wind-call error is a dispersion source distinct from the ballistic
574    // (gust-to-gust) wind-speed uncertainty --wind-std already models, but both perturb the same
575    // physical channel (wind speed fed to the solve). As independent random errors they compose
576    // in quadrature -- not by simple addition -- into the effective standard deviation the
577    // underlying Monte Carlo sampler uses.
578    let combined_wind_speed_std = wind_std.hypot(wind_call_error);
579
580    // Matches run_monte_carlo's own convention: horizontal (azimuth) aim dispersion defaults to
581    // half of the vertical (elevation) dispersion.
582    let angle_std_rad = angle_std.to_radians();
583    let azimuth_std_dev = angle_std_rad * 0.5;
584    let wind_direction_std_rad = wind_direction_std.to_radians();
585
586    // The fixed reference the WEZ target box is centered on: the horizontal line of sight,
587    // extended straight (not the curved bullet path). This is what makes the zero-uncertainty
588    // case a genuine step function -- ballistic drop below this fixed line, not just random
589    // dispersion, can carry the bullet outside the box as range grows. It does NOT change per
590    // range step: a WEZ sweep answers "how far can I engage this target size with ONE hold",
591    // the classic point-blank-range question, not "assuming I re-dial for every range".
592    let atmosphere = AtmosphericConditions {
593        temperature: base_inputs.temperature,
594        pressure: base_inputs.pressure,
595        humidity: base_inputs.humidity_percent(),
596        altitude: base_inputs.altitude,
597    };
598    let line_of_sight_height_m = base_inputs.muzzle_height + base_inputs.sight_height;
599
600    let mut ranges_m = Vec::new();
601    let mut next = wez_start;
602    // Guard against an unbounded loop from a step so small that floating-point addition never
603    // advances `next` past `wez_end`.
604    for _ in 0..100_000 {
605        if next > wez_end + wez_step * 1e-9 {
606            break;
607        }
608        ranges_m.push(next);
609        next += wez_step;
610    }
611
612    let mut rows = Vec::with_capacity(ranges_m.len());
613    for (step_index, &range_m) in ranges_m.iter().enumerate() {
614        let solver_max_range = range_m.max(1000.0) * 2.0;
615        let baseline = wez_solve_target_plane(
616            base_inputs.clone(),
617            base_wind.clone(),
618            atmosphere.clone(),
619            solver_max_range,
620            range_m,
621        )?;
622        let baseline_reached = baseline.x >= range_m - 1e-6;
623
624        let mc_params = MonteCarloParams {
625            num_simulations: num_sims,
626            velocity_std_dev: velocity_std,
627            angle_std_dev: angle_std_rad,
628            bc_std_dev: bc_std,
629            wind_speed_std_dev: combined_wind_speed_std,
630            target_distance: Some(range_m),
631            base_wind_speed: wind_speed,
632            base_wind_direction: wind_direction.to_radians(),
633            azimuth_std_dev,
634        };
635
636        // A fixed, distinct seed per range step keeps a sweep reproducible run-to-run while
637        // still drawing independent samples at each range.
638        let seed = 0x57_45_5A_00_u64 ^ (step_index as u64);
639        let p_hit = match crate::run_monte_carlo_with_wind_and_direction_std_dev_seeded(
640            base_inputs.clone(),
641            base_wind.clone(),
642            mc_params,
643            wind_direction_std_rad,
644            seed,
645        ) {
646            Ok(results) => {
647                wez_p_hit(&results, &baseline, line_of_sight_height_m, target_size)
648            }
649            // The baseline never reached this range plane at all -> every sample is a definite
650            // miss for it.
651            Err(_) => 0.0,
652        };
653
654        let (shares, attribution_unavailable) = if baseline_reached {
655            (
656                wez_variance_shares(
657                    &base_inputs,
658                    &base_wind,
659                    &atmosphere,
660                    solver_max_range,
661                    range_m,
662                    &baseline,
663                    velocity_std,
664                    angle_std_rad,
665                    bc_std,
666                    azimuth_std_dev,
667                    wind_std,
668                    wind_call_error,
669                    wind_direction_std_rad,
670                )?,
671                false,
672            )
673        } else {
674            (WezVarianceShares::default(), true)
675        };
676
677        rows.push(WezRow {
678            range_m,
679            p_hit,
680            dominant_error_source: shares.dominant(),
681            wind_call_share: shares.wind_call,
682            mv_sd_share: shares.mv_sd,
683            other_share: shares.other,
684            attribution_unavailable,
685        });
686    }
687
688    Ok(WezResult {
689        target_size: match target_size {
690            TargetSizeMetric::Rect { width_m, height_m } => WezTargetSizeJson {
691                width_m: Some(width_m),
692                height_m: Some(height_m),
693                radius_m: None,
694            },
695            TargetSizeMetric::Radius { radius_m } => WezTargetSizeJson {
696                width_m: None,
697                height_m: None,
698                radius_m: Some(radius_m),
699            },
700        },
701        wind_speed_std_mps: wind_std,
702        wind_call_error_mps: wind_call_error,
703        combined_wind_speed_std_mps: combined_wind_speed_std,
704        num_sims_per_step: num_sims,
705        rows,
706    })
707}
708
709#[cfg(test)]
710mod wez_tests {
711    use super::*;
712
713    // A modest .308/168gr load, zeroed at 300 m with a shallow elevation that keeps the
714    // trajectory well above ground for the whole 50-600 m range these tests sweep -- chosen with
715    // `ballistics zero` (see CLI_USAGE.md's WEZ worked example for the imperial equivalent).
716    fn test_base_inputs() -> BallisticInputs {
717        BallisticInputs {
718            muzzle_velocity: 823.0, // ~2700 fps
719            muzzle_angle: 0.001274, // ~0.073 degrees: a 300 m zero for this load
720            bc_value: 0.475,
721            bullet_mass: 0.010_886, // 168 gr
722            bullet_diameter: 0.007_82, // .308 in
723            muzzle_height: 1.5,
724            ground_threshold: 0.0,
725            ..Default::default()
726        }
727    }
728
729    fn test_atmosphere(inputs: &BallisticInputs) -> AtmosphericConditions {
730        AtmosphericConditions {
731            temperature: inputs.temperature,
732            pressure: inputs.pressure,
733            humidity: inputs.humidity_percent(),
734            altitude: inputs.altitude,
735        }
736    }
737
738    // ---- parse_target_size --------------------------------------------------------------
739
740    #[test]
741    fn parse_target_size_accepts_a_wxh_rectangle() {
742        assert_eq!(
743            parse_target_size("18x30").unwrap(),
744            TargetSize::Rect {
745                width: 18.0,
746                height: 30.0
747            }
748        );
749        // Case-insensitive separator and surrounding whitespace.
750        assert_eq!(
751            parse_target_size(" 18.5X30.25 ").unwrap(),
752            TargetSize::Rect {
753                width: 18.5,
754                height: 30.25
755            }
756        );
757    }
758
759    #[test]
760    fn parse_target_size_accepts_a_single_radius() {
761        assert_eq!(parse_target_size("12").unwrap(), TargetSize::Radius(12.0));
762        assert_eq!(parse_target_size(" 0.5 ").unwrap(), TargetSize::Radius(0.5));
763    }
764
765    #[test]
766    fn parse_target_size_rejects_garbage() {
767        for bad in [
768            "",
769            "   ",
770            "abc",
771            "18xthirty",
772            "eighteenx30",
773            "18x30x40",
774            "0",
775            "-5",
776            "18x-5",
777            "18x0",
778            "NaN",
779        ] {
780            assert!(
781                parse_target_size(bad).is_err(),
782                "expected an error for {bad:?}"
783            );
784        }
785    }
786
787    // ---- WEZ hit-probability step function -----------------------------------------------
788
789    #[test]
790    fn zero_uncertainty_is_a_step_function_in_range() {
791        let inputs = test_base_inputs();
792        let wind = WindConditions::default();
793        let atmosphere = test_atmosphere(&inputs);
794        // 18x30 box: 0.4572 m x 0.762 m.
795        let target = TargetSizeMetric::Rect {
796            width_m: 0.4572,
797            height_m: 0.762,
798        };
799        let los_height_m = inputs.muzzle_height + inputs.sight_height;
800
801        let mc_params = MonteCarloParams {
802            num_simulations: 20,
803            velocity_std_dev: 0.0,
804            angle_std_dev: 0.0,
805            bc_std_dev: 0.0,
806            wind_speed_std_dev: 0.0,
807            target_distance: None,
808            base_wind_speed: 0.0,
809            base_wind_direction: 0.0,
810            azimuth_std_dev: 0.0,
811        };
812
813        let mut p_hits = Vec::new();
814        for &range_m in &[50.0_f64, 100.0, 150.0, 200.0, 250.0, 300.0, 350.0, 400.0] {
815            let solver_max_range = range_m.max(1000.0) * 2.0;
816            let baseline = wez_solve_target_plane(
817                inputs.clone(),
818                wind.clone(),
819                atmosphere.clone(),
820                solver_max_range,
821                range_m,
822            )
823            .expect("valid test baseline solve");
824            let mut params = mc_params.clone();
825            params.target_distance = Some(range_m);
826            let results = crate::run_monte_carlo_with_wind_and_direction_std_dev_seeded(
827                inputs.clone(),
828                wind.clone(),
829                params,
830                0.0,
831                0xA11CE,
832            )
833            .expect("zero-uncertainty solve");
834            let p_hit = wez_p_hit(&results, &baseline, los_height_m, target);
835            // Every one of the (identical, undispersed) samples must agree: exactly a hit or
836            // exactly a miss, never a fractional probability.
837            assert!(
838                p_hit == 0.0 || p_hit == 1.0,
839                "range {range_m} m: expected a step (0.0 or 1.0), got {p_hit}"
840            );
841            p_hits.push((range_m, p_hit));
842        }
843
844        assert!(
845            p_hits.iter().any(|&(_, p)| p == 1.0),
846            "expected at least one in-box range close to the muzzle: {p_hits:?}"
847        );
848        assert!(
849            p_hits.iter().any(|&(_, p)| p == 0.0),
850            "expected at least one out-of-box range far downrange: {p_hits:?}"
851        );
852        // Once it steps down to a miss, a plain (unheld) trajectory that has already passed its
853        // zero does not come back into a fixed-size box further downrange.
854        let first_miss = p_hits.iter().position(|&(_, p)| p == 0.0);
855        if let Some(idx) = first_miss {
856            assert!(
857                p_hits[idx..].iter().all(|&(_, p)| p == 0.0),
858                "expected the box exit to be permanent for the rest of the sweep: {p_hits:?}"
859            );
860        }
861    }
862
863    // ---- P(hit) monotonicity with real dispersion -----------------------------------------
864
865    #[test]
866    fn p_hit_is_monotone_non_increasing_with_range() {
867        let inputs = test_base_inputs();
868        let wind = WindConditions::default();
869        let atmosphere = test_atmosphere(&inputs);
870        let target = TargetSizeMetric::Rect {
871            width_m: 0.4572,
872            height_m: 0.762,
873        };
874        let los_height_m = inputs.muzzle_height + inputs.sight_height;
875        let wind_call_error = 1.5_f64; // m/s
876        let wind_std = 0.5_f64; // m/s
877        let combined_wind_std = wind_std.hypot(wind_call_error);
878
879        let mc_params = MonteCarloParams {
880            num_simulations: 500, // a fixed seed keeps this run-to-run deterministic
881            velocity_std_dev: 1.0,
882            angle_std_dev: 0.001,
883            bc_std_dev: 0.01,
884            wind_speed_std_dev: combined_wind_std,
885            target_distance: None,
886            base_wind_speed: 0.0,
887            base_wind_direction: 0.0,
888            azimuth_std_dev: 0.0005,
889        };
890
891        let ranges_m = [100.0_f64, 200.0, 300.0, 400.0, 500.0, 600.0];
892        let mut p_hits = Vec::new();
893        for (step_index, &range_m) in ranges_m.iter().enumerate() {
894            let solver_max_range = range_m.max(1000.0) * 2.0;
895            let baseline = wez_solve_target_plane(
896                inputs.clone(),
897                wind.clone(),
898                atmosphere.clone(),
899                solver_max_range,
900                range_m,
901            )
902            .expect("valid test baseline solve");
903            let mut params = mc_params.clone();
904            params.target_distance = Some(range_m);
905            let seed = 0x57_45_5A_00_u64 ^ (step_index as u64);
906            let results = crate::run_monte_carlo_with_wind_and_direction_std_dev_seeded(
907                inputs.clone(),
908                wind.clone(),
909                params,
910                0.0,
911                seed,
912            )
913            .expect("dispersed solve");
914            p_hits.push(wez_p_hit(&results, &baseline, los_height_m, target));
915        }
916
917        // A large sample count plus a fixed seed makes this close to the noiseless limit, but a
918        // finite Monte Carlo estimate can still tick up by a hair at the boundary between two
919        // adjacent steps -- allow a small generous tolerance rather than asserting exact
920        // non-increase (MBA-1317 test spec).
921        let tolerance = 0.03;
922        for pair in p_hits.windows(2) {
923            assert!(
924                pair[1] <= pair[0] + tolerance,
925                "P(hit) rose more than the allowed jitter: {p_hits:?}"
926            );
927        }
928        // The overall trend across the full sweep must be a clear decline.
929        assert!(
930            p_hits.first().unwrap() - p_hits.last().unwrap() > 0.2,
931            "expected a clear overall decline across the sweep: {p_hits:?}"
932        );
933    }
934
935    // ---- Variance-attribution shares --------------------------------------------------------
936
937    #[test]
938    fn variance_shares_sum_to_one_when_multiple_sources_are_active() {
939        let inputs = test_base_inputs();
940        let wind = WindConditions::default();
941        let atmosphere = test_atmosphere(&inputs);
942        let range_m: f64 = 300.0;
943        let solver_max_range = range_m.max(1000.0) * 2.0;
944        let baseline = wez_solve_target_plane(
945            inputs.clone(),
946            wind.clone(),
947            atmosphere.clone(),
948            solver_max_range,
949            range_m,
950        )
951        .expect("valid test baseline solve");
952
953        let shares = wez_variance_shares(
954            &inputs,
955            &wind,
956            &atmosphere,
957            solver_max_range,
958            range_m,
959            &baseline,
960            /* velocity_std_dev */ 1.0,
961            /* angle_std_dev_rad */ 0.001,
962            /* bc_std_dev */ 0.01,
963            /* azimuth_std_dev_rad */ 0.0005,
964            /* wind_speed_std_dev */ 0.4,
965            /* wind_call_error_std_dev */ 1.2,
966            /* wind_direction_std_dev_rad */ 0.02,
967        )
968        .expect("valid test attribution solve");
969
970        let sum = shares.wind_call + shares.mv_sd + shares.other;
971        assert!(
972            (sum - 1.0).abs() < 1e-9,
973            "shares should sum to ~1.0, got {sum} ({shares:?})"
974        );
975        for share in [shares.wind_call, shares.mv_sd, shares.other] {
976            assert!((0.0..=1.0).contains(&share), "share out of range: {share}");
977        }
978        assert!(shares.dominant().is_some());
979    }
980
981    #[test]
982    fn variance_shares_are_all_zero_with_no_dispersion_sources() {
983        let inputs = test_base_inputs();
984        let wind = WindConditions::default();
985        let atmosphere = test_atmosphere(&inputs);
986        let range_m: f64 = 300.0;
987        let solver_max_range = range_m.max(1000.0) * 2.0;
988        let baseline = wez_solve_target_plane(
989            inputs.clone(),
990            wind.clone(),
991            atmosphere.clone(),
992            solver_max_range,
993            range_m,
994        )
995        .expect("valid test baseline solve");
996
997        let shares = wez_variance_shares(
998            &inputs,
999            &wind,
1000            &atmosphere,
1001            solver_max_range,
1002            range_m,
1003            &baseline,
1004            0.0,
1005            0.0,
1006            0.0,
1007            0.0,
1008            0.0,
1009            0.0,
1010            0.0,
1011        )
1012        .expect("valid test attribution solve");
1013
1014        assert_eq!(shares.wind_call, 0.0);
1015        assert_eq!(shares.mv_sd, 0.0);
1016        assert_eq!(shares.other, 0.0);
1017        assert!(shares.dominant().is_none());
1018    }
1019
1020    #[test]
1021    fn wind_call_bucket_dominates_when_it_is_the_only_active_source() {
1022        let inputs = test_base_inputs();
1023        let wind = WindConditions::default();
1024        let atmosphere = test_atmosphere(&inputs);
1025        let range_m: f64 = 300.0;
1026        let solver_max_range = range_m.max(1000.0) * 2.0;
1027        let baseline = wez_solve_target_plane(
1028            inputs.clone(),
1029            wind.clone(),
1030            atmosphere.clone(),
1031            solver_max_range,
1032            range_m,
1033        )
1034        .expect("valid test baseline solve");
1035
1036        let shares = wez_variance_shares(
1037            &inputs,
1038            &wind,
1039            &atmosphere,
1040            solver_max_range,
1041            range_m,
1042            &baseline,
1043            0.0,
1044            0.0,
1045            0.0,
1046            0.0,
1047            0.0,
1048            /* wind_call_error_std_dev */ 3.0,
1049            0.0,
1050        )
1051        .expect("valid test attribution solve");
1052
1053        assert!((shares.wind_call - 1.0).abs() < 1e-9);
1054        assert_eq!(shares.mv_sd, 0.0);
1055        assert_eq!(shares.other, 0.0);
1056        assert_eq!(shares.dominant(), Some(WezErrorBucket::WindCall));
1057    }
1058}