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