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/// * `cant` — rifle cant, DEGREES, positive = clockwise from the shooter.
505///
506/// A fixed, distinct seed per range step (`0x57_45_5A_00 ^ step_index`) keeps a sweep
507/// reproducible run-to-run while still drawing independent samples at each range.
508#[allow(
509    clippy::too_many_arguments,
510    reason = "flat arguments mirror the stable Monte Carlo CLI command shape (MBA-1317)"
511)]
512pub fn compute_wez(
513    velocity: f64,
514    angle: f64,
515    bc: f64,
516    mass: f64,
517    diameter: f64,
518    num_sims: usize,
519    velocity_std: f64,
520    angle_std: f64,
521    bc_std: f64,
522    wind_std: f64,
523    wind_direction_std: f64,
524    wind_speed: f64,
525    wind_direction: f64,
526    wind_vertical: f64,
527    wind_call_error: f64,
528    target_size: TargetSizeMetric,
529    wez_start: f64,
530    wez_end: f64,
531    wez_step: f64,
532    drag_model: DragModel,
533    custom_drag_table: Option<DragTable>,
534    cant: f64,
535) -> Result<WezResult, Box<dyn Error>> {
536    if !(wez_step > 0.0 && wez_step.is_finite()) {
537        return Err("--wez-step must be a positive, finite distance".into());
538    }
539    if !wez_start.is_finite() || !wez_end.is_finite() || wez_end < wez_start {
540        return Err("--wez-end must be finite and >= --wez-start".into());
541    }
542
543    // Same bore-height/ground convention as the base `monte-carlo` command (MBA-967).
544    let bore_height_metric = 1.5_f64;
545    let base_inputs = BallisticInputs {
546        muzzle_velocity: velocity,
547        muzzle_angle: angle.to_radians(),
548        bc_value: bc,
549        bc_type: drag_model,
550        bullet_mass: mass,
551        bullet_diameter: diameter,
552        muzzle_height: bore_height_metric,
553        ground_threshold: 0.0,
554        custom_drag_table,
555        cant_angle: cant.to_radians(),
556        ..Default::default()
557    };
558    let base_wind = WindConditions {
559        speed: wind_speed,
560        direction: wind_direction.to_radians(),
561        vertical_speed: wind_vertical,
562    };
563
564    // The shooter's wind-call error is a dispersion source distinct from the ballistic
565    // (gust-to-gust) wind-speed uncertainty --wind-std already models, but both perturb the same
566    // physical channel (wind speed fed to the solve). As independent random errors they compose
567    // in quadrature -- not by simple addition -- into the effective standard deviation the
568    // underlying Monte Carlo sampler uses.
569    let combined_wind_speed_std = wind_std.hypot(wind_call_error);
570
571    // Matches run_monte_carlo's own convention: horizontal (azimuth) aim dispersion defaults to
572    // half of the vertical (elevation) dispersion.
573    let angle_std_rad = angle_std.to_radians();
574    let azimuth_std_dev = angle_std_rad * 0.5;
575    let wind_direction_std_rad = wind_direction_std.to_radians();
576
577    // The fixed reference the WEZ target box is centered on: the horizontal line of sight,
578    // extended straight (not the curved bullet path). This is what makes the zero-uncertainty
579    // case a genuine step function -- ballistic drop below this fixed line, not just random
580    // dispersion, can carry the bullet outside the box as range grows. It does NOT change per
581    // range step: a WEZ sweep answers "how far can I engage this target size with ONE hold",
582    // the classic point-blank-range question, not "assuming I re-dial for every range".
583    let atmosphere = AtmosphericConditions {
584        temperature: base_inputs.temperature,
585        pressure: base_inputs.pressure,
586        humidity: base_inputs.humidity_percent(),
587        altitude: base_inputs.altitude,
588    };
589    let line_of_sight_height_m = base_inputs.muzzle_height + base_inputs.sight_height;
590
591    let mut ranges_m = Vec::new();
592    let mut next = wez_start;
593    // Guard against an unbounded loop from a step so small that floating-point addition never
594    // advances `next` past `wez_end`.
595    for _ in 0..100_000 {
596        if next > wez_end + wez_step * 1e-9 {
597            break;
598        }
599        ranges_m.push(next);
600        next += wez_step;
601    }
602
603    let mut rows = Vec::with_capacity(ranges_m.len());
604    for (step_index, &range_m) in ranges_m.iter().enumerate() {
605        let solver_max_range = range_m.max(1000.0) * 2.0;
606        let baseline = wez_solve_target_plane(
607            base_inputs.clone(),
608            base_wind.clone(),
609            atmosphere.clone(),
610            solver_max_range,
611            range_m,
612        )?;
613        let baseline_reached = baseline.x >= range_m - 1e-6;
614
615        let mc_params = MonteCarloParams {
616            num_simulations: num_sims,
617            velocity_std_dev: velocity_std,
618            angle_std_dev: angle_std_rad,
619            bc_std_dev: bc_std,
620            wind_speed_std_dev: combined_wind_speed_std,
621            target_distance: Some(range_m),
622            base_wind_speed: wind_speed,
623            base_wind_direction: wind_direction.to_radians(),
624            azimuth_std_dev,
625        };
626
627        // A fixed, distinct seed per range step keeps a sweep reproducible run-to-run while
628        // still drawing independent samples at each range.
629        let seed = 0x57_45_5A_00_u64 ^ (step_index as u64);
630        let p_hit = match crate::run_monte_carlo_with_wind_and_direction_std_dev_seeded(
631            base_inputs.clone(),
632            base_wind.clone(),
633            mc_params,
634            wind_direction_std_rad,
635            seed,
636        ) {
637            Ok(results) => {
638                wez_p_hit(&results, &baseline, line_of_sight_height_m, target_size)
639            }
640            // The baseline never reached this range plane at all -> every sample is a definite
641            // miss for it.
642            Err(_) => 0.0,
643        };
644
645        let (shares, attribution_unavailable) = if baseline_reached {
646            (
647                wez_variance_shares(
648                    &base_inputs,
649                    &base_wind,
650                    &atmosphere,
651                    solver_max_range,
652                    range_m,
653                    &baseline,
654                    velocity_std,
655                    angle_std_rad,
656                    bc_std,
657                    azimuth_std_dev,
658                    wind_std,
659                    wind_call_error,
660                    wind_direction_std_rad,
661                )?,
662                false,
663            )
664        } else {
665            (WezVarianceShares::default(), true)
666        };
667
668        rows.push(WezRow {
669            range_m,
670            p_hit,
671            dominant_error_source: shares.dominant(),
672            wind_call_share: shares.wind_call,
673            mv_sd_share: shares.mv_sd,
674            other_share: shares.other,
675            attribution_unavailable,
676        });
677    }
678
679    Ok(WezResult {
680        target_size: match target_size {
681            TargetSizeMetric::Rect { width_m, height_m } => WezTargetSizeJson {
682                width_m: Some(width_m),
683                height_m: Some(height_m),
684                radius_m: None,
685            },
686            TargetSizeMetric::Radius { radius_m } => WezTargetSizeJson {
687                width_m: None,
688                height_m: None,
689                radius_m: Some(radius_m),
690            },
691        },
692        wind_speed_std_mps: wind_std,
693        wind_call_error_mps: wind_call_error,
694        combined_wind_speed_std_mps: combined_wind_speed_std,
695        num_sims_per_step: num_sims,
696        rows,
697    })
698}
699
700#[cfg(test)]
701mod wez_tests {
702    use super::*;
703
704    // A modest .308/168gr load, zeroed at 300 m with a shallow elevation that keeps the
705    // trajectory well above ground for the whole 50-600 m range these tests sweep -- chosen with
706    // `ballistics zero` (see CLI_USAGE.md's WEZ worked example for the imperial equivalent).
707    fn test_base_inputs() -> BallisticInputs {
708        BallisticInputs {
709            muzzle_velocity: 823.0, // ~2700 fps
710            muzzle_angle: 0.001274, // ~0.073 degrees: a 300 m zero for this load
711            bc_value: 0.475,
712            bullet_mass: 0.010_886, // 168 gr
713            bullet_diameter: 0.007_82, // .308 in
714            muzzle_height: 1.5,
715            ground_threshold: 0.0,
716            ..Default::default()
717        }
718    }
719
720    fn test_atmosphere(inputs: &BallisticInputs) -> AtmosphericConditions {
721        AtmosphericConditions {
722            temperature: inputs.temperature,
723            pressure: inputs.pressure,
724            humidity: inputs.humidity_percent(),
725            altitude: inputs.altitude,
726        }
727    }
728
729    // ---- parse_target_size --------------------------------------------------------------
730
731    #[test]
732    fn parse_target_size_accepts_a_wxh_rectangle() {
733        assert_eq!(
734            parse_target_size("18x30").unwrap(),
735            TargetSize::Rect {
736                width: 18.0,
737                height: 30.0
738            }
739        );
740        // Case-insensitive separator and surrounding whitespace.
741        assert_eq!(
742            parse_target_size(" 18.5X30.25 ").unwrap(),
743            TargetSize::Rect {
744                width: 18.5,
745                height: 30.25
746            }
747        );
748    }
749
750    #[test]
751    fn parse_target_size_accepts_a_single_radius() {
752        assert_eq!(parse_target_size("12").unwrap(), TargetSize::Radius(12.0));
753        assert_eq!(parse_target_size(" 0.5 ").unwrap(), TargetSize::Radius(0.5));
754    }
755
756    #[test]
757    fn parse_target_size_rejects_garbage() {
758        for bad in [
759            "",
760            "   ",
761            "abc",
762            "18xthirty",
763            "eighteenx30",
764            "18x30x40",
765            "0",
766            "-5",
767            "18x-5",
768            "18x0",
769            "NaN",
770        ] {
771            assert!(
772                parse_target_size(bad).is_err(),
773                "expected an error for {bad:?}"
774            );
775        }
776    }
777
778    // ---- WEZ hit-probability step function -----------------------------------------------
779
780    #[test]
781    fn zero_uncertainty_is_a_step_function_in_range() {
782        let inputs = test_base_inputs();
783        let wind = WindConditions::default();
784        let atmosphere = test_atmosphere(&inputs);
785        // 18x30 box: 0.4572 m x 0.762 m.
786        let target = TargetSizeMetric::Rect {
787            width_m: 0.4572,
788            height_m: 0.762,
789        };
790        let los_height_m = inputs.muzzle_height + inputs.sight_height;
791
792        let mc_params = MonteCarloParams {
793            num_simulations: 20,
794            velocity_std_dev: 0.0,
795            angle_std_dev: 0.0,
796            bc_std_dev: 0.0,
797            wind_speed_std_dev: 0.0,
798            target_distance: None,
799            base_wind_speed: 0.0,
800            base_wind_direction: 0.0,
801            azimuth_std_dev: 0.0,
802        };
803
804        let mut p_hits = Vec::new();
805        for &range_m in &[50.0_f64, 100.0, 150.0, 200.0, 250.0, 300.0, 350.0, 400.0] {
806            let solver_max_range = range_m.max(1000.0) * 2.0;
807            let baseline = wez_solve_target_plane(
808                inputs.clone(),
809                wind.clone(),
810                atmosphere.clone(),
811                solver_max_range,
812                range_m,
813            )
814            .expect("valid test baseline solve");
815            let mut params = mc_params.clone();
816            params.target_distance = Some(range_m);
817            let results = crate::run_monte_carlo_with_wind_and_direction_std_dev_seeded(
818                inputs.clone(),
819                wind.clone(),
820                params,
821                0.0,
822                0xA11CE,
823            )
824            .expect("zero-uncertainty solve");
825            let p_hit = wez_p_hit(&results, &baseline, los_height_m, target);
826            // Every one of the (identical, undispersed) samples must agree: exactly a hit or
827            // exactly a miss, never a fractional probability.
828            assert!(
829                p_hit == 0.0 || p_hit == 1.0,
830                "range {range_m} m: expected a step (0.0 or 1.0), got {p_hit}"
831            );
832            p_hits.push((range_m, p_hit));
833        }
834
835        assert!(
836            p_hits.iter().any(|&(_, p)| p == 1.0),
837            "expected at least one in-box range close to the muzzle: {p_hits:?}"
838        );
839        assert!(
840            p_hits.iter().any(|&(_, p)| p == 0.0),
841            "expected at least one out-of-box range far downrange: {p_hits:?}"
842        );
843        // Once it steps down to a miss, a plain (unheld) trajectory that has already passed its
844        // zero does not come back into a fixed-size box further downrange.
845        let first_miss = p_hits.iter().position(|&(_, p)| p == 0.0);
846        if let Some(idx) = first_miss {
847            assert!(
848                p_hits[idx..].iter().all(|&(_, p)| p == 0.0),
849                "expected the box exit to be permanent for the rest of the sweep: {p_hits:?}"
850            );
851        }
852    }
853
854    // ---- P(hit) monotonicity with real dispersion -----------------------------------------
855
856    #[test]
857    fn p_hit_is_monotone_non_increasing_with_range() {
858        let inputs = test_base_inputs();
859        let wind = WindConditions::default();
860        let atmosphere = test_atmosphere(&inputs);
861        let target = TargetSizeMetric::Rect {
862            width_m: 0.4572,
863            height_m: 0.762,
864        };
865        let los_height_m = inputs.muzzle_height + inputs.sight_height;
866        let wind_call_error = 1.5_f64; // m/s
867        let wind_std = 0.5_f64; // m/s
868        let combined_wind_std = wind_std.hypot(wind_call_error);
869
870        let mc_params = MonteCarloParams {
871            num_simulations: 500, // a fixed seed keeps this run-to-run deterministic
872            velocity_std_dev: 1.0,
873            angle_std_dev: 0.001,
874            bc_std_dev: 0.01,
875            wind_speed_std_dev: combined_wind_std,
876            target_distance: None,
877            base_wind_speed: 0.0,
878            base_wind_direction: 0.0,
879            azimuth_std_dev: 0.0005,
880        };
881
882        let ranges_m = [100.0_f64, 200.0, 300.0, 400.0, 500.0, 600.0];
883        let mut p_hits = Vec::new();
884        for (step_index, &range_m) in ranges_m.iter().enumerate() {
885            let solver_max_range = range_m.max(1000.0) * 2.0;
886            let baseline = wez_solve_target_plane(
887                inputs.clone(),
888                wind.clone(),
889                atmosphere.clone(),
890                solver_max_range,
891                range_m,
892            )
893            .expect("valid test baseline solve");
894            let mut params = mc_params.clone();
895            params.target_distance = Some(range_m);
896            let seed = 0x57_45_5A_00_u64 ^ (step_index as u64);
897            let results = crate::run_monte_carlo_with_wind_and_direction_std_dev_seeded(
898                inputs.clone(),
899                wind.clone(),
900                params,
901                0.0,
902                seed,
903            )
904            .expect("dispersed solve");
905            p_hits.push(wez_p_hit(&results, &baseline, los_height_m, target));
906        }
907
908        // A large sample count plus a fixed seed makes this close to the noiseless limit, but a
909        // finite Monte Carlo estimate can still tick up by a hair at the boundary between two
910        // adjacent steps -- allow a small generous tolerance rather than asserting exact
911        // non-increase (MBA-1317 test spec).
912        let tolerance = 0.03;
913        for pair in p_hits.windows(2) {
914            assert!(
915                pair[1] <= pair[0] + tolerance,
916                "P(hit) rose more than the allowed jitter: {p_hits:?}"
917            );
918        }
919        // The overall trend across the full sweep must be a clear decline.
920        assert!(
921            p_hits.first().unwrap() - p_hits.last().unwrap() > 0.2,
922            "expected a clear overall decline across the sweep: {p_hits:?}"
923        );
924    }
925
926    // ---- Variance-attribution shares --------------------------------------------------------
927
928    #[test]
929    fn variance_shares_sum_to_one_when_multiple_sources_are_active() {
930        let inputs = test_base_inputs();
931        let wind = WindConditions::default();
932        let atmosphere = test_atmosphere(&inputs);
933        let range_m: f64 = 300.0;
934        let solver_max_range = range_m.max(1000.0) * 2.0;
935        let baseline = wez_solve_target_plane(
936            inputs.clone(),
937            wind.clone(),
938            atmosphere.clone(),
939            solver_max_range,
940            range_m,
941        )
942        .expect("valid test baseline solve");
943
944        let shares = wez_variance_shares(
945            &inputs,
946            &wind,
947            &atmosphere,
948            solver_max_range,
949            range_m,
950            &baseline,
951            /* velocity_std_dev */ 1.0,
952            /* angle_std_dev_rad */ 0.001,
953            /* bc_std_dev */ 0.01,
954            /* azimuth_std_dev_rad */ 0.0005,
955            /* wind_speed_std_dev */ 0.4,
956            /* wind_call_error_std_dev */ 1.2,
957            /* wind_direction_std_dev_rad */ 0.02,
958        )
959        .expect("valid test attribution solve");
960
961        let sum = shares.wind_call + shares.mv_sd + shares.other;
962        assert!(
963            (sum - 1.0).abs() < 1e-9,
964            "shares should sum to ~1.0, got {sum} ({shares:?})"
965        );
966        for share in [shares.wind_call, shares.mv_sd, shares.other] {
967            assert!((0.0..=1.0).contains(&share), "share out of range: {share}");
968        }
969        assert!(shares.dominant().is_some());
970    }
971
972    #[test]
973    fn variance_shares_are_all_zero_with_no_dispersion_sources() {
974        let inputs = test_base_inputs();
975        let wind = WindConditions::default();
976        let atmosphere = test_atmosphere(&inputs);
977        let range_m: f64 = 300.0;
978        let solver_max_range = range_m.max(1000.0) * 2.0;
979        let baseline = wez_solve_target_plane(
980            inputs.clone(),
981            wind.clone(),
982            atmosphere.clone(),
983            solver_max_range,
984            range_m,
985        )
986        .expect("valid test baseline solve");
987
988        let shares = wez_variance_shares(
989            &inputs,
990            &wind,
991            &atmosphere,
992            solver_max_range,
993            range_m,
994            &baseline,
995            0.0,
996            0.0,
997            0.0,
998            0.0,
999            0.0,
1000            0.0,
1001            0.0,
1002        )
1003        .expect("valid test attribution solve");
1004
1005        assert_eq!(shares.wind_call, 0.0);
1006        assert_eq!(shares.mv_sd, 0.0);
1007        assert_eq!(shares.other, 0.0);
1008        assert!(shares.dominant().is_none());
1009    }
1010
1011    #[test]
1012    fn wind_call_bucket_dominates_when_it_is_the_only_active_source() {
1013        let inputs = test_base_inputs();
1014        let wind = WindConditions::default();
1015        let atmosphere = test_atmosphere(&inputs);
1016        let range_m: f64 = 300.0;
1017        let solver_max_range = range_m.max(1000.0) * 2.0;
1018        let baseline = wez_solve_target_plane(
1019            inputs.clone(),
1020            wind.clone(),
1021            atmosphere.clone(),
1022            solver_max_range,
1023            range_m,
1024        )
1025        .expect("valid test baseline solve");
1026
1027        let shares = wez_variance_shares(
1028            &inputs,
1029            &wind,
1030            &atmosphere,
1031            solver_max_range,
1032            range_m,
1033            &baseline,
1034            0.0,
1035            0.0,
1036            0.0,
1037            0.0,
1038            0.0,
1039            /* wind_call_error_std_dev */ 3.0,
1040            0.0,
1041        )
1042        .expect("valid test attribution solve");
1043
1044        assert!((shares.wind_call - 1.0).abs() < 1e-9);
1045        assert_eq!(shares.mv_sd, 0.0);
1046        assert_eq!(shares.other, 0.0);
1047        assert_eq!(shares.dominant(), Some(WezErrorBucket::WindCall));
1048    }
1049}