use serde::{Deserialize, Serialize};
use crate::perturbation::access::{read_axis, with_axis, AxisValue, KernelError};
use crate::perturbation::taxonomy::{axis_meta, AxisKind, InputAxis};
use crate::perturbation::{evaluate, Observation};
use crate::solve_json::ResolvedSolveRequestV1;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DifferenceScheme {
Central,
ForwardOneSided,
BackwardOneSided,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Derivative {
pub axis: InputAxis,
pub range_m: f64,
pub d_drop_d_x: f64,
pub d_windage_d_x: f64,
pub step_used: f64,
pub scheme: DifferenceScheme,
}
pub fn central_difference(
base: &ResolvedSolveRequestV1,
axis: InputAxis,
ranges_m: &[f64],
step: Option<f64>,
) -> Result<Vec<Derivative>, KernelError> {
let (rel, min_abs) = match axis_meta(axis).kind {
AxisKind::Continuous { default_rel_step, min_abs_step, .. } => (default_rel_step, min_abs_step),
AxisKind::Categorical => return Err(KernelError::CategoricalAxis(axis)),
};
let x = match read_axis(base, axis) {
Some(AxisValue::Scalar(x)) => x,
Some(_) => return Err(KernelError::TypeMismatch(axis)),
None => return Err(KernelError::AxisAbsent(axis)),
};
let h = step.unwrap_or_else(|| (x.abs() * rel).max(min_abs));
if !(h.is_finite() && h > 0.0) {
return Err(KernelError::NonFinite(axis));
}
let plus_req = with_axis(base, axis, AxisValue::Scalar(x + h))?;
let minus_req = with_axis(base, axis, AxisValue::Scalar(x - h))?;
let plus_result = evaluate(&plus_req, ranges_m);
let minus_result = evaluate(&minus_req, ranges_m);
let plus_is_domain_rejection = matches!(&plus_result, Err(e) if e.is_domain_rejection());
let minus_is_domain_rejection = matches!(&minus_result, Err(e) if e.is_domain_rejection());
let (hi, lo, denom, scheme) = match (plus_result, minus_result) {
(Ok(p), Ok(m)) => (p, m, 2.0 * h, DifferenceScheme::Central),
(Ok(p), Err(_)) if minus_is_domain_rejection => {
let base_req: crate::solve_json::SolveRequestV1 = base.into();
let f_x = evaluate(&base_req, ranges_m)?;
(p, f_x, h, DifferenceScheme::ForwardOneSided)
}
(Err(_), Ok(m)) if plus_is_domain_rejection => {
let base_req: crate::solve_json::SolveRequestV1 = base.into();
let f_x = evaluate(&base_req, ranges_m)?;
(f_x, m, h, DifferenceScheme::BackwardOneSided)
}
(Err(_), Err(_)) if plus_is_domain_rejection && minus_is_domain_rejection => {
return Err(KernelError::StepOutOfDomain { axis, attempted: h });
}
(Ok(_), Err(e)) => return Err(e),
(Err(e), _) => return Err(e),
};
debug_assert_eq!(hi.len(), lo.len());
let mut out = Vec::with_capacity(ranges_m.len());
for (a, b) in hi.iter().zip(lo.iter()) {
let d_drop = (a.drop_m - b.drop_m) / denom;
let d_wind = (a.windage_m - b.windage_m) / denom;
if !d_drop.is_finite() || !d_wind.is_finite() {
return Err(KernelError::NonFinite(axis));
}
out.push(Derivative {
axis,
range_m: a.range_m,
d_drop_d_x: d_drop,
d_windage_d_x: d_wind,
step_used: h,
scheme,
});
}
Ok(out)
}
pub const BISECTION_MAX_ITERATIONS: u32 = 80;
pub fn bisect_axis(
base: &ResolvedSolveRequestV1,
axis: InputAxis,
range_m: f64,
domain: (f64, f64),
predicate: &dyn Fn(&Observation) -> bool,
tolerance: f64,
) -> Result<Option<f64>, KernelError> {
if matches!(axis_meta(axis).kind, AxisKind::Categorical) {
return Err(KernelError::CategoricalAxis(axis));
}
let at = |v: f64| -> Result<bool, KernelError> {
let obs = evaluate(&with_axis(base, axis, AxisValue::Scalar(v))?, &[range_m])?;
Ok(predicate(&obs[0]))
};
let (mut lo, mut hi) = domain;
let lo_state = at(lo)?;
if lo_state == at(hi)? {
return Ok(None);
}
for _ in 0..BISECTION_MAX_ITERATIONS {
if (hi - lo).abs() <= tolerance {
break;
}
let mid = 0.5 * (lo + hi);
if at(mid)? == lo_state {
lo = mid;
} else {
hi = mid;
}
}
Ok(Some(0.5 * (lo + hi)))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::perturbation::InputAxis;
fn resolved(mv: 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": {},
"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
}
#[test]
fn drop_derivative_wrt_muzzle_velocity_is_negative() {
let r = resolved(823.0);
let d = central_difference(&r, InputAxis::MuzzleVelocityMps, &[600.0], None).unwrap();
assert_eq!(d.len(), 1);
assert!(d[0].d_drop_d_x < 0.0, "expected negative, got {}", d[0].d_drop_d_x);
assert!(d[0].step_used > 0.0);
assert_eq!(d[0].scheme, DifferenceScheme::Central);
}
#[test]
fn categorical_axes_cannot_be_differentiated() {
let r = resolved(823.0);
let e = central_difference(&r, InputAxis::CoriolisEnabled, &[600.0], None);
assert!(matches!(e, Err(KernelError::CategoricalAxis(_))));
}
#[test]
fn bisect_finds_the_crossing() {
let r = resolved(823.0);
let base = central_difference(&r, InputAxis::MuzzleVelocityMps, &[600.0], None).unwrap();
let _ = base;
let target_drop = 2.0_f64;
let found = bisect_axis(&r, InputAxis::MuzzleVelocityMps, 600.0, (600.0, 1100.0),
&|o: &Observation| o.drop_m < target_drop, 0.05).unwrap();
let mv = found.expect("a crossing exists in this domain");
assert!(mv > 600.0 && mv < 1100.0);
}
#[test]
fn central_difference_matches_the_vacuum_analytic_derivative() {
let json = serde_json::json!({
"schema_version": 1,
"projectile": {"mass_kg": 0.01, "diameter_m": 0.0077, "drag_model": "G1",
"ballistic_coefficient": 100.0},
"rifle": {"muzzle_velocity_mps": 800.0, "sight_height_m": 0.0},
"shot": {"max_range_m": 500.0, "muzzle_angle_rad": 0.0},
"atmosphere": {}, "wind": {}, "solver": {}, "effects": {},
"sampling": {"interval_m": 5.0}
}).to_string();
let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
let r = crate::solve_v1::solve_v1(req).unwrap().resolved_request;
let x = 400.0_f64;
let v = r.rifle.muzzle_velocity_mps;
let expected = -9.80665 * x * x / (v * v * v);
let d = central_difference(&r, InputAxis::MuzzleVelocityMps, &[x], None).unwrap();
let rel = ((d[0].d_drop_d_x - expected) / expected).abs();
assert!(rel < 0.02, "expected ~{expected}, got {} (rel {rel})", d[0].d_drop_d_x);
assert_eq!(d[0].scheme, DifferenceScheme::Central);
}
#[test]
fn central_difference_matches_the_vacuum_oracle_at_every_requested_range() {
let json = serde_json::json!({
"schema_version": 1,
"projectile": {"mass_kg": 0.01, "diameter_m": 0.0077, "drag_model": "G1",
"ballistic_coefficient": 100.0},
"rifle": {"muzzle_velocity_mps": 800.0, "sight_height_m": 0.0},
"shot": {"max_range_m": 500.0, "muzzle_angle_rad": 0.0},
"atmosphere": {}, "wind": {}, "solver": {}, "effects": {},
"sampling": {"interval_m": 5.0}
}).to_string();
let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
let r = crate::solve_v1::solve_v1(req).unwrap().resolved_request;
let v = r.rifle.muzzle_velocity_mps;
let ranges = [200.0_f64, 400.0_f64];
let d = central_difference(&r, InputAxis::MuzzleVelocityMps, &ranges, None).unwrap();
assert_eq!(d.len(), 2);
for (i, &x) in ranges.iter().enumerate() {
assert_eq!(
d[i].range_m, x,
"derivative {i} must be tagged with the range it was requested at"
);
let expected = -9.80665 * x * x / (v * v * v);
let rel = ((d[i].d_drop_d_x - expected) / expected).abs();
assert!(
rel < 0.02,
"range {x}: expected ~{expected}, got {} (rel {rel})",
d[i].d_drop_d_x
);
}
assert!(
d[1].d_drop_d_x.abs() > d[0].d_drop_d_x.abs() * 3.0,
"sensitivity to muzzle velocity should grow with range^2: at {} m got {}, at {} m got {}",
ranges[0], d[0].d_drop_d_x, ranges[1], d[1].d_drop_d_x
);
assert!(d.iter().all(|d| d.scheme == DifferenceScheme::Central));
}
#[test]
fn windage_derivative_wrt_wind_speed_dominates_and_matches_the_baseline_sign() {
let r = resolved(823.0);
let baseline_req: crate::solve_json::SolveRequestV1 = (&r).into();
let baseline = evaluate(&baseline_req, &[600.0]).expect("baseline evaluate");
assert!(
baseline[0].windage_m.abs() > 0.01,
"fixture must have non-negligible baseline windage, got {}",
baseline[0].windage_m
);
let d = central_difference(&r, InputAxis::WindSpeed, &[600.0], None).unwrap();
assert_eq!(d.len(), 1);
assert_eq!(
d[0].d_windage_d_x.signum(),
baseline[0].windage_m.signum(),
"more crosswind should push windage further the SAME way: derivative {}, baseline {}",
d[0].d_windage_d_x,
baseline[0].windage_m
);
assert!(
d[0].d_windage_d_x.abs() > d[0].d_drop_d_x.abs() * 5.0,
"a pure crosswind should move windage far more than it moves drop: \
d_windage_d_x={}, d_drop_d_x={}",
d[0].d_windage_d_x,
d[0].d_drop_d_x
);
}
#[test]
fn step_used_follows_the_crate_convention() {
let r = resolved(823.0);
let x = match read_axis(&r, InputAxis::WindSpeed).unwrap() {
AxisValue::Scalar(x) => x,
other => panic!("WindSpeed must read back as a scalar, got {other:?}"),
};
let (rel, min_abs) = match axis_meta(InputAxis::WindSpeed).kind {
AxisKind::Continuous { default_rel_step, min_abs_step, .. } => {
(default_rel_step, min_abs_step)
}
AxisKind::Categorical => panic!("WindSpeed must be continuous"),
};
let expected_h = (x.abs() * rel).max(min_abs);
let d = central_difference(&r, InputAxis::WindSpeed, &[600.0], None).unwrap();
assert_eq!(d[0].step_used, expected_h);
}
#[test]
fn bisect_axis_returns_none_when_the_predicate_never_flips() {
let r = resolved(823.0);
let found = bisect_axis(
&r,
InputAxis::MuzzleVelocityMps,
600.0,
(600.0, 1100.0),
&|o: &Observation| o.drop_m < 1000.0,
0.05,
)
.unwrap();
assert!(
found.is_none(),
"predicate holds everywhere on this domain; bisect_axis must report None, not a \
fabricated crossing, got {found:?}"
);
}
#[test]
fn bisect_axis_returns_none_when_the_predicate_is_false_at_both_ends() {
let r = resolved(823.0);
let found = bisect_axis(
&r,
InputAxis::MuzzleVelocityMps,
600.0,
(600.0, 1100.0),
&|o: &Observation| o.drop_m < -1000.0, 0.05,
)
.unwrap();
assert!(
found.is_none(),
"predicate is false everywhere on this domain; bisect_axis must report None, got \
{found:?}"
);
}
#[test]
fn bisect_axis_refuses_categorical_axes() {
let r = resolved(823.0);
let e = bisect_axis(
&r,
InputAxis::CoriolisEnabled,
600.0,
(0.0, 1.0),
&|o: &Observation| o.drop_m < 1.0,
0.1,
);
assert!(matches!(e, Err(KernelError::CategoricalAxis(InputAxis::CoriolisEnabled))));
}
#[test]
fn bisect_axis_converges_to_the_vacuum_analytic_root_not_the_domain_midpoint() {
let json = serde_json::json!({
"schema_version": 1,
"projectile": {"mass_kg": 0.01, "diameter_m": 0.0077, "drag_model": "G1",
"ballistic_coefficient": 100.0},
"rifle": {"muzzle_velocity_mps": 800.0, "sight_height_m": 0.0},
"shot": {"max_range_m": 500.0, "muzzle_angle_rad": 0.0},
"atmosphere": {}, "wind": {}, "solver": {}, "effects": {},
"sampling": {"interval_m": 5.0}
})
.to_string();
let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
let r = crate::solve_v1::solve_v1(req).unwrap().resolved_request;
let x = 400.0_f64;
let target_drop = 1.0_f64;
let domain = (500.0_f64, 2000.0_f64);
let expected_v = x * (9.80665_f64 / (2.0 * target_drop)).sqrt();
let midpoint = 0.5 * (domain.0 + domain.1);
assert!(
(expected_v - midpoint).abs() / expected_v > 0.2,
"fixture must keep the analytic root well away from the domain midpoint: \
root {expected_v}, midpoint {midpoint}"
);
let found = bisect_axis(
&r,
InputAxis::MuzzleVelocityMps,
x,
domain,
&|o: &Observation| o.drop_m < target_drop,
0.05,
)
.unwrap()
.expect("a crossing exists in this domain");
let rel = ((found - expected_v) / expected_v).abs();
assert!(
rel < 0.02,
"expected the bisection to converge near the analytic root ~{expected_v}, got \
{found} (rel {rel})"
);
}
#[test]
fn central_difference_propagates_axis_unsupported_for_request() {
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": 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 req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
let r = crate::solve_v1::solve_v1(req).unwrap().resolved_request;
let e = central_difference(&r, InputAxis::Altitude, &[300.0], None);
match e {
Err(KernelError::AxisUnsupportedForRequest { axis: InputAxis::Altitude, reason }) => {
assert!(reason.to_lowercase().contains("qnh"), "reason should name QNH: {reason}");
}
other => panic!("expected AxisUnsupportedForRequest, got {other:?}"),
}
}
#[test]
fn bisect_axis_propagates_axis_unsupported_for_request() {
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": 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 req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
let r = crate::solve_v1::solve_v1(req).unwrap().resolved_request;
let e = bisect_axis(
&r,
InputAxis::Altitude,
300.0,
(400.0, 600.0),
&|o: &Observation| o.drop_m < 1.0,
0.5,
);
match e {
Err(KernelError::AxisUnsupportedForRequest { axis: InputAxis::Altitude, reason }) => {
assert!(reason.to_lowercase().contains("qnh"), "reason should name QNH: {reason}");
}
other => panic!("expected AxisUnsupportedForRequest, got {other:?}"),
}
}
#[test]
fn central_difference_reports_axis_absent_for_wind_axes_under_segmented_wind() {
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": 823.0, "sight_height_m": 0.05},
"shot": {"max_range_m": 900.0},
"atmosphere": {},
"wind": {"segments": [{"until_distance_m": 900.0, "speed_mps": 3.0,
"direction_from_rad": 1.0}]},
"solver": {}, "effects": {}, "sampling": {"interval_m": 50.0}
})
.to_string();
let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
let r = crate::solve_v1::solve_v1(req).unwrap().resolved_request;
let e = central_difference(&r, InputAxis::WindSpeed, &[300.0], None);
assert!(matches!(e, Err(KernelError::AxisAbsent(InputAxis::WindSpeed))));
}
#[test]
fn bisect_axis_reports_axis_absent_for_wind_axes_under_segmented_wind() {
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": 823.0, "sight_height_m": 0.05},
"shot": {"max_range_m": 900.0},
"atmosphere": {},
"wind": {"segments": [{"until_distance_m": 900.0, "speed_mps": 3.0,
"direction_from_rad": 1.0}]},
"solver": {}, "effects": {}, "sampling": {"interval_m": 50.0}
})
.to_string();
let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
let r = crate::solve_v1::solve_v1(req).unwrap().resolved_request;
let e = bisect_axis(
&r,
InputAxis::WindSpeed,
300.0,
(0.0, 10.0),
&|o: &Observation| o.windage_m < 0.0,
0.5,
);
assert!(matches!(e, Err(KernelError::AxisAbsent(InputAxis::WindSpeed))));
}
#[test]
fn explicit_step_overrides_the_default_and_still_matches_the_vacuum_oracle() {
let json = serde_json::json!({
"schema_version": 1,
"projectile": {"mass_kg": 0.01, "diameter_m": 0.0077, "drag_model": "G1",
"ballistic_coefficient": 100.0},
"rifle": {"muzzle_velocity_mps": 800.0, "sight_height_m": 0.0},
"shot": {"max_range_m": 500.0, "muzzle_angle_rad": 0.0},
"atmosphere": {}, "wind": {}, "solver": {}, "effects": {},
"sampling": {"interval_m": 5.0}
})
.to_string();
let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
let r = crate::solve_v1::solve_v1(req).unwrap().resolved_request;
let v = r.rifle.muzzle_velocity_mps;
let x = 400.0_f64;
let custom_step = 2.0_f64;
let d = central_difference(&r, InputAxis::MuzzleVelocityMps, &[x], Some(custom_step))
.unwrap();
assert_eq!(
d[0].step_used, custom_step,
"an explicit step must override the default formula, not just be ignored"
);
let expected = -9.80665 * x * x / (v * v * v);
let rel = ((d[0].d_drop_d_x - expected) / expected).abs();
assert!(rel < 0.02, "expected ~{expected}, got {} (rel {rel})", d[0].d_drop_d_x);
assert_eq!(d[0].scheme, DifferenceScheme::Central);
}
#[test]
fn non_finite_or_non_positive_explicit_step_is_rejected() {
let r = resolved(823.0);
let nan = central_difference(&r, InputAxis::MuzzleVelocityMps, &[600.0], Some(f64::NAN));
assert!(matches!(nan, Err(KernelError::NonFinite(InputAxis::MuzzleVelocityMps))));
let zero = central_difference(&r, InputAxis::MuzzleVelocityMps, &[600.0], Some(0.0));
assert!(matches!(zero, Err(KernelError::NonFinite(InputAxis::MuzzleVelocityMps))));
let negative = central_difference(&r, InputAxis::MuzzleVelocityMps, &[600.0], Some(-1.0));
assert!(matches!(negative, Err(KernelError::NonFinite(InputAxis::MuzzleVelocityMps))));
}
#[test]
fn wind_speed_falls_back_to_one_sided_in_still_air() {
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": 823.0, "sight_height_m": 0.05},
"shot": {"max_range_m": 900.0, "zero_distance_m": 100.0},
"atmosphere": {},
"wind": {"speed_mps": 0.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();
let r = crate::solve_v1::solve_v1(req).unwrap().resolved_request;
assert_eq!(
match &r.wind {
crate::solve_json::ResolvedWindV1::Constant(c) => c.speed_mps,
crate::solve_json::ResolvedWindV1::Segmented(_) => panic!("constant wind expected"),
},
0.0,
"fixture assumption: still air"
);
let d = central_difference(&r, InputAxis::WindSpeed, &[600.0], None);
let d = d.unwrap_or_else(|e| panic!("still air must fall back, not error, got {e:?}"));
assert_eq!(d.len(), 1);
assert_eq!(
d[0].scheme,
DifferenceScheme::ForwardOneSided,
"the minus side (-0.05 m/s) leaves WindSpeed's domain, so this must be a forward \
one-sided fallback, not Central and not Backward"
);
assert!(d[0].d_windage_d_x.is_finite() && d[0].d_windage_d_x != 0.0);
}
#[test]
fn relative_humidity_falls_back_to_one_sided_at_the_dry_air_boundary() {
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": 823.0, "sight_height_m": 0.05},
"shot": {"max_range_m": 900.0, "zero_distance_m": 100.0},
"atmosphere": {"relative_humidity": 0.0},
"wind": {}, "solver": {}, "effects": {}, "sampling": {"interval_m": 25.0}
})
.to_string();
let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
let r = crate::solve_v1::solve_v1(req).unwrap().resolved_request;
assert_eq!(
r.atmosphere.relative_humidity, 0.0,
"fixture assumption: an explicit 0.0 must resolve to exactly 0.0, not the 0.5 \
literal default"
);
let d = central_difference(&r, InputAxis::RelativeHumidity, &[600.0], None);
let d = d.unwrap_or_else(|e| panic!("the dry-air boundary must fall back, not error, got {e:?}"));
assert_eq!(d.len(), 1);
assert_eq!(
d[0].scheme,
DifferenceScheme::ForwardOneSided,
"the minus side (-0.001) leaves RelativeHumidity's [0, 1] domain"
);
assert!(d[0].d_drop_d_x.is_finite());
}
#[test]
fn target_distance_falls_back_to_one_sided_when_queried_near_its_own_max_range() {
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": 823.0, "sight_height_m": 0.05},
"shot": {"max_range_m": 900.0, "zero_distance_m": 100.0},
"atmosphere": {}, "wind": {}, "solver": {}, "effects": {},
"sampling": {"interval_m": 25.0}
})
.to_string();
let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
let r = crate::solve_v1::solve_v1(req).unwrap().resolved_request;
assert_eq!(r.shot.max_range_m, 900.0);
let d = central_difference(&r, InputAxis::TargetDistance, &[899.5], None);
let d = d.unwrap_or_else(|e| {
panic!("a range just inside the unperturbed max_range_m must fall back, not error, got {e:?}")
});
assert_eq!(d.len(), 1);
assert_eq!(
d[0].scheme,
DifferenceScheme::ForwardOneSided,
"the minus side (max_range_m = 899.1) no longer covers the 899.5 m query, so this \
must be a forward one-sided fallback"
);
assert!(d[0].d_drop_d_x.is_finite());
}
#[test]
fn both_sides_out_of_domain_reports_step_out_of_domain() {
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": 823.0, "sight_height_m": 0.05},
"shot": {"max_range_m": 900.0, "zero_distance_m": 100.0},
"atmosphere": {"relative_humidity": 0.0},
"wind": {}, "solver": {}, "effects": {}, "sampling": {"interval_m": 25.0}
})
.to_string();
let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
let r = crate::solve_v1::solve_v1(req).unwrap().resolved_request;
assert_eq!(r.atmosphere.relative_humidity, 0.0, "fixture assumption");
let e = central_difference(&r, InputAxis::RelativeHumidity, &[600.0], Some(2.0));
match e {
Err(KernelError::StepOutOfDomain { axis: InputAxis::RelativeHumidity, attempted }) => {
assert_eq!(attempted, 2.0);
}
other => panic!("expected StepOutOfDomain, got {other:?}"),
}
}
#[test]
fn a_genuine_non_convergent_zero_search_on_one_side_propagates_not_falls_back() {
let r = resolved(823.0); let e = central_difference(&r, InputAxis::MuzzleVelocityMps, &[50.0], Some(813.0));
match e {
Err(KernelError::Solve { code, .. }) => {
assert_eq!(
code,
crate::solve_json::SolveErrorCodeV1::SolveFailed,
"expected the zero search's own non-convergence code, not a re-labeled \
domain rejection"
);
}
other => panic!(
"expected the minus side's genuine SolveFailed to propagate unchanged, not be \
swallowed into a one-sided fallback; got {other:?}"
),
}
}
}