use serde::{Deserialize, Serialize};
use crate::perturbation::access::{read_axis, with_axis, KernelError};
use crate::perturbation::taxonomy::{axes_in_group, InputAxis, InputGroup};
use crate::perturbation::{evaluate, kernel_solve_error, Observation};
use crate::solve_json::{PressureReferenceV1, ResolvedSolveRequestV1, SolveRequestV1, WindReferenceV1};
pub const EXPLAIN_SCHEMA_VERSION_V1: u32 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Default)]
pub struct DeltaV1 {
pub drop_m: f64,
pub windage_m: f64,
pub time_s: f64,
pub velocity_mps: f64,
}
impl DeltaV1 {
fn between(a: &Observation, b: &Observation) -> Self {
DeltaV1 {
drop_m: b.drop_m - a.drop_m,
windage_m: b.windage_m - a.windage_m,
time_s: b.time_s - a.time_s,
velocity_mps: b.velocity_mps - a.velocity_mps,
}
}
fn mean(x: Self, y: Self) -> Self {
DeltaV1 {
drop_m: 0.5 * (x.drop_m + y.drop_m),
windage_m: 0.5 * (x.windage_m + y.windage_m),
time_s: 0.5 * (x.time_s + y.time_s),
velocity_mps: 0.5 * (x.velocity_mps + y.velocity_mps),
}
}
fn neg(self) -> Self {
DeltaV1 {
drop_m: -self.drop_m,
windage_m: -self.windage_m,
time_s: -self.time_s,
velocity_mps: -self.velocity_mps,
}
}
fn add(self, o: Self) -> Self {
DeltaV1 {
drop_m: self.drop_m + o.drop_m,
windage_m: self.windage_m + o.windage_m,
time_s: self.time_s + o.time_s,
velocity_mps: self.velocity_mps + o.velocity_mps,
}
}
fn sub(self, o: Self) -> Self {
DeltaV1 {
drop_m: self.drop_m - o.drop_m,
windage_m: self.windage_m - o.windage_m,
time_s: self.time_s - o.time_s,
velocity_mps: self.velocity_mps - o.velocity_mps,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SwapDirectionV1 {
Forward,
Backward,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SkippedAxisV1 {
pub group: InputGroup,
pub axis: InputAxis,
pub direction: SwapDirectionV1,
pub reason: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GroupContributionV1 {
pub group: InputGroup,
pub delta: DeltaV1,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SolutionDiffRowV1 {
pub range_m: f64,
pub total: DeltaV1,
pub contributions: Vec<GroupContributionV1>,
pub interaction_remainder: DeltaV1,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SolutionDiffReportV1 {
pub schema_version: u32,
pub method: String,
pub assumptions: Vec<String>,
pub skipped_axes: Vec<SkippedAxisV1>,
pub rows: Vec<SolutionDiffRowV1>,
}
fn describe_refusal(axis: InputAxis, own_refusal: &Option<String>, other_refusal: &Option<String>) -> String {
let context = match axis {
InputAxis::Altitude => {
"altitude is tied to pressure under a QNH-referenced atmosphere; this group swap \
cannot re-derive that relationship for a different altitude. \
Temperature/RelativeHumidity/Latitude in the same group ARE copied normally; \
Pressure is ALSO excluded whenever the two requests' altitudes actually differ \
(see Pressure's own skipped_axes entry when that applies), but is copied normally \
alongside Altitude's exclusion when the altitudes happen to match"
}
InputAxis::ShotAzimuth => {
"the shot azimuth is tied to an earth-fixed wind bearing under compass-referenced \
wind; this group swap cannot re-derive that bearing for a different azimuth, even \
though TargetDistance/ShootingAngle/Cant/AimAzimuth/TargetHeight in the same group \
ARE copied normally"
}
_ => "this axis cannot be swapped for one of the two requests being compared",
};
match own_refusal {
Some(reason) => format!("{context} ({reason})"),
None => format!(
"excluded to match the other swap direction, which cannot swap this axis: {context} \
({})",
other_refusal.as_deref().unwrap_or("no further detail available")
),
}
}
fn describe_wind_absence(own_segmented: bool, other_segmented: bool) -> String {
match (own_segmented, other_segmented) {
(true, true) => "both requests being compared use segmented wind; neither has a \
single scalar value for this axis"
.to_string(),
(true, false) => "this request's wind is segmented, so there is no single scalar \
field to write this axis into"
.to_string(),
(false, true) => "the other request's wind is segmented, so there is no single \
scalar value to read for this axis"
.to_string(),
(false, false) => {
unreachable!("describe_wind_absence called when neither side is segmented")
}
}
}
fn is_compass_referenced(r: &ResolvedSolveRequestV1) -> bool {
crate::perturbation::access::wind_reference_of(&r.wind) == Some(WindReferenceV1::Compass)
}
fn is_qnh_referenced(r: &ResolvedSolveRequestV1) -> bool {
r.atmosphere.pressure_reference == Some(PressureReferenceV1::Qnh)
}
fn plan_exclusions(
a: &ResolvedSolveRequestV1,
b: &ResolvedSolveRequestV1,
group: InputGroup,
) -> Result<(Vec<InputAxis>, Vec<SkippedAxisV1>), KernelError> {
let mut excluded = Vec::new();
let mut skipped = Vec::new();
for &axis in axes_in_group(group) {
if excluded.contains(&axis) {
continue;
}
let a_value = read_axis(a, axis);
let b_value = read_axis(b, axis);
if matches!(
axis,
InputAxis::WindSpeed | InputAxis::WindDirection | InputAxis::WindVertical
) {
if a_value.is_none() || b_value.is_none() {
excluded.push(axis);
skipped.push(SkippedAxisV1 {
group,
axis,
direction: SwapDirectionV1::Forward,
reason: describe_wind_absence(a_value.is_none(), b_value.is_none()),
});
skipped.push(SkippedAxisV1 {
group,
axis,
direction: SwapDirectionV1::Backward,
reason: describe_wind_absence(b_value.is_none(), a_value.is_none()),
});
continue;
}
if axis == InputAxis::WindDirection
&& (is_compass_referenced(a) || is_compass_referenced(b))
&& a.shot.shot_azimuth_rad != b.shot.shot_azimuth_rad
{
excluded.push(axis);
let reason = "the resolved wind direction is derived from shot_azimuth_rad (a \
ShotGeometry axis) under compass-referenced wind; swapping it \
here would attribute part of a shot-azimuth difference to Wind \
instead of leaving it in the interaction remainder"
.to_string();
skipped.push(SkippedAxisV1 {
group,
axis,
direction: SwapDirectionV1::Forward,
reason: reason.clone(),
});
skipped.push(SkippedAxisV1 {
group,
axis,
direction: SwapDirectionV1::Backward,
reason,
});
}
continue;
}
if axis == InputAxis::Pressure
&& (is_qnh_referenced(a) || is_qnh_referenced(b))
&& a.atmosphere.altitude_m != b.atmosphere.altitude_m
{
excluded.push(axis);
let reason = "the resolved station pressure is derived from altitude_m under a \
QNH-referenced atmosphere, and Altitude is excluded from this same \
comparison; swapping Pressure alone would move a value that is only \
physically valid at the OTHER request's (unswapped) altitude"
.to_string();
skipped.push(SkippedAxisV1 {
group,
axis,
direction: SwapDirectionV1::Forward,
reason: reason.clone(),
});
skipped.push(SkippedAxisV1 {
group,
axis,
direction: SwapDirectionV1::Backward,
reason,
});
continue;
}
if axis == InputAxis::MagnusEnabled
&& ((a.effects.magnus && b.effects.enhanced_spin_drift)
|| (b.effects.magnus && a.effects.enhanced_spin_drift))
{
let reason = "magnus and enhanced_spin_drift cannot both be enabled on one resolved \
request, and one of these two requests has magnus enabled while the \
other has enhanced_spin_drift enabled; swapping either flag alone \
would transiently combine a newly-written true with the destination's \
own still-unswapped true on the other flag, which solve_v1 rejects as \
a conflict, so both effects are excluded together rather than aborting \
the comparison"
.to_string();
for excluded_axis in
[InputAxis::MagnusEnabled, InputAxis::EnhancedSpinDriftEnabled]
{
excluded.push(excluded_axis);
skipped.push(SkippedAxisV1 {
group,
axis: excluded_axis,
direction: SwapDirectionV1::Forward,
reason: reason.clone(),
});
skipped.push(SkippedAxisV1 {
group,
axis: excluded_axis,
direction: SwapDirectionV1::Backward,
reason: reason.clone(),
});
}
continue;
}
if axis == InputAxis::CoriolisEnabled
&& a.effects.coriolis != b.effects.coriolis
&& (a.atmosphere.latitude_rad.is_none() || b.atmosphere.latitude_rad.is_none())
{
excluded.push(axis);
let reason = "the coriolis flag differs between the two requests and at least one \
of them has no latitude_rad; solve_v1 requires latitude_rad whenever \
the coriolis effect is enabled, so swapping this flag onto the request \
that lacks one would fail re-resolution rather than build a valid \
counterfactual"
.to_string();
skipped.push(SkippedAxisV1 {
group,
axis,
direction: SwapDirectionV1::Forward,
reason: reason.clone(),
});
skipped.push(SkippedAxisV1 {
group,
axis,
direction: SwapDirectionV1::Backward,
reason,
});
continue;
}
if a_value.is_some() != b_value.is_some() {
excluded.push(axis);
let reason = "this axis is present on only one of the two requests being compared \
(absent on the other), so there is no symmetric value to swap in \
either direction"
.to_string();
skipped.push(SkippedAxisV1 {
group,
axis,
direction: SwapDirectionV1::Forward,
reason: reason.clone(),
});
skipped.push(SkippedAxisV1 {
group,
axis,
direction: SwapDirectionV1::Backward,
reason,
});
continue;
}
let (Some(a_value), Some(b_value)) = (a_value, b_value) else {
continue; };
let a_refusal = match with_axis(a, axis, a_value) {
Ok(_) => None,
Err(KernelError::AxisUnsupportedForRequest { reason, .. }) => Some(reason.to_string()),
Err(other) => return Err(other),
};
let b_refusal = match with_axis(b, axis, b_value) {
Ok(_) => None,
Err(KernelError::AxisUnsupportedForRequest { reason, .. }) => Some(reason.to_string()),
Err(other) => return Err(other),
};
if a_refusal.is_some() || b_refusal.is_some() {
excluded.push(axis);
skipped.push(SkippedAxisV1 {
group,
axis,
direction: SwapDirectionV1::Forward,
reason: describe_refusal(axis, &a_refusal, &b_refusal),
});
skipped.push(SkippedAxisV1 {
group,
axis,
direction: SwapDirectionV1::Backward,
reason: describe_refusal(axis, &b_refusal, &a_refusal),
});
}
}
Ok((excluded, skipped))
}
fn swap_group(
dst: &ResolvedSolveRequestV1,
src: &ResolvedSolveRequestV1,
group: InputGroup,
excluded: &[InputAxis],
) -> Result<SolveRequestV1, KernelError> {
let mut current = dst.clone();
for &axis in axes_in_group(group) {
if excluded.contains(&axis) {
continue;
}
let Some(v) = read_axis(src, axis) else {
continue;
};
let req = with_axis(¤t, axis, v)?;
current = crate::solve_v1::solve_v1(req)
.map_err(kernel_solve_error)?
.resolved_request;
}
Ok((¤t).into())
}
pub fn explain_difference(
a: &ResolvedSolveRequestV1,
b: &ResolvedSolveRequestV1,
ranges_m: &[f64],
) -> Result<SolutionDiffReportV1, KernelError> {
let obs_a = evaluate(&a.into(), ranges_m)?;
let obs_b = evaluate(&b.into(), ranges_m)?;
let mut skipped_axes = Vec::new();
let mut per_group: Vec<(InputGroup, Vec<DeltaV1>)> = Vec::with_capacity(InputGroup::ALL.len());
for &group in InputGroup::ALL {
let (excluded, mut group_skips) = plan_exclusions(a, b, group)?;
skipped_axes.append(&mut group_skips);
let fwd_req = swap_group(a, b, group, &excluded)?;
let bwd_req = swap_group(b, a, group, &excluded)?;
let fwd_obs = evaluate(&fwd_req, ranges_m)?;
let bwd_obs = evaluate(&bwd_req, ranges_m)?;
let mut deltas = Vec::with_capacity(ranges_m.len());
for i in 0..ranges_m.len() {
let forward = DeltaV1::between(&obs_a[i], &fwd_obs[i]); let backward = DeltaV1::between(&obs_b[i], &bwd_obs[i]).neg(); deltas.push(DeltaV1::mean(forward, backward));
}
per_group.push((group, deltas));
}
let mut rows = Vec::with_capacity(ranges_m.len());
for (i, &range_m) in ranges_m.iter().enumerate() {
let total = DeltaV1::between(&obs_a[i], &obs_b[i]);
let contributions: Vec<GroupContributionV1> = per_group
.iter()
.map(|(g, d)| GroupContributionV1 {
group: *g,
delta: d[i],
})
.collect();
let summed = contributions
.iter()
.fold(DeltaV1::default(), |acc, c| acc.add(c.delta));
rows.push(SolutionDiffRowV1 {
range_m,
total,
contributions,
interaction_remainder: total.sub(summed),
});
}
Ok(SolutionDiffReportV1 {
schema_version: EXPLAIN_SCHEMA_VERSION_V1,
method: "symmetric_group_counterfactual".to_string(),
assumptions: vec![
"Group contributions are symmetric counterfactuals: the mean of swapping the group \
in each direction, so the result does not depend on replacement order."
.to_string(),
"Nonlinear interaction between groups is reported as an explicit interaction \
remainder and is NOT distributed across groups. For correlated inputs no unique \
causal attribution exists."
.to_string(),
"An axis that could not be swapped for one or both requests (see skipped_axes) is \
excluded from BOTH directions of that axis's group, so the two directions keep \
measuring the same counterfactual; any real effect the axis would have had is \
folded into the interaction remainder, not silently dropped and not partially \
attributed to its group."
.to_string(),
],
skipped_axes,
rows,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn resolved(mv: f64, temp: f64) -> crate::solve_json::ResolvedSolveRequestV1 {
let json = serde_json::json!({
"schema_version": 1,
"projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
"ballistic_coefficient": 0.243},
"rifle": {"muzzle_velocity_mps": mv, "sight_height_m": 0.05},
"shot": {"max_range_m": 900.0, "zero_distance_m": 100.0},
"atmosphere": {"temperature_k": temp}, "wind": {"speed_mps": 3.0,
"direction_from_rad": std::f64::consts::FRAC_PI_2},
"solver": {}, "effects": {}, "sampling": {"interval_m": 25.0}
})
.to_string();
let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
crate::solve_v1::solve_v1(req).unwrap().resolved_request
}
fn resolved_with_bc_and_wind_speed(
ballistic_coefficient: f64,
wind_speed_mps: f64,
) -> crate::solve_json::ResolvedSolveRequestV1 {
let json = serde_json::json!({
"schema_version": 1,
"projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
"ballistic_coefficient": ballistic_coefficient},
"rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
"shot": {"max_range_m": 900.0, "zero_distance_m": 100.0},
"atmosphere": {"temperature_k": 288.0},
"wind": {"speed_mps": wind_speed_mps,
"direction_from_rad": std::f64::consts::FRAC_PI_2},
"solver": {}, "effects": {}, "sampling": {"interval_m": 25.0}
})
.to_string();
let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
crate::solve_v1::solve_v1(req).unwrap().resolved_request
}
fn resolved_with_effects(
mv: f64,
magnus: bool,
enhanced_spin_drift: bool,
) -> crate::solve_json::ResolvedSolveRequestV1 {
let json = serde_json::json!({
"schema_version": 1,
"projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
"ballistic_coefficient": 0.243},
"rifle": {"muzzle_velocity_mps": mv, "sight_height_m": 0.05},
"shot": {"max_range_m": 900.0},
"atmosphere": {"temperature_k": 288.0},
"wind": {},
"solver": {},
"effects": {"magnus": magnus, "enhanced_spin_drift": enhanced_spin_drift},
"sampling": {"interval_m": 50.0}
})
.to_string();
let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
crate::solve_v1::solve_v1(req).unwrap().resolved_request
}
#[test]
fn identical_requests_produce_zero_everything() {
let a = resolved(823.0, 288.0);
let rep = explain_difference(&a, &a, &[300.0, 600.0]).unwrap();
assert!(
rep.skipped_axes.is_empty(),
"expected no skipped axes for two ordinary, identical requests, got {:?}",
rep.skipped_axes
);
for row in &rep.rows {
assert!(row.total.drop_m.abs() < 1e-9, "total drop {}", row.total.drop_m);
assert!(row.interaction_remainder.drop_m.abs() < 1e-9);
for c in &row.contributions {
assert!(
c.delta.drop_m.abs() < 1e-9,
"{:?} contributed {}",
c.group,
c.delta.drop_m
);
}
}
}
#[test]
fn the_decomposition_is_antisymmetric() {
let a = resolved(823.0, 288.0);
let b = resolved(870.0, 300.0);
let ab = explain_difference(&a, &b, &[600.0]).unwrap();
let ba = explain_difference(&b, &a, &[600.0]).unwrap();
assert!((ab.rows[0].total.drop_m + ba.rows[0].total.drop_m).abs() < 1e-6);
for (x, y) in ab.rows[0]
.contributions
.iter()
.zip(ba.rows[0].contributions.iter())
{
assert_eq!(x.group, y.group);
assert!(
(x.delta.drop_m + y.delta.drop_m).abs() < 1e-6,
"{:?} not antisymmetric",
x.group
);
}
}
#[test]
fn contributions_plus_remainder_equal_the_total() {
let a = resolved(823.0, 288.0);
let b = resolved(870.0, 300.0);
let rep = explain_difference(&a, &b, &[600.0]).unwrap();
let row = &rep.rows[0];
let sum: f64 = row.contributions.iter().map(|c| c.delta.drop_m).sum();
assert!((sum + row.interaction_remainder.drop_m - row.total.drop_m).abs() < 1e-9);
}
#[test]
fn the_report_states_its_method_and_assumptions() {
let a = resolved(823.0, 288.0);
let rep = explain_difference(&a, &a, &[300.0]).unwrap();
assert_eq!(rep.method, "symmetric_group_counterfactual");
assert!(rep.assumptions.iter().any(|s| s.contains("interaction")));
}
#[test]
fn schema_version_matches_the_declared_constant() {
let a = resolved(823.0, 288.0);
let rep = explain_difference(&a, &a, &[300.0]).unwrap();
assert_eq!(rep.schema_version, EXPLAIN_SCHEMA_VERSION_V1);
}
#[test]
fn source_requests_are_not_mutated_by_the_comparison() {
let a = resolved(823.0, 288.0);
let b = resolved(870.0, 300.0);
let a_before = a.clone();
let b_before = b.clone();
let _ = explain_difference(&a, &b, &[300.0, 600.0]).unwrap();
assert_eq!(a, a_before, "the first source request changed");
assert_eq!(b, b_before, "the second source request changed");
}
#[test]
fn contribution_is_the_mean_of_a_genuinely_different_forward_and_backward() {
let a = resolved(823.0, 288.0);
let b = resolved(870.0, 300.0);
let ranges = [600.0];
let obs_a = evaluate(&(&a).into(), &ranges).unwrap();
let obs_b = evaluate(&(&b).into(), &ranges).unwrap();
let (excluded, _skipped) =
plan_exclusions(&a, &b, InputGroup::MuzzleVelocity).unwrap();
let fwd_req = swap_group(&a, &b, InputGroup::MuzzleVelocity, &excluded).unwrap();
let bwd_req = swap_group(&b, &a, InputGroup::MuzzleVelocity, &excluded).unwrap();
let fwd_obs = evaluate(&fwd_req, &ranges).unwrap();
let bwd_obs = evaluate(&bwd_req, &ranges).unwrap();
let forward = fwd_obs[0].drop_m - obs_a[0].drop_m;
let backward = obs_b[0].drop_m - bwd_obs[0].drop_m;
assert!(
(forward - backward).abs() > 2e-6,
"forward ({forward}) and backward ({backward}) must genuinely differ here, or this \
test cannot distinguish 'the mean of the two' from 'either endpoint alone'"
);
let rep = explain_difference(&a, &b, &ranges).unwrap();
let mv = rep.rows[0]
.contributions
.iter()
.find(|c| c.group == InputGroup::MuzzleVelocity)
.unwrap();
let expected_mean = 0.5 * (forward + backward);
assert!(
(mv.delta.drop_m - expected_mean).abs() < 1e-9,
"reported {} expected mean {}",
mv.delta.drop_m,
expected_mean
);
assert!(
(mv.delta.drop_m - forward).abs() > 1e-6,
"reported value must not equal the forward leg alone"
);
assert!(
(mv.delta.drop_m - backward).abs() > 1e-6,
"reported value must not equal the backward leg alone"
);
}
fn assert_matches_independent_recomputation(
a: &crate::solve_json::ResolvedSolveRequestV1,
b: &crate::solve_json::ResolvedSolveRequestV1,
range_m: f64,
) -> SolutionDiffReportV1 {
let ranges = [range_m];
let rep = explain_difference(a, b, &ranges).unwrap();
let row = &rep.rows[0];
let obs_a = evaluate(&a.into(), &ranges).unwrap();
let obs_b = evaluate(&b.into(), &ranges).unwrap();
let mut independent_sum = DeltaV1::default();
for &group in InputGroup::ALL {
let (excluded, _skipped) = plan_exclusions(a, b, group).unwrap();
let fwd_req = swap_group(a, b, group, &excluded).unwrap();
let bwd_req = swap_group(b, a, group, &excluded).unwrap();
let fwd_obs = evaluate(&fwd_req, &ranges).unwrap();
let bwd_obs = evaluate(&bwd_req, &ranges).unwrap();
let forward = DeltaV1::between(&obs_a[0], &fwd_obs[0]);
let backward = DeltaV1::between(&obs_b[0], &bwd_obs[0]).neg();
let expected = DeltaV1::mean(forward, backward);
let reported = row
.contributions
.iter()
.find(|c| c.group == group)
.unwrap_or_else(|| panic!("{group:?} missing from the report"));
assert!(
(reported.delta.drop_m - expected.drop_m).abs() < 1e-9,
"{group:?}: reported drop {} independent {}",
reported.delta.drop_m,
expected.drop_m
);
assert!(
(reported.delta.windage_m - expected.windage_m).abs() < 1e-9,
"{group:?}: windage mismatch"
);
assert!(
(reported.delta.time_s - expected.time_s).abs() < 1e-9,
"{group:?}: time mismatch"
);
assert!(
(reported.delta.velocity_mps - expected.velocity_mps).abs() < 1e-9,
"{group:?}: velocity mismatch"
);
independent_sum = independent_sum.add(expected);
}
let total = DeltaV1::between(&obs_a[0], &obs_b[0]);
let expected_remainder = total.sub(independent_sum);
assert!(
(row.interaction_remainder.drop_m - expected_remainder.drop_m).abs() < 1e-9,
"reported remainder {} independent remainder {}",
row.interaction_remainder.drop_m,
expected_remainder.drop_m
);
rep
}
#[test]
fn every_groups_contribution_matches_an_independent_recomputation() {
let a = resolved(823.0, 288.0);
let b = resolved(870.0, 300.0);
let rep = assert_matches_independent_recomputation(&a, &b, 600.0);
let row = &rep.rows[0];
for g in [
InputGroup::ProjectileDrag,
InputGroup::ZeroSightGeometry,
InputGroup::Wind,
InputGroup::ShotGeometry,
InputGroup::Effects,
] {
let c = row.contributions.iter().find(|x| x.group == g).unwrap();
assert!(
c.delta.drop_m.abs() < 1e-9,
"{g:?} does not differ between a and b in this fixture at all, expected \
exactly 0, got {}",
c.delta.drop_m
);
}
let mv = row
.contributions
.iter()
.find(|c| c.group == InputGroup::MuzzleVelocity)
.unwrap();
let atmosphere = row
.contributions
.iter()
.find(|c| c.group == InputGroup::Atmosphere)
.unwrap();
for (name, c) in [("MuzzleVelocity", mv), ("Atmosphere", atmosphere)] {
assert!(
c.delta.drop_m.abs() > 0.01,
"{name} should have a substantial, non-negligible contribution here, got {}",
c.delta.drop_m
);
}
assert!(
(mv.delta.drop_m - atmosphere.delta.drop_m).abs() > 0.01,
"MuzzleVelocity ({}) and Atmosphere ({}) should be distinguishable, not just both \
'non-negligible'",
mv.delta.drop_m,
atmosphere.delta.drop_m
);
assert!(
row.interaction_remainder.drop_m.abs() > 1e-4,
"expected a non-negligible interaction remainder in this fixture, got {}",
row.interaction_remainder.drop_m
);
}
#[test]
fn every_groups_contribution_matches_an_independent_recomputation_bc_and_wind_fixture() {
let a = resolved_with_bc_and_wind_speed(0.243, 3.0);
let b = resolved_with_bc_and_wind_speed(0.300, 6.0);
let rep = assert_matches_independent_recomputation(&a, &b, 600.0);
let row = &rep.rows[0];
for g in [
InputGroup::MuzzleVelocity,
InputGroup::ZeroSightGeometry,
InputGroup::Atmosphere,
InputGroup::ShotGeometry,
InputGroup::Effects,
] {
let c = row.contributions.iter().find(|x| x.group == g).unwrap();
assert!(
c.delta.drop_m.abs() < 1e-9,
"{g:?} does not differ between a and b in this fixture at all, expected \
exactly 0, got {}",
c.delta.drop_m
);
}
let drag = row
.contributions
.iter()
.find(|c| c.group == InputGroup::ProjectileDrag)
.unwrap();
let wind = row
.contributions
.iter()
.find(|c| c.group == InputGroup::Wind)
.unwrap();
assert!(
drag.delta.drop_m.abs() > 0.01,
"ProjectileDrag should have a substantial drop contribution here, got {}",
drag.delta.drop_m
);
assert!(
wind.delta.windage_m.abs() > 0.01,
"Wind should have a substantial windage contribution here, got {}",
wind.delta.windage_m
);
assert!(
drag.delta.windage_m.abs() < wind.delta.windage_m.abs(),
"ProjectileDrag's windage move ({}) should be smaller than Wind's own ({})",
drag.delta.windage_m,
wind.delta.windage_m
);
assert!(
wind.delta.drop_m.abs() < drag.delta.drop_m.abs(),
"Wind's drop move ({}) should be smaller than ProjectileDrag's own ({})",
wind.delta.drop_m,
drag.delta.drop_m
);
}
#[test]
fn muzzle_angle_is_still_swapped_for_an_angle_only_request() {
let build = |muzzle_angle_rad: f64| {
serde_json::json!({
"schema_version": 1,
"projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
"ballistic_coefficient": 0.243},
"rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
"shot": {"max_range_m": 900.0, "muzzle_angle_rad": muzzle_angle_rad},
"atmosphere": {"temperature_k": 288.0},
"wind": {},
"solver": {}, "effects": {}, "sampling": {"interval_m": 25.0}
})
.to_string()
};
let a = crate::solve_v1::solve_v1(
crate::solve_json::decode_solve_request_v1(&build(0.010)).unwrap(),
)
.unwrap()
.resolved_request;
let b = crate::solve_v1::solve_v1(
crate::solve_json::decode_solve_request_v1(&build(0.030)).unwrap(),
)
.unwrap()
.resolved_request;
assert_eq!(
a.shot.zero_distance_m, None,
"fixture assumption: angle-only, no zero distance at all"
);
assert_eq!(
b.shot.zero_distance_m, None,
"fixture assumption: angle-only, no zero distance at all"
);
let rep = assert_matches_independent_recomputation(&a, &b, 600.0);
let row = &rep.rows[0];
let zero_sight_geometry = row
.contributions
.iter()
.find(|c| c.group == InputGroup::ZeroSightGeometry)
.unwrap();
assert!(
zero_sight_geometry.delta.drop_m.abs() > 0.01,
"MuzzleAngle must still be swapped for an angle-only request -- expected a \
substantial ZeroSightGeometry contribution, got {}",
zero_sight_geometry.delta.drop_m
);
}
#[test]
fn altitude_is_skipped_and_recorded_under_qnh_pressure() {
let qnh_json = serde_json::json!({
"schema_version": 1,
"projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
"ballistic_coefficient": 0.243},
"rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
"shot": {"max_range_m": 900.0},
"atmosphere": {"altitude_m": 500.0, "temperature_k": 288.0, "pressure_pa": 101325.0,
"pressure_reference": "qnh"},
"wind": {}, "solver": {}, "effects": {}, "sampling": {"interval_m": 50.0}
})
.to_string();
let a = crate::solve_v1::solve_v1(
crate::solve_json::decode_solve_request_v1(&qnh_json).unwrap(),
)
.unwrap()
.resolved_request;
let b = resolved(870.0, 300.0);
assert_eq!(
b.atmosphere.pressure_reference, None,
"fixture assumption: b is NOT QNH-referenced, so only a's QNH-ness drives this test"
);
let rep = explain_difference(&a, &b, &[300.0])
.expect("a refused axis must not abort the whole comparison");
for direction in [SwapDirectionV1::Forward, SwapDirectionV1::Backward] {
let hit = rep
.skipped_axes
.iter()
.find(|s| {
s.group == InputGroup::Atmosphere
&& s.axis == InputAxis::Altitude
&& s.direction == direction
})
.unwrap_or_else(|| {
panic!(
"expected a {direction:?}-direction Altitude skip under QNH pressure \
(review I1: both directions must be excluded, not just the one that \
independently refused), got {:?}",
rep.skipped_axes
)
});
assert!(
hit.reason.to_lowercase().contains("qnh"),
"reason should name QNH: {}",
hit.reason
);
}
}
#[test]
fn altitude_exclusion_does_not_leak_a_partial_effect_into_atmosphere() {
let qnh_json = serde_json::json!({
"schema_version": 1,
"projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
"ballistic_coefficient": 0.243},
"rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
"shot": {"max_range_m": 900.0},
"atmosphere": {"altitude_m": 500.0, "temperature_k": 288.0, "pressure_pa": 101325.0,
"pressure_reference": "qnh"},
"wind": {}, "solver": {}, "effects": {}, "sampling": {"interval_m": 50.0}
})
.to_string();
let a = crate::solve_v1::solve_v1(
crate::solve_json::decode_solve_request_v1(&qnh_json).unwrap(),
)
.unwrap()
.resolved_request;
let b_json = |altitude_m: f64| {
serde_json::json!({
"schema_version": 1,
"projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
"ballistic_coefficient": 0.243},
"rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
"shot": {"max_range_m": 900.0},
"atmosphere": {"altitude_m": altitude_m, "temperature_k": 300.0,
"pressure_pa": 98000.0},
"wind": {}, "solver": {}, "effects": {}, "sampling": {"interval_m": 50.0}
})
.to_string()
};
let b_altitude_1200 = crate::solve_v1::solve_v1(
crate::solve_json::decode_solve_request_v1(&b_json(1200.0)).unwrap(),
)
.unwrap()
.resolved_request;
let b_altitude_900 = crate::solve_v1::solve_v1(
crate::solve_json::decode_solve_request_v1(&b_json(900.0)).unwrap(),
)
.unwrap()
.resolved_request;
assert_ne!(b_altitude_1200.atmosphere.altitude_m, a.atmosphere.altitude_m);
assert_ne!(b_altitude_900.atmosphere.altitude_m, a.atmosphere.altitude_m);
let rep_1200 = explain_difference(&a, &b_altitude_1200, &[300.0]).unwrap();
let rep_900 = explain_difference(&a, &b_altitude_900, &[300.0]).unwrap();
for rep in [&rep_1200, &rep_900] {
for direction in [SwapDirectionV1::Forward, SwapDirectionV1::Backward] {
assert!(
rep.skipped_axes.iter().any(|s| s.group == InputGroup::Atmosphere
&& s.axis == InputAxis::Altitude
&& s.direction == direction),
"expected Altitude excluded on {direction:?} in both comparisons"
);
}
}
let atmosphere_1200 = rep_1200.rows[0]
.contributions
.iter()
.find(|c| c.group == InputGroup::Atmosphere)
.unwrap();
let atmosphere_900 = rep_900.rows[0]
.contributions
.iter()
.find(|c| c.group == InputGroup::Atmosphere)
.unwrap();
assert!(
(atmosphere_1200.delta.drop_m - atmosphere_900.delta.drop_m).abs() < 1e-9,
"Atmosphere's contribution changed when b's altitude changed (1200 -> 900 m) even \
though Altitude is excluded from both comparisons -- {} vs {} -- a partial \
altitude effect must be leaking through",
atmosphere_1200.delta.drop_m,
atmosphere_900.delta.drop_m
);
assert!(
atmosphere_900.delta.drop_m.abs() > 0.001,
"expected a non-negligible Atmosphere contribution from temperature alone, got {}",
atmosphere_900.delta.drop_m
);
}
#[test]
fn pressure_exclusion_does_not_leak_a_partial_effect_into_atmosphere() {
let qnh_json = |altitude_m: f64, temperature_k: f64, raw_qnh_pa: f64| {
serde_json::json!({
"schema_version": 1,
"projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
"ballistic_coefficient": 0.243},
"rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
"shot": {"max_range_m": 900.0},
"atmosphere": {"altitude_m": altitude_m, "temperature_k": temperature_k,
"pressure_pa": raw_qnh_pa, "pressure_reference": "qnh"},
"wind": {}, "solver": {}, "effects": {}, "sampling": {"interval_m": 50.0}
})
.to_string()
};
let a = crate::solve_v1::solve_v1(
crate::solve_json::decode_solve_request_v1(&qnh_json(500.0, 288.0, 101_325.0))
.unwrap(),
)
.unwrap()
.resolved_request;
let b_qnh_101325 = crate::solve_v1::solve_v1(
crate::solve_json::decode_solve_request_v1(&qnh_json(1200.0, 300.0, 101_325.0))
.unwrap(),
)
.unwrap()
.resolved_request;
let b_qnh_105000 = crate::solve_v1::solve_v1(
crate::solve_json::decode_solve_request_v1(&qnh_json(1200.0, 300.0, 105_000.0))
.unwrap(),
)
.unwrap()
.resolved_request;
assert_eq!(b_qnh_101325.atmosphere.altitude_m, b_qnh_105000.atmosphere.altitude_m);
assert_ne!(b_qnh_101325.atmosphere.altitude_m, a.atmosphere.altitude_m);
assert_ne!(
b_qnh_101325.atmosphere.pressure_pa, b_qnh_105000.atmosphere.pressure_pa,
"fixture assumption: different raw QNH at the same altitude must resolve to a \
different station pressure"
);
let rep_101325 = explain_difference(&a, &b_qnh_101325, &[300.0]).unwrap();
let rep_105000 = explain_difference(&a, &b_qnh_105000, &[300.0]).unwrap();
for rep in [&rep_101325, &rep_105000] {
for direction in [SwapDirectionV1::Forward, SwapDirectionV1::Backward] {
assert!(
rep.skipped_axes.iter().any(|s| s.group == InputGroup::Atmosphere
&& s.axis == InputAxis::Pressure
&& s.direction == direction),
"expected Pressure excluded on {direction:?} under QNH-vs-QNH; got {:?}",
rep.skipped_axes
);
}
}
let atmosphere_101325 = rep_101325.rows[0]
.contributions
.iter()
.find(|c| c.group == InputGroup::Atmosphere)
.unwrap();
let atmosphere_105000 = rep_105000.rows[0]
.contributions
.iter()
.find(|c| c.group == InputGroup::Atmosphere)
.unwrap();
assert!(
(atmosphere_101325.delta.drop_m - atmosphere_105000.delta.drop_m).abs() < 2e-3,
"Atmosphere's contribution changed by more than the expected small second-order \
residual when b's raw QNH value changed (101325 -> 105000 Pa) at the SAME altitude, \
even though Pressure and Altitude are both excluded -- {} vs {} -- a first-order \
pressure effect looks like it is leaking through",
atmosphere_101325.delta.drop_m,
atmosphere_105000.delta.drop_m
);
assert!(
atmosphere_105000.delta.drop_m.abs() > 0.001,
"expected a non-negligible Atmosphere contribution from temperature alone, got {}",
atmosphere_105000.delta.drop_m
);
}
#[test]
fn pressure_and_altitude_exclusion_give_an_exactly_zero_atmosphere_contribution_when_nothing_else_differs(
) {
let qnh_json = |altitude_m: f64| {
serde_json::json!({
"schema_version": 1,
"projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
"ballistic_coefficient": 0.243},
"rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
"shot": {"max_range_m": 900.0},
"atmosphere": {"altitude_m": altitude_m, "temperature_k": 288.0,
"pressure_pa": 101_325.0, "pressure_reference": "qnh"},
"wind": {}, "solver": {}, "effects": {}, "sampling": {"interval_m": 50.0}
})
.to_string()
};
let a = crate::solve_v1::solve_v1(
crate::solve_json::decode_solve_request_v1(&qnh_json(500.0)).unwrap(),
)
.unwrap()
.resolved_request;
let b = crate::solve_v1::solve_v1(
crate::solve_json::decode_solve_request_v1(&qnh_json(1200.0)).unwrap(),
)
.unwrap()
.resolved_request;
assert_ne!(a.atmosphere.altitude_m, b.atmosphere.altitude_m, "fixture assumption");
assert_eq!(
a.atmosphere.temperature_k, b.atmosphere.temperature_k,
"fixture assumption: temperature must be identical, or Atmosphere would have a \
real, non-excluded axis to report and the contribution would not be zero"
);
let rep = explain_difference(&a, &b, &[300.0])
.expect("Altitude/Pressure exclusion must not abort the comparison");
let atmosphere = rep.rows[0]
.contributions
.iter()
.find(|c| c.group == InputGroup::Atmosphere)
.unwrap();
assert_eq!(
atmosphere.delta.drop_m, 0.0,
"Atmosphere's drop contribution must be exactly zero when altitude is the ONLY \
thing that differs and both Altitude and Pressure are excluded, got {}",
atmosphere.delta.drop_m
);
assert_eq!(
atmosphere.delta.windage_m, 0.0,
"Atmosphere's windage contribution must be exactly zero for the same reason, got {}",
atmosphere.delta.windage_m
);
for direction in [SwapDirectionV1::Forward, SwapDirectionV1::Backward] {
for axis in [InputAxis::Altitude, InputAxis::Pressure] {
assert!(
rep.skipped_axes.iter().any(|s| s.group == InputGroup::Atmosphere
&& s.axis == axis
&& s.direction == direction),
"expected {axis:?} excluded on {direction:?}; got {:?}",
rep.skipped_axes
);
}
}
}
#[test]
fn pressure_is_still_swapped_when_the_altitude_matches() {
let build = |raw_qnh_pa: f64| {
serde_json::json!({
"schema_version": 1,
"projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
"ballistic_coefficient": 0.243},
"rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
"shot": {"max_range_m": 900.0},
"atmosphere": {"altitude_m": 500.0, "temperature_k": 288.0,
"pressure_pa": raw_qnh_pa, "pressure_reference": "qnh"},
"wind": {}, "solver": {}, "effects": {}, "sampling": {"interval_m": 50.0}
})
.to_string()
};
let a = crate::solve_v1::solve_v1(
crate::solve_json::decode_solve_request_v1(&build(101_325.0)).unwrap(),
)
.unwrap()
.resolved_request;
let b = crate::solve_v1::solve_v1(
crate::solve_json::decode_solve_request_v1(&build(98_000.0)).unwrap(),
)
.unwrap()
.resolved_request;
assert_eq!(
a.atmosphere.altitude_m, b.atmosphere.altitude_m,
"fixture assumption: identical altitude on both sides"
);
assert_ne!(
a.atmosphere.pressure_pa, b.atmosphere.pressure_pa,
"fixture assumption: different raw QNH must still resolve to different station \
pressure at the same altitude"
);
let rep = explain_difference(&a, &b, &[300.0]).unwrap();
assert!(
!rep.skipped_axes.iter().any(|s| s.group == InputGroup::Atmosphere
&& s.axis == InputAxis::Pressure),
"Pressure must not be excluded when altitude matches on both sides; got {:?}",
rep.skipped_axes
);
let atmosphere = rep
.rows[0]
.contributions
.iter()
.find(|c| c.group == InputGroup::Atmosphere)
.unwrap();
assert!(
atmosphere.delta.drop_m.abs() > 0.001,
"Atmosphere's contribution must be substantial and non-zero here -- the raw QNH \
genuinely differs and the shared altitude means Pressure is safe to swap -- got {}",
atmosphere.delta.drop_m
);
}
#[test]
fn an_axis_present_on_only_one_side_is_skipped_and_recorded_symmetrically() {
let with_latitude_json = serde_json::json!({
"schema_version": 1,
"projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
"ballistic_coefficient": 0.243},
"rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
"shot": {"max_range_m": 900.0},
"atmosphere": {"temperature_k": 288.0, "latitude_rad": 0.7},
"wind": {}, "solver": {}, "effects": {}, "sampling": {"interval_m": 50.0}
})
.to_string();
let a = crate::solve_v1::solve_v1(
crate::solve_json::decode_solve_request_v1(&with_latitude_json).unwrap(),
)
.unwrap()
.resolved_request;
assert_eq!(
a.atmosphere.latitude_rad,
Some(0.7),
"fixture assumption: a supplies latitude_rad"
);
let b = resolved(870.0, 300.0);
assert_eq!(
b.atmosphere.latitude_rad, None,
"fixture assumption: b omits latitude_rad entirely"
);
let rep = explain_difference(&a, &b, &[300.0])
.expect("a presence-only-on-one-side axis must not abort the whole comparison");
for direction in [SwapDirectionV1::Forward, SwapDirectionV1::Backward] {
assert!(
rep.skipped_axes.iter().any(|s| s.group == InputGroup::Atmosphere
&& s.axis == InputAxis::Latitude
&& s.direction == direction),
"expected a {direction:?} Latitude skip when it is present on only one side; \
got {:?}",
rep.skipped_axes
);
}
}
#[test]
fn coriolis_conflict_with_missing_latitude_is_excluded_not_a_hard_abort() {
let with_coriolis_json = serde_json::json!({
"schema_version": 1,
"projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
"ballistic_coefficient": 0.243},
"rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
"shot": {"max_range_m": 900.0},
"atmosphere": {"temperature_k": 288.0, "latitude_rad": 0.7},
"wind": {}, "solver": {}, "effects": {"coriolis": true},
"sampling": {"interval_m": 50.0}
})
.to_string();
let a = crate::solve_v1::solve_v1(
crate::solve_json::decode_solve_request_v1(&with_coriolis_json).unwrap(),
)
.unwrap()
.resolved_request;
assert!(a.effects.coriolis, "fixture assumption: a has coriolis enabled");
assert_eq!(
a.atmosphere.latitude_rad,
Some(0.7),
"fixture assumption: a supplies latitude_rad"
);
let b = resolved(823.0, 288.0);
assert!(!b.effects.coriolis, "fixture assumption: b has coriolis disabled");
assert_eq!(
b.atmosphere.latitude_rad, None,
"fixture assumption: b omits latitude_rad entirely"
);
let rep = explain_difference(&a, &b, &[600.0]).expect(
"a coriolis/latitude conflict must be excluded, not abort the whole comparison",
);
for direction in [SwapDirectionV1::Forward, SwapDirectionV1::Backward] {
assert!(
rep.skipped_axes.iter().any(|s| s.group == InputGroup::Effects
&& s.axis == InputAxis::CoriolisEnabled
&& s.direction == direction),
"expected a {direction:?} CoriolisEnabled skip when the flags differ and one \
side lacks latitude_rad; got {:?}",
rep.skipped_axes
);
}
let reason = &rep
.skipped_axes
.iter()
.find(|s| s.group == InputGroup::Effects && s.axis == InputAxis::CoriolisEnabled)
.unwrap()
.reason;
assert!(
reason.to_lowercase().contains("latitude"),
"reason must name latitude_rad as the cause: {reason}"
);
assert_eq!(rep.rows.len(), 1);
}
#[test]
fn coriolis_still_swaps_when_both_requests_have_latitude() {
let build = |coriolis: bool| {
serde_json::json!({
"schema_version": 1,
"projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
"ballistic_coefficient": 0.243},
"rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
"shot": {"max_range_m": 900.0},
"atmosphere": {"temperature_k": 288.0, "latitude_rad": 0.7},
"wind": {}, "solver": {}, "effects": {"coriolis": coriolis},
"sampling": {"interval_m": 50.0}
})
.to_string()
};
let a = crate::solve_v1::solve_v1(
crate::solve_json::decode_solve_request_v1(&build(true)).unwrap(),
)
.unwrap()
.resolved_request;
let b = crate::solve_v1::solve_v1(
crate::solve_json::decode_solve_request_v1(&build(false)).unwrap(),
)
.unwrap()
.resolved_request;
assert_eq!(
a.atmosphere.latitude_rad, b.atmosphere.latitude_rad,
"fixture assumption: identical latitude_rad on both sides"
);
assert_ne!(a.effects.coriolis, b.effects.coriolis, "fixture assumption: flags differ");
let rep = explain_difference(&a, &b, &[600.0]).unwrap();
assert!(
!rep.skipped_axes.iter().any(|s| s.group == InputGroup::Effects
&& s.axis == InputAxis::CoriolisEnabled),
"CoriolisEnabled must not be excluded when both sides supply the same latitude_rad; \
got {:?}",
rep.skipped_axes
);
let effects = rep
.rows[0]
.contributions
.iter()
.find(|c| c.group == InputGroup::Effects)
.unwrap();
assert!(
effects.delta.drop_m.abs() > 1e-6 || effects.delta.windage_m.abs() > 1e-6,
"Effects' contribution must be substantial and non-zero here -- coriolis genuinely \
differs and both sides can safely carry it -- got {:?}",
effects.delta
);
}
#[test]
fn shot_azimuth_is_refused_even_when_other_shot_geometry_axes_are_applied_first() {
let compass_json = serde_json::json!({
"schema_version": 1,
"projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
"ballistic_coefficient": 0.243},
"rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
"shot": {"max_range_m": 900.0, "shooting_angle_rad": 0.05, "cant_angle_rad": 0.01,
"shot_azimuth_rad": 0.3},
"atmosphere": {"temperature_k": 288.0},
"wind": {"speed_mps": 3.0, "direction_from_rad": 1.0, "wind_reference": "compass"},
"solver": {}, "effects": {}, "sampling": {"interval_m": 25.0}
})
.to_string();
let a = crate::solve_v1::solve_v1(
crate::solve_json::decode_solve_request_v1(&compass_json).unwrap(),
)
.unwrap()
.resolved_request;
match &a.wind {
crate::solve_json::ResolvedWindV1::Constant(c) => assert_eq!(
c.wind_reference,
Some(crate::solve_json::WindReferenceV1::Compass),
"fixture assumption: a's wind must be compass-referenced"
),
crate::solve_json::ResolvedWindV1::Segmented(_) => panic!("constant wind expected"),
}
let shooter_json = serde_json::json!({
"schema_version": 1,
"projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
"ballistic_coefficient": 0.243},
"rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
"shot": {"max_range_m": 950.0, "shooting_angle_rad": 0.08, "cant_angle_rad": 0.02,
"shot_azimuth_rad": 0.9},
"atmosphere": {"temperature_k": 288.0},
"wind": {"speed_mps": 3.0, "direction_from_rad": 1.0},
"solver": {}, "effects": {}, "sampling": {"interval_m": 25.0}
})
.to_string();
let b = crate::solve_v1::solve_v1(
crate::solve_json::decode_solve_request_v1(&shooter_json).unwrap(),
)
.unwrap()
.resolved_request;
let rep = explain_difference(&a, &b, &[300.0])
.expect("a refused axis must not abort the whole comparison");
let hit = rep
.skipped_axes
.iter()
.find(|s| {
s.group == InputGroup::ShotGeometry
&& s.axis == InputAxis::ShotAzimuth
&& s.direction == SwapDirectionV1::Forward
})
.unwrap_or_else(|| {
panic!(
"expected a Forward-direction ShotAzimuth skip under compass wind, even \
though TargetDistance/ShootingAngle/Cant are applied (and each \
re-resolved) first within the same ShotGeometry group; got skipped_axes = \
{:?}",
rep.skipped_axes
)
});
assert!(
hit.reason.to_lowercase().contains("compass"),
"reason should name compass wind: {}",
hit.reason
);
let backward_hit = rep
.skipped_axes
.iter()
.find(|s| s.group == InputGroup::ShotGeometry
&& s.axis == InputAxis::ShotAzimuth
&& s.direction == SwapDirectionV1::Backward)
.unwrap_or_else(|| {
panic!(
"expected a Backward-direction ShotAzimuth skip too (review I1: symmetric \
exclusion), even though b's wind is shooter-relative; got skipped_axes = \
{:?}",
rep.skipped_axes
)
});
assert!(
backward_hit.reason.to_lowercase().contains("compass"),
"the sympathetic Backward exclusion's reason should still explain the compass \
refusal that drove it: {}",
backward_hit.reason
);
}
#[test]
fn wind_direction_is_excluded_under_compass_wind_even_with_an_identical_raw_bearing() {
let build = |shot_azimuth_rad: f64| {
serde_json::json!({
"schema_version": 1,
"projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
"ballistic_coefficient": 0.243},
"rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
"shot": {"max_range_m": 900.0, "shot_azimuth_rad": shot_azimuth_rad},
"atmosphere": {"temperature_k": 288.0},
"wind": {"speed_mps": 3.0, "direction_from_rad": std::f64::consts::FRAC_PI_2,
"wind_reference": "compass"},
"solver": {}, "effects": {}, "sampling": {"interval_m": 25.0}
})
.to_string()
};
let a = crate::solve_v1::solve_v1(
crate::solve_json::decode_solve_request_v1(&build(0.0)).unwrap(),
)
.unwrap()
.resolved_request;
let b = crate::solve_v1::solve_v1(
crate::solve_json::decode_solve_request_v1(&build(std::f64::consts::FRAC_PI_4))
.unwrap(),
)
.unwrap()
.resolved_request;
let (a_dir, b_dir) = match (&a.wind, &b.wind) {
(
crate::solve_json::ResolvedWindV1::Constant(ca),
crate::solve_json::ResolvedWindV1::Constant(cb),
) => (ca.direction_from_rad, cb.direction_from_rad),
_ => panic!("constant wind expected on both sides"),
};
assert!(
(a_dir - b_dir).abs() > 0.1,
"fixture must produce genuinely different resolved wind directions from the SAME \
raw bearing (a: {a_dir}, b: {b_dir}), or this test proves nothing"
);
let rep = explain_difference(&a, &b, &[300.0])
.expect("a derived-value exclusion must not abort the whole comparison");
assert!(
rep.rows[0].total.windage_m.abs() > 0.01,
"expected a's and b's own (un-swapped) trajectories to differ meaningfully in \
windage, since their effective wind angles differ -- got total windage {}",
rep.rows[0].total.windage_m
);
let wind = rep
.rows[0]
.contributions
.iter()
.find(|c| c.group == InputGroup::Wind)
.unwrap();
assert_eq!(
wind.delta.drop_m, 0.0,
"Wind's drop contribution must be exactly zero (WindDirection excluded), got {}",
wind.delta.drop_m
);
assert_eq!(
wind.delta.windage_m, 0.0,
"Wind's windage contribution must be exactly zero (WindDirection excluded), got {}",
wind.delta.windage_m
);
for direction in [SwapDirectionV1::Forward, SwapDirectionV1::Backward] {
let hit = rep
.skipped_axes
.iter()
.find(|s| {
s.group == InputGroup::Wind
&& s.axis == InputAxis::WindDirection
&& s.direction == direction
})
.unwrap_or_else(|| {
panic!(
"expected a {direction:?} WindDirection skip under compass wind; got \
{:?}",
rep.skipped_axes
)
});
assert!(
hit.reason.to_lowercase().contains("shot_azimuth")
|| hit.reason.to_lowercase().contains("azimuth"),
"reason should name the shot-azimuth dependency: {}",
hit.reason
);
}
}
#[test]
fn wind_direction_is_still_swapped_when_the_shot_azimuth_matches() {
let build = |bearing_rad: f64| {
serde_json::json!({
"schema_version": 1,
"projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
"ballistic_coefficient": 0.243},
"rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05},
"shot": {"max_range_m": 900.0, "shot_azimuth_rad": 0.3},
"atmosphere": {"temperature_k": 288.0},
"wind": {"speed_mps": 3.0, "direction_from_rad": bearing_rad,
"wind_reference": "compass"},
"solver": {}, "effects": {}, "sampling": {"interval_m": 25.0}
})
.to_string()
};
let a = crate::solve_v1::solve_v1(
crate::solve_json::decode_solve_request_v1(&build(std::f64::consts::FRAC_PI_2))
.unwrap(),
)
.unwrap()
.resolved_request;
let b = crate::solve_v1::solve_v1(
crate::solve_json::decode_solve_request_v1(&build(std::f64::consts::FRAC_PI_4))
.unwrap(),
)
.unwrap()
.resolved_request;
assert_eq!(
a.shot.shot_azimuth_rad, b.shot.shot_azimuth_rad,
"fixture assumption: identical shot azimuth on both sides"
);
let rep = explain_difference(&a, &b, &[300.0]).unwrap();
assert!(
!rep.skipped_axes.iter().any(|s| s.group == InputGroup::Wind
&& s.axis == InputAxis::WindDirection),
"WindDirection must not be excluded when the shot azimuth matches on both sides; \
got {:?}",
rep.skipped_axes
);
let wind = rep
.rows[0]
.contributions
.iter()
.find(|c| c.group == InputGroup::Wind)
.unwrap();
assert!(
wind.delta.windage_m.abs() > 0.01,
"Wind's windage contribution must be substantial and non-zero here -- the raw \
bearing genuinely differs and the shared azimuth means WindDirection is safe to \
swap -- got {}",
wind.delta.windage_m
);
}
#[test]
fn wind_axes_are_skipped_and_recorded_under_segmented_wind_on_either_side() {
let a = resolved(823.0, 288.0);
let segmented_json = serde_json::json!({
"schema_version": 1,
"projectile": {"mass_kg": 0.0113, "diameter_m": 0.00782, "drag_model": "G7",
"ballistic_coefficient": 0.243},
"rifle": {"muzzle_velocity_mps": 850.0, "sight_height_m": 0.05},
"shot": {"max_range_m": 900.0, "zero_distance_m": 100.0},
"atmosphere": {"temperature_k": 295.0},
"wind": {"segments": [{"until_distance_m": 900.0, "speed_mps": 4.0,
"direction_from_rad": 1.2}]},
"solver": {}, "effects": {}, "sampling": {"interval_m": 25.0}
})
.to_string();
let b = crate::solve_v1::solve_v1(
crate::solve_json::decode_solve_request_v1(&segmented_json).unwrap(),
)
.unwrap()
.resolved_request;
assert!(matches!(
b.wind,
crate::solve_json::ResolvedWindV1::Segmented(_)
));
let rep = explain_difference(&a, &b, &[300.0])
.expect("segmented wind must not abort the whole comparison");
for axis in [
InputAxis::WindSpeed,
InputAxis::WindDirection,
InputAxis::WindVertical,
] {
assert!(
rep.skipped_axes.iter().any(|s| s.group == InputGroup::Wind
&& s.axis == axis
&& s.direction == SwapDirectionV1::Forward),
"{axis:?}: expected a Forward skip (the source, b, is segmented); got {:?}",
rep.skipped_axes
);
assert!(
rep.skipped_axes.iter().any(|s| s.group == InputGroup::Wind
&& s.axis == axis
&& s.direction == SwapDirectionV1::Backward),
"{axis:?}: expected a Backward skip (the destination, b, is segmented); got {:?}",
rep.skipped_axes
);
}
}
#[test]
fn magnus_and_enhanced_spin_drift_conflict_is_excluded_not_a_hard_abort() {
let a = resolved_with_effects(823.0, false, true); let b = resolved_with_effects(823.0, true, false); let rep = explain_difference(&a, &b, &[300.0]).expect(
"a magnus/enhanced_spin_drift conflict must be excluded, not abort the whole report",
);
for axis in [InputAxis::MagnusEnabled, InputAxis::EnhancedSpinDriftEnabled] {
for direction in [SwapDirectionV1::Forward, SwapDirectionV1::Backward] {
assert!(
rep.skipped_axes.iter().any(|s| s.group == InputGroup::Effects
&& s.axis == axis
&& s.direction == direction),
"{axis:?} {direction:?}: expected a skip naming the magnus/\
enhanced_spin_drift conflict; got {:?}",
rep.skipped_axes
);
}
}
let reason = &rep
.skipped_axes
.iter()
.find(|s| s.group == InputGroup::Effects && s.axis == InputAxis::MagnusEnabled)
.unwrap()
.reason;
assert!(reason.contains("enhanced_spin_drift"), "{reason}");
assert_eq!(rep.rows.len(), 1);
}
}