use std::error::Error;
use nalgebra::Vector3;
use serde::Serialize;
use crate::cli_api::UnitSystem;
use crate::drag::DragTable;
use crate::perturbation::{central_difference, InputAxis, KernelError};
use crate::solve_json::{
DragModelV1, ResolvedAtmosphereV1, ResolvedConstantWindV1, ResolvedEffectsV1,
ResolvedProjectileV1, ResolvedRifleV1, ResolvedSamplingV1, ResolvedShotV1,
ResolvedSolveRequestV1, ResolvedSolverV1, ResolvedWindV1, SchemaVersionV1, SolverMethodV1,
TwistDirectionV1,
};
use crate::{
AtmosphericConditions, BallisticInputs, BallisticsError, DragModel, MonteCarloParams,
MonteCarloResults, TrajectorySolver, WindConditions,
};
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum TargetSize {
Rect { width: f64, height: f64 },
Radius(f64),
}
pub fn parse_target_size(spec: &str) -> Result<TargetSize, String> {
let trimmed = spec.trim();
if trimmed.is_empty() {
return Err("expected a size like \"18x30\" or a single radius like \"12\"".to_string());
}
let x_positions: Vec<usize> = trimmed
.char_indices()
.filter(|(_, c)| *c == 'x' || *c == 'X')
.map(|(i, _)| i)
.collect();
match x_positions.len() {
0 => {
let radius: f64 = trimmed
.parse()
.map_err(|_| format!("\"{trimmed}\" is not a number or a WIDTHxHEIGHT pair"))?;
if !(radius.is_finite() && radius > 0.0) {
return Err(format!(
"radius must be a positive, finite number, got {radius}"
));
}
Ok(TargetSize::Radius(radius))
}
1 => {
let idx = x_positions[0];
let width_str = &trimmed[..idx];
let height_str = &trimmed[idx + 1..];
let width: f64 = width_str
.trim()
.parse()
.map_err(|_| format!("\"{}\" is not a valid width", width_str.trim()))?;
let height: f64 = height_str
.trim()
.parse()
.map_err(|_| format!("\"{}\" is not a valid height", height_str.trim()))?;
if !(width.is_finite() && width > 0.0 && height.is_finite() && height > 0.0) {
return Err(format!(
"width and height must be positive, finite numbers, got {width}x{height}"
));
}
Ok(TargetSize::Rect { width, height })
}
_ => Err(format!(
"\"{trimmed}\" has more than one 'x' separator; expected WIDTHxHEIGHT or a single radius"
)),
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum TargetSizeMetric {
Rect { width_m: f64, height_m: f64 },
Radius { radius_m: f64 },
}
fn target_size_to_metric(val: f64, units: UnitSystem) -> f64 {
match units {
UnitSystem::Metric => val * 0.01, UnitSystem::Imperial => val * 0.0254, }
}
impl TargetSize {
pub fn to_metric(self, units: UnitSystem) -> TargetSizeMetric {
match self {
TargetSize::Rect { width, height } => TargetSizeMetric::Rect {
width_m: target_size_to_metric(width, units),
height_m: target_size_to_metric(height, units),
},
TargetSize::Radius(radius) => TargetSizeMetric::Radius {
radius_m: target_size_to_metric(radius, units),
},
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum WezErrorBucket {
WindCall,
MvSd,
Other,
}
impl std::fmt::Display for WezErrorBucket {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let label = match self {
WezErrorBucket::WindCall => "wind_call",
WezErrorBucket::MvSd => "mv_sd",
WezErrorBucket::Other => "other",
};
write!(f, "{label}")
}
}
#[derive(Debug, Clone, Copy, Default, Serialize)]
struct WezVarianceShares {
wind_call: f64,
mv_sd: f64,
other: f64,
}
impl WezVarianceShares {
fn dominant(&self) -> Option<WezErrorBucket> {
[
(WezErrorBucket::WindCall, self.wind_call),
(WezErrorBucket::MvSd, self.mv_sd),
(WezErrorBucket::Other, self.other),
]
.into_iter()
.filter(|(_, share)| *share > 0.0)
.max_by(|a, b| a.1.total_cmp(&b.1))
.map(|(bucket, _)| bucket)
}
}
fn wez_solve_target_plane(
inputs: BallisticInputs,
wind: WindConditions,
atmosphere: AtmosphericConditions,
solver_max_range: f64,
target_distance_m: f64,
) -> Result<Vector3<f64>, BallisticsError> {
let mut solver = TrajectorySolver::new(inputs, wind, atmosphere);
solver.set_max_range(solver_max_range);
let result = solver.solve()?;
Ok(result
.position_at_range(target_distance_m)
.expect("WEZ attribution solve: non-empty trajectory always has a last point"))
}
fn wez_kernel_drag_model(model: DragModel) -> Option<DragModelV1> {
match model {
DragModel::G1 => Some(DragModelV1::G1),
DragModel::G6 => Some(DragModelV1::G6),
DragModel::G7 => Some(DragModelV1::G7),
DragModel::G8 => Some(DragModelV1::G8),
DragModel::G2 | DragModel::G5 | DragModel::GI | DragModel::GS | DragModel::RA4 => None,
}
}
fn wez_resolved_request(
base_inputs: &BallisticInputs,
base_wind: &WindConditions,
solver_max_range: f64,
) -> Option<ResolvedSolveRequestV1> {
if base_inputs.custom_drag_table.is_some() {
return None;
}
let drag_model = wez_kernel_drag_model(base_inputs.bc_type)?;
Some(ResolvedSolveRequestV1 {
schema_version: SchemaVersionV1,
projectile: ResolvedProjectileV1 {
mass_kg: base_inputs.bullet_mass,
diameter_m: base_inputs.bullet_diameter,
length_m: Some(base_inputs.bullet_length),
drag_model,
ballistic_coefficient: base_inputs.bc_value,
},
rifle: ResolvedRifleV1 {
muzzle_velocity_mps: base_inputs.muzzle_velocity,
sight_height_m: base_inputs.sight_height,
muzzle_height_m: base_inputs.muzzle_height,
twist_rate_m_per_turn: base_inputs.twist_rate * 0.0254,
twist_direction: if base_inputs.is_twist_right {
TwistDirectionV1::Right
} else {
TwistDirectionV1::Left
},
sight_offset_lateral_m: Some(base_inputs.sight_offset_lateral_m),
},
shot: ResolvedShotV1 {
max_range_m: solver_max_range,
zero_distance_m: None,
muzzle_angle_rad: base_inputs.muzzle_angle,
aim_azimuth_rad: base_inputs.azimuth_angle,
shot_azimuth_rad: base_inputs.shot_azimuth,
shooting_angle_rad: base_inputs.shooting_angle,
cant_angle_rad: base_inputs.cant_angle,
target_height_m: base_inputs.target_height,
ground_threshold_m: base_inputs.ground_threshold,
zero_poi_up_m: Some(base_inputs.zero_poi_vertical_m),
zero_poi_right_m: Some(base_inputs.zero_poi_horizontal_m),
drops_reference: None,
},
atmosphere: ResolvedAtmosphereV1 {
altitude_m: base_inputs.altitude,
temperature_k: base_inputs.temperature + 273.15,
pressure_pa: base_inputs.pressure * 100.0,
relative_humidity: base_inputs.humidity,
latitude_rad: base_inputs.latitude.map(f64::to_radians),
pressure_reference: None,
},
wind: ResolvedWindV1::Constant(ResolvedConstantWindV1 {
speed_mps: base_wind.speed,
direction_from_rad: base_wind.direction,
vertical_speed_mps: base_wind.vertical_speed,
wind_reference: None,
}),
solver: ResolvedSolverV1 {
method: SolverMethodV1::Rk45,
time_step_s: 0.001, },
effects: ResolvedEffectsV1 {
magnus: false,
coriolis: false,
enhanced_spin_drift: false,
},
sampling: ResolvedSamplingV1 { interval_m: 10.0 },
reticle: None,
})
}
#[allow(
clippy::too_many_arguments,
reason = "flat sigma list mirrors the Monte Carlo sampler's own parameter set (MBA-1317)"
)]
fn wez_variance_shares(
base_resolved: &ResolvedSolveRequestV1,
target_distance_m: f64,
velocity_std_dev: f64,
angle_std_dev_rad: f64,
bc_std_dev: f64,
azimuth_std_dev_rad: f64,
wind_speed_std_dev: f64,
wind_call_error_std_dev: f64,
wind_direction_std_dev_rad: f64,
) -> Result<Option<WezVarianceShares>, KernelError> {
let displacement_sq = |d: &crate::perturbation::Derivative, sigma: f64| -> f64 {
if sigma.is_nan() || sigma <= 0.0 {
0.0
} else {
(d.d_drop_d_x * sigma).powi(2) + (d.d_windage_d_x * sigma).powi(2)
}
};
let derivative_for =
|axis: InputAxis| -> Result<Option<crate::perturbation::Derivative>, KernelError> {
match central_difference(base_resolved, axis, &[target_distance_m], None) {
Ok(d) => Ok(Some(d[0])),
Err(e) if crate::error_budget::unavailable_reason(&e).is_some() => Ok(None),
Err(e) => Err(e),
}
};
let contribution = |axis: InputAxis, sigma: f64| -> Result<Option<f64>, KernelError> {
if sigma.is_nan() || sigma <= 0.0 {
return Ok(Some(0.0));
}
Ok(derivative_for(axis)?.map(|d| displacement_sq(&d, sigma)))
};
let Some(mv_sd_var) = contribution(InputAxis::MuzzleVelocityMps, velocity_std_dev)? else {
return Ok(None);
};
let mut other_var = 0.0;
for (axis, sigma) in [
(InputAxis::MuzzleAngle, angle_std_dev_rad),
(InputAxis::BallisticCoefficient, bc_std_dev),
(InputAxis::AimAzimuth, azimuth_std_dev_rad),
(InputAxis::WindDirection, wind_direction_std_dev_rad),
] {
let Some(v) = contribution(axis, sigma)? else {
return Ok(None);
};
other_var += v;
}
let wind_call_var = if (wind_speed_std_dev.is_nan() || wind_speed_std_dev <= 0.0)
&& (wind_call_error_std_dev.is_nan() || wind_call_error_std_dev <= 0.0)
{
0.0
} else {
let Some(d) = derivative_for(InputAxis::WindSpeed)? else {
return Ok(None);
};
other_var += displacement_sq(&d, wind_speed_std_dev);
displacement_sq(&d, wind_call_error_std_dev)
};
let total = wind_call_var + mv_sd_var + other_var;
if total.is_nan() || total <= 0.0 {
return Ok(Some(WezVarianceShares::default()));
}
Ok(Some(WezVarianceShares {
wind_call: wind_call_var / total,
mv_sd: mv_sd_var / total,
other: other_var / total,
}))
}
fn wez_p_hit(
results: &MonteCarloResults,
baseline: &Vector3<f64>,
line_of_sight_height_m: f64,
target_size: TargetSizeMetric,
) -> f64 {
if results.impact_positions.is_empty() {
return 0.0;
}
let hits = results
.impact_positions
.iter()
.filter(|deviation| {
let absolute_y = baseline.y + deviation.y;
let absolute_z = baseline.z + deviation.z;
let drop_from_los = absolute_y - line_of_sight_height_m;
match target_size {
TargetSizeMetric::Rect { width_m, height_m } => {
drop_from_los.abs() <= height_m / 2.0 && absolute_z.abs() <= width_m / 2.0
}
TargetSizeMetric::Radius { radius_m } => {
(drop_from_los * drop_from_los + absolute_z * absolute_z).sqrt() <= radius_m
}
}
})
.count();
hits as f64 / results.impact_positions.len() as f64
}
#[derive(Debug, Clone, Serialize)]
pub struct WezRow {
pub range_m: f64,
pub p_hit: f64,
pub dominant_error_source: Option<WezErrorBucket>,
pub wind_call_share: f64,
pub mv_sd_share: f64,
pub other_share: f64,
pub attribution_unavailable: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct WezTargetSizeJson {
#[serde(skip_serializing_if = "Option::is_none")]
pub width_m: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub height_m: Option<f64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub radius_m: Option<f64>,
}
#[derive(Debug, Clone, Serialize)]
pub struct WezResult {
pub target_size: WezTargetSizeJson,
pub wind_speed_std_mps: f64,
pub wind_call_error_mps: f64,
pub combined_wind_speed_std_mps: f64,
pub num_sims_per_step: usize,
pub rows: Vec<WezRow>,
}
#[allow(
clippy::too_many_arguments,
reason = "flat arguments mirror the stable Monte Carlo CLI command shape (MBA-1317)"
)]
pub fn compute_wez(
velocity: f64,
angle: f64,
bc: f64,
mass: f64,
diameter: f64,
num_sims: usize,
velocity_std: f64,
angle_std: f64,
bc_std: f64,
wind_std: f64,
wind_direction_std: f64,
wind_speed: f64,
wind_direction: f64,
wind_vertical: f64,
wind_call_error: f64,
target_size: TargetSizeMetric,
wez_start: f64,
wez_end: f64,
wez_step: f64,
drag_model: DragModel,
custom_drag_table: Option<DragTable>,
cd_scale: f64,
cant: f64,
sight_offset_lateral_m: f64,
) -> Result<WezResult, Box<dyn Error>> {
if !(wez_step > 0.0 && wez_step.is_finite()) {
return Err("--wez-step must be a positive, finite distance".into());
}
if !wez_start.is_finite() || !wez_end.is_finite() || wez_end < wez_start {
return Err("--wez-end must be finite and >= --wez-start".into());
}
let bore_height_metric = 1.5_f64;
let base_inputs = BallisticInputs {
muzzle_velocity: velocity,
muzzle_angle: angle.to_radians(),
bc_value: bc,
bc_type: drag_model,
bullet_mass: mass,
bullet_diameter: diameter,
muzzle_height: bore_height_metric,
ground_threshold: 0.0,
custom_drag_table,
cd_scale,
cant_angle: cant.to_radians(),
sight_offset_lateral_m,
..Default::default()
};
let base_wind = WindConditions {
speed: wind_speed,
direction: wind_direction.to_radians(),
vertical_speed: wind_vertical,
};
let combined_wind_speed_std = wind_std.hypot(wind_call_error);
let angle_std_rad = angle_std.to_radians();
let azimuth_std_dev = angle_std_rad * 0.5;
let wind_direction_std_rad = wind_direction_std.to_radians();
let atmosphere = AtmosphericConditions {
temperature: base_inputs.temperature,
pressure: base_inputs.pressure,
humidity: base_inputs.humidity_percent(),
altitude: base_inputs.altitude,
};
let line_of_sight_height_m = base_inputs.muzzle_height + base_inputs.sight_height;
let mut ranges_m = Vec::new();
let mut next = wez_start;
for _ in 0..100_000 {
if next > wez_end + wez_step * 1e-9 {
break;
}
ranges_m.push(next);
next += wez_step;
}
let mut rows = Vec::with_capacity(ranges_m.len());
for (step_index, &range_m) in ranges_m.iter().enumerate() {
let solver_max_range = range_m.max(1000.0) * 2.0;
let baseline = wez_solve_target_plane(
base_inputs.clone(),
base_wind.clone(),
atmosphere.clone(),
solver_max_range,
range_m,
)?;
let baseline_reached = baseline.x >= range_m - 1e-6;
let mc_params = MonteCarloParams {
num_simulations: num_sims,
velocity_std_dev: velocity_std,
angle_std_dev: angle_std_rad,
bc_std_dev: bc_std,
wind_speed_std_dev: combined_wind_speed_std,
target_distance: Some(range_m),
base_wind_speed: wind_speed,
base_wind_direction: wind_direction.to_radians(),
azimuth_std_dev,
};
let seed = 0x57_45_5A_00_u64 ^ (step_index as u64);
let p_hit = match crate::run_monte_carlo_with_wind_and_direction_std_dev_seeded(
base_inputs.clone(),
base_wind.clone(),
mc_params,
wind_direction_std_rad,
seed,
) {
Ok(results) => {
wez_p_hit(&results, &baseline, line_of_sight_height_m, target_size)
}
Err(_) => 0.0,
};
let (shares, attribution_unavailable) = if baseline_reached {
match wez_resolved_request(&base_inputs, &base_wind, solver_max_range) {
Some(resolved) => match wez_variance_shares(
&resolved,
range_m,
velocity_std,
angle_std_rad,
bc_std,
azimuth_std_dev,
wind_std,
wind_call_error,
wind_direction_std_rad,
)? {
Some(shares) => (shares, false),
None => (WezVarianceShares::default(), true),
},
None => (WezVarianceShares::default(), true),
}
} else {
(WezVarianceShares::default(), true)
};
rows.push(WezRow {
range_m,
p_hit,
dominant_error_source: shares.dominant(),
wind_call_share: shares.wind_call,
mv_sd_share: shares.mv_sd,
other_share: shares.other,
attribution_unavailable,
});
}
Ok(WezResult {
target_size: match target_size {
TargetSizeMetric::Rect { width_m, height_m } => WezTargetSizeJson {
width_m: Some(width_m),
height_m: Some(height_m),
radius_m: None,
},
TargetSizeMetric::Radius { radius_m } => WezTargetSizeJson {
width_m: None,
height_m: None,
radius_m: Some(radius_m),
},
},
wind_speed_std_mps: wind_std,
wind_call_error_mps: wind_call_error,
combined_wind_speed_std_mps: combined_wind_speed_std,
num_sims_per_step: num_sims,
rows,
})
}
#[cfg(test)]
mod wez_tests {
use super::*;
fn test_base_inputs() -> BallisticInputs {
BallisticInputs {
muzzle_velocity: 823.0, muzzle_angle: 0.001274, bc_value: 0.475,
bullet_mass: 0.010_886, bullet_diameter: 0.007_82, muzzle_height: 1.5,
ground_threshold: 0.0,
..Default::default()
}
}
fn test_atmosphere(inputs: &BallisticInputs) -> AtmosphericConditions {
AtmosphericConditions {
temperature: inputs.temperature,
pressure: inputs.pressure,
humidity: inputs.humidity_percent(),
altitude: inputs.altitude,
}
}
#[test]
fn parse_target_size_accepts_a_wxh_rectangle() {
assert_eq!(
parse_target_size("18x30").unwrap(),
TargetSize::Rect {
width: 18.0,
height: 30.0
}
);
assert_eq!(
parse_target_size(" 18.5X30.25 ").unwrap(),
TargetSize::Rect {
width: 18.5,
height: 30.25
}
);
}
#[test]
fn parse_target_size_accepts_a_single_radius() {
assert_eq!(parse_target_size("12").unwrap(), TargetSize::Radius(12.0));
assert_eq!(parse_target_size(" 0.5 ").unwrap(), TargetSize::Radius(0.5));
}
#[test]
fn parse_target_size_rejects_garbage() {
for bad in [
"",
" ",
"abc",
"18xthirty",
"eighteenx30",
"18x30x40",
"0",
"-5",
"18x-5",
"18x0",
"NaN",
] {
assert!(
parse_target_size(bad).is_err(),
"expected an error for {bad:?}"
);
}
}
#[test]
fn zero_uncertainty_is_a_step_function_in_range() {
let inputs = test_base_inputs();
let wind = WindConditions::default();
let atmosphere = test_atmosphere(&inputs);
let target = TargetSizeMetric::Rect {
width_m: 0.4572,
height_m: 0.762,
};
let los_height_m = inputs.muzzle_height + inputs.sight_height;
let mc_params = MonteCarloParams {
num_simulations: 20,
velocity_std_dev: 0.0,
angle_std_dev: 0.0,
bc_std_dev: 0.0,
wind_speed_std_dev: 0.0,
target_distance: None,
base_wind_speed: 0.0,
base_wind_direction: 0.0,
azimuth_std_dev: 0.0,
};
let mut p_hits = Vec::new();
for &range_m in &[50.0_f64, 100.0, 150.0, 200.0, 250.0, 300.0, 350.0, 400.0] {
let solver_max_range = range_m.max(1000.0) * 2.0;
let baseline = wez_solve_target_plane(
inputs.clone(),
wind.clone(),
atmosphere.clone(),
solver_max_range,
range_m,
)
.expect("valid test baseline solve");
let mut params = mc_params.clone();
params.target_distance = Some(range_m);
let results = crate::run_monte_carlo_with_wind_and_direction_std_dev_seeded(
inputs.clone(),
wind.clone(),
params,
0.0,
0xA11CE,
)
.expect("zero-uncertainty solve");
let p_hit = wez_p_hit(&results, &baseline, los_height_m, target);
assert!(
p_hit == 0.0 || p_hit == 1.0,
"range {range_m} m: expected a step (0.0 or 1.0), got {p_hit}"
);
p_hits.push((range_m, p_hit));
}
assert!(
p_hits.iter().any(|&(_, p)| p == 1.0),
"expected at least one in-box range close to the muzzle: {p_hits:?}"
);
assert!(
p_hits.iter().any(|&(_, p)| p == 0.0),
"expected at least one out-of-box range far downrange: {p_hits:?}"
);
let first_miss = p_hits.iter().position(|&(_, p)| p == 0.0);
if let Some(idx) = first_miss {
assert!(
p_hits[idx..].iter().all(|&(_, p)| p == 0.0),
"expected the box exit to be permanent for the rest of the sweep: {p_hits:?}"
);
}
}
#[test]
fn p_hit_is_monotone_non_increasing_with_range() {
let inputs = test_base_inputs();
let wind = WindConditions::default();
let atmosphere = test_atmosphere(&inputs);
let target = TargetSizeMetric::Rect {
width_m: 0.4572,
height_m: 0.762,
};
let los_height_m = inputs.muzzle_height + inputs.sight_height;
let wind_call_error = 1.5_f64; let wind_std = 0.5_f64; let combined_wind_std = wind_std.hypot(wind_call_error);
let mc_params = MonteCarloParams {
num_simulations: 500, velocity_std_dev: 1.0,
angle_std_dev: 0.001,
bc_std_dev: 0.01,
wind_speed_std_dev: combined_wind_std,
target_distance: None,
base_wind_speed: 0.0,
base_wind_direction: 0.0,
azimuth_std_dev: 0.0005,
};
let ranges_m = [100.0_f64, 200.0, 300.0, 400.0, 500.0, 600.0];
let mut p_hits = Vec::new();
for (step_index, &range_m) in ranges_m.iter().enumerate() {
let solver_max_range = range_m.max(1000.0) * 2.0;
let baseline = wez_solve_target_plane(
inputs.clone(),
wind.clone(),
atmosphere.clone(),
solver_max_range,
range_m,
)
.expect("valid test baseline solve");
let mut params = mc_params.clone();
params.target_distance = Some(range_m);
let seed = 0x57_45_5A_00_u64 ^ (step_index as u64);
let results = crate::run_monte_carlo_with_wind_and_direction_std_dev_seeded(
inputs.clone(),
wind.clone(),
params,
0.0,
seed,
)
.expect("dispersed solve");
p_hits.push(wez_p_hit(&results, &baseline, los_height_m, target));
}
let tolerance = 0.03;
for pair in p_hits.windows(2) {
assert!(
pair[1] <= pair[0] + tolerance,
"P(hit) rose more than the allowed jitter: {p_hits:?}"
);
}
assert!(
p_hits.first().unwrap() - p_hits.last().unwrap() > 0.2,
"expected a clear overall decline across the sweep: {p_hits:?}"
);
}
#[test]
fn variance_shares_sum_to_one_when_multiple_sources_are_active() {
let inputs = test_base_inputs();
let wind = WindConditions::default();
let range_m: f64 = 300.0;
let solver_max_range = range_m.max(1000.0) * 2.0;
let resolved = wez_resolved_request(&inputs, &wind, solver_max_range)
.expect("test_base_inputs's default G1 drag model is always kernel-representable");
let shares = wez_variance_shares(
&resolved,
range_m,
1.0,
0.001,
0.01,
0.0005,
0.4,
1.2,
0.02,
)
.expect("valid test attribution solve")
.expect("attribution must be available for this fixture");
let sum = shares.wind_call + shares.mv_sd + shares.other;
assert!(
(sum - 1.0).abs() < 1e-9,
"shares should sum to ~1.0, got {sum} ({shares:?})"
);
for share in [shares.wind_call, shares.mv_sd, shares.other] {
assert!((0.0..=1.0).contains(&share), "share out of range: {share}");
}
assert!(shares.dominant().is_some());
}
#[test]
fn variance_shares_are_all_zero_with_no_dispersion_sources() {
let inputs = test_base_inputs();
let wind = WindConditions::default();
let range_m: f64 = 300.0;
let solver_max_range = range_m.max(1000.0) * 2.0;
let resolved = wez_resolved_request(&inputs, &wind, solver_max_range)
.expect("test_base_inputs's default G1 drag model is always kernel-representable");
let shares = wez_variance_shares(
&resolved, range_m, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
)
.expect("valid test attribution solve")
.expect("attribution must be available for this fixture");
assert_eq!(shares.wind_call, 0.0);
assert_eq!(shares.mv_sd, 0.0);
assert_eq!(shares.other, 0.0);
assert!(shares.dominant().is_none());
}
#[test]
fn wind_call_bucket_dominates_when_it_is_the_only_active_source() {
let inputs = test_base_inputs();
let wind = WindConditions::default();
let range_m: f64 = 300.0;
let solver_max_range = range_m.max(1000.0) * 2.0;
let resolved = wez_resolved_request(&inputs, &wind, solver_max_range)
.expect("test_base_inputs's default G1 drag model is always kernel-representable");
let shares = wez_variance_shares(
&resolved,
range_m,
0.0,
0.0,
0.0,
0.0,
0.0,
3.0,
0.0,
)
.expect("valid test attribution solve")
.expect("attribution must be available for this fixture");
assert!((shares.wind_call - 1.0).abs() < 1e-9);
assert_eq!(shares.mv_sd, 0.0);
assert_eq!(shares.other, 0.0);
assert_eq!(shares.dominant(), Some(WezErrorBucket::WindCall));
}
#[test]
fn wind_call_and_ballistic_wind_shares_are_exactly_proportional_to_sigma_squared() {
let inputs = test_base_inputs();
let wind = WindConditions::default();
let range_m: f64 = 300.0;
let solver_max_range = range_m.max(1000.0) * 2.0;
let resolved = wez_resolved_request(&inputs, &wind, solver_max_range)
.expect("test_base_inputs's default G1 drag model is always kernel-representable");
let wind_speed_std_dev = 0.4_f64;
let wind_call_error_std_dev = 1.2_f64;
let shares = wez_variance_shares(
&resolved,
range_m,
0.0,
0.0,
0.0,
0.0,
wind_speed_std_dev,
wind_call_error_std_dev,
0.0,
)
.expect("valid test attribution solve")
.expect("attribution must be available for this fixture");
assert!(shares.other > 0.0, "fixture must produce a nonzero ballistic-wind share");
let expected_ratio = (wind_call_error_std_dev / wind_speed_std_dev).powi(2);
let actual_ratio = shares.wind_call / shares.other;
assert!(
(actual_ratio - expected_ratio).abs() < 1e-9,
"expected wind_call/other == (sigma_call/sigma_wind)^2 == {expected_ratio}, got \
{actual_ratio}"
);
}
#[test]
fn resolved_request_matches_wez_solve_target_plane_baseline() {
let mut inputs = test_base_inputs();
inputs.cant_angle = 5.0_f64.to_radians();
inputs.sight_offset_lateral_m = 0.02;
inputs.azimuth_angle = 0.001;
let wind = WindConditions::default();
let atmosphere = test_atmosphere(&inputs);
let range_m: f64 = 300.0;
let solver_max_range = range_m.max(1000.0) * 2.0;
let baseline = wez_solve_target_plane(
inputs.clone(),
wind.clone(),
atmosphere.clone(),
solver_max_range,
range_m,
)
.expect("valid test baseline solve");
let resolved = wez_resolved_request(&inputs, &wind, solver_max_range)
.expect("test_base_inputs's default G1 drag model is always kernel-representable");
let req: crate::solve_json::SolveRequestV1 = (&resolved).into();
let obs = crate::perturbation::evaluate(&req, &[range_m]).expect("kernel evaluate");
assert_eq!(obs.len(), 1);
let line_of_sight_height_m = inputs.muzzle_height + inputs.sight_height;
let expected_drop_m = line_of_sight_height_m - baseline.y;
assert!(
(obs[0].drop_m - expected_drop_m).abs() < 1e-6,
"kernel drop_m {} disagrees with wez_solve_target_plane-derived {} -- \
wez_resolved_request likely mismaps a field",
obs[0].drop_m,
expected_drop_m
);
assert!(
(obs[0].windage_m - baseline.z).abs() < 1e-6,
"kernel windage_m {} disagrees with wez_solve_target_plane baseline.z {} -- \
wez_resolved_request likely mismaps a field",
obs[0].windage_m,
baseline.z
);
}
#[test]
fn attribution_unavailable_when_drag_model_has_no_kernel_representation() {
let result = compute_wez(
823.0,
0.0,
0.243,
0.0113,
0.00782,
20,
5.0,
0.0001,
0.005,
1.0,
0.05,
3.0,
90.0,
0.0,
1.5,
TargetSizeMetric::Rect { width_m: 0.5, height_m: 0.75 },
300.0,
300.0,
100.0,
DragModel::G2,
None,
1.0,
0.0,
0.0,
)
.expect("compute_wez");
let row = result.rows.first().expect("one row");
assert!(
row.attribution_unavailable,
"G2 has no solve-json v1 drag-model counterpart; attribution cannot run"
);
assert!(row.p_hit.is_finite(), "p_hit comes from the real Monte Carlo run, unaffected");
}
#[test]
fn attribution_unavailable_with_a_custom_drag_table() {
let table = crate::drag::DragTable::new(
vec![0.0, 0.5, 1.0, 1.5, 2.0, 3.0],
vec![0.5, 0.5, 0.5, 0.5, 0.5, 0.5],
);
let result = compute_wez(
823.0,
0.0,
0.243,
0.0113,
0.00782,
20,
5.0,
0.0001,
0.005,
1.0,
0.05,
3.0,
90.0,
0.0,
1.5,
TargetSizeMetric::Rect { width_m: 0.5, height_m: 0.75 },
300.0,
300.0,
100.0,
DragModel::G7,
Some(table),
1.0,
0.0,
0.0,
)
.expect("compute_wez");
let row = result.rows.first().expect("one row");
assert!(
row.attribution_unavailable,
"a custom drag table has no solve-json v1 representation; attribution cannot run"
);
assert!(row.p_hit.is_finite(), "p_hit comes from the real Monte Carlo run, unaffected");
}
#[test]
fn attribution_shares_for_a_fixed_configuration() {
let result = compute_wez(
823.0, 0.0, 0.243, 0.0113, 0.00782, 20, 5.0, 0.0001, 0.005, 1.0, 0.05, 3.0, 90.0, 0.0, 1.5, TargetSizeMetric::Rect { width_m: 0.5, height_m: 0.75 },
300.0, 300.0, 100.0, DragModel::G7,
None, 1.0, 0.0, 0.0, ).expect("compute_wez");
let row = result.rows.first().expect("one row");
assert!(!row.attribution_unavailable, "attribution must be available here");
eprintln!("CHARACTERIZATION wind_call={} mv_sd={} other={}",
row.wind_call_share, row.mv_sd_share, row.other_share);
assert!((row.wind_call_share + row.mv_sd_share + row.other_share - 1.0).abs() < 1e-9);
assert!((row.wind_call_share - 0.681_529_624_943_378).abs() < 1e-6,
"update the characterization constant");
assert!((row.mv_sd_share - 0.012_968_843_319_743_649).abs() < 1e-6,
"update the characterization constant");
assert!((row.other_share - 0.305_501_531_736_878_2).abs() < 1e-6,
"update the characterization constant");
}
}