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