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