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