use crate::perturbation::taxonomy::{axis_meta, InputAxis};
use crate::solve_json::{
DragModelV1, PressureReferenceV1, ResolvedSolveRequestV1, ResolvedWindV1, SolveErrorCodeV1,
SolveRequestV1, TwistDirectionV1, WindReferenceV1,
};
use crate::trajectory_observation::TrajectoryObservationError;
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum AxisValue {
Scalar(f64),
Flag(bool),
DragModel(DragModelV1),
TwistDirection(TwistDirectionV1),
}
#[derive(Debug, Clone, PartialEq)]
pub enum KernelError {
CategoricalAxis(InputAxis),
AxisAbsent(InputAxis),
TypeMismatch(InputAxis),
AxisUnsupportedForRequest {
axis: InputAxis,
reason: &'static str,
},
Solve {
code: SolveErrorCodeV1,
message: String,
},
Observation(TrajectoryObservationError),
NonFinite(InputAxis),
StepOutOfDomain { axis: InputAxis, attempted: f64 },
DuplicateAxis(InputAxis),
InvalidDomain { axis: InputAxis, reason: &'static str },
}
impl std::fmt::Display for KernelError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
KernelError::CategoricalAxis(a) => {
write!(f, "axis {a:?} is categorical and cannot be differentiated")
}
KernelError::AxisAbsent(a) => write!(f, "axis {a:?} is not present in this request"),
KernelError::TypeMismatch(a) => write!(f, "value type does not match axis {a:?}"),
KernelError::AxisUnsupportedForRequest { axis, reason } => {
write!(f, "axis {axis:?} is not supported for this request: {reason}")
}
KernelError::Solve { code, message } => write!(f, "solve failed ({code:?}): {message}"),
KernelError::Observation(e) => write!(f, "observation failed: {e}"),
KernelError::NonFinite(a) => write!(f, "axis {a:?} produced a non-finite result"),
KernelError::StepOutOfDomain { axis, attempted } => write!(
f,
"axis {axis:?} could not be differentiated with step {attempted}: both the \
forward and backward perturbed values failed to evaluate"
),
KernelError::DuplicateAxis(a) => {
write!(f, "axis {a:?} was declared more than once")
}
KernelError::InvalidDomain { axis, reason } => {
write!(f, "invalid tolerance-envelope domain for axis {axis:?}: {reason}")
}
}
}
}
impl std::error::Error for KernelError {}
impl KernelError {
pub fn is_domain_rejection(&self) -> bool {
matches!(
self,
KernelError::Solve { code: SolveErrorCodeV1::InvalidValue, .. }
| KernelError::Observation(TrajectoryObservationError::OutOfRange { .. })
)
}
}
pub(crate) fn wind_reference_of(w: &ResolvedWindV1) -> Option<WindReferenceV1> {
match w {
ResolvedWindV1::Constant(c) => c.wind_reference,
ResolvedWindV1::Segmented(s) => s.wind_reference,
}
}
pub fn read_axis(r: &ResolvedSolveRequestV1, axis: InputAxis) -> Option<AxisValue> {
use InputAxis::*;
let wind_speed = match &r.wind {
ResolvedWindV1::Constant(c) => Some(c.speed_mps),
ResolvedWindV1::Segmented(_) => None,
};
let wind_dir = match &r.wind {
ResolvedWindV1::Constant(c) => Some(c.direction_from_rad),
ResolvedWindV1::Segmented(_) => None,
};
let wind_vert = match &r.wind {
ResolvedWindV1::Constant(c) => Some(c.vertical_speed_mps),
ResolvedWindV1::Segmented(_) => None,
};
Some(match axis {
Mass => AxisValue::Scalar(r.projectile.mass_kg),
Diameter => AxisValue::Scalar(r.projectile.diameter_m),
Length => AxisValue::Scalar(r.projectile.length_m?),
BallisticCoefficient => AxisValue::Scalar(r.projectile.ballistic_coefficient),
TwistRate => AxisValue::Scalar(r.rifle.twist_rate_m_per_turn),
TwistDirection => AxisValue::TwistDirection(r.rifle.twist_direction),
DragModel => AxisValue::DragModel(r.projectile.drag_model),
MuzzleVelocityMps => AxisValue::Scalar(r.rifle.muzzle_velocity_mps),
SightHeight => AxisValue::Scalar(r.rifle.sight_height_m),
ZeroDistance => AxisValue::Scalar(r.shot.zero_distance_m?),
ZeroPoiUp => AxisValue::Scalar(r.shot.zero_poi_up_m?),
ZeroPoiRight => AxisValue::Scalar(r.shot.zero_poi_right_m?),
SightOffsetLateral => AxisValue::Scalar(r.rifle.sight_offset_lateral_m?),
MuzzleHeight => AxisValue::Scalar(r.rifle.muzzle_height_m),
MuzzleAngle => AxisValue::Scalar(r.shot.muzzle_angle_rad),
Altitude => AxisValue::Scalar(r.atmosphere.altitude_m),
Temperature => AxisValue::Scalar(r.atmosphere.temperature_k),
Pressure => AxisValue::Scalar(r.atmosphere.pressure_pa),
RelativeHumidity => AxisValue::Scalar(r.atmosphere.relative_humidity),
Latitude => AxisValue::Scalar(r.atmosphere.latitude_rad?),
WindSpeed => AxisValue::Scalar(wind_speed?),
WindDirection => AxisValue::Scalar(wind_dir?),
WindVertical => AxisValue::Scalar(wind_vert?),
TargetDistance => AxisValue::Scalar(r.shot.max_range_m),
ShootingAngle => AxisValue::Scalar(r.shot.shooting_angle_rad),
Cant => AxisValue::Scalar(r.shot.cant_angle_rad),
ShotAzimuth => AxisValue::Scalar(r.shot.shot_azimuth_rad),
AimAzimuth => AxisValue::Scalar(r.shot.aim_azimuth_rad),
TargetHeight => AxisValue::Scalar(r.shot.target_height_m),
MagnusEnabled => AxisValue::Flag(r.effects.magnus),
CoriolisEnabled => AxisValue::Flag(r.effects.coriolis),
EnhancedSpinDriftEnabled => AxisValue::Flag(r.effects.enhanced_spin_drift),
})
}
pub fn with_axis(
r: &ResolvedSolveRequestV1,
axis: InputAxis,
v: AxisValue,
) -> Result<SolveRequestV1, KernelError> {
if axis == InputAxis::Altitude
&& r.atmosphere.pressure_reference == Some(PressureReferenceV1::Qnh)
{
return Err(KernelError::AxisUnsupportedForRequest {
axis,
reason: "the original request declared a QNH pressure_reference; the rebuilt \
request always carries absolute station pressure (request_roundtrip.rs \
cannot re-derive the original altimeter setting), so perturbing altitude \
would change air density-by-altitude without moving the QNH-referenced \
station pressure the way the caller means",
});
}
if axis == InputAxis::ShotAzimuth
&& wind_reference_of(&r.wind) == Some(WindReferenceV1::Compass)
{
return Err(KernelError::AxisUnsupportedForRequest {
axis,
reason: "the original request declared compass-referenced wind; the rebuilt \
request always carries shooter-relative wind (request_roundtrip.rs \
cannot re-derive the original earth-fixed bearing), so perturbing the \
shot azimuth would rotate the wind WITH the rifle instead of keeping it \
earth-fixed",
});
}
if matches!(
axis,
InputAxis::WindSpeed | InputAxis::WindDirection | InputAxis::WindVertical
) && matches!(r.wind, ResolvedWindV1::Segmented(_))
{
return Err(KernelError::AxisAbsent(axis));
}
let mut req: SolveRequestV1 = r.into();
let scalar = |v: AxisValue| -> Result<f64, KernelError> {
match v {
AxisValue::Scalar(x) if x.is_finite() => Ok(x),
AxisValue::Scalar(_) => Err(KernelError::NonFinite(axis)),
_ => Err(KernelError::TypeMismatch(axis)),
}
};
let flag = |v: AxisValue| -> Result<bool, KernelError> {
match v {
AxisValue::Flag(b) => Ok(b),
_ => Err(KernelError::TypeMismatch(axis)),
}
};
let drag_model = |v: AxisValue| -> Result<DragModelV1, KernelError> {
match v {
AxisValue::DragModel(m) => Ok(m),
_ => Err(KernelError::TypeMismatch(axis)),
}
};
let twist_direction = |v: AxisValue| -> Result<TwistDirectionV1, KernelError> {
match v {
AxisValue::TwistDirection(d) => Ok(d),
_ => Err(KernelError::TypeMismatch(axis)),
}
};
use InputAxis::*;
match axis {
Mass => req.projectile.mass_kg = scalar(v)?,
Diameter => req.projectile.diameter_m = scalar(v)?,
Length => req.projectile.length_m = Some(scalar(v)?),
BallisticCoefficient => req.projectile.ballistic_coefficient = scalar(v)?,
TwistRate => req.rifle.twist_rate_m_per_turn = Some(scalar(v)?),
TwistDirection => req.rifle.twist_direction = Some(twist_direction(v)?),
DragModel => req.projectile.drag_model = drag_model(v)?,
MuzzleVelocityMps => req.rifle.muzzle_velocity_mps = scalar(v)?,
SightHeight => req.rifle.sight_height_m = Some(scalar(v)?),
ZeroDistance => req.shot.zero_distance_m = Some(scalar(v)?),
ZeroPoiUp => req.shot.zero_poi_up_m = Some(scalar(v)?),
ZeroPoiRight => req.shot.zero_poi_right_m = Some(scalar(v)?),
SightOffsetLateral => req.rifle.sight_offset_lateral_m = Some(scalar(v)?),
MuzzleHeight => req.rifle.muzzle_height_m = Some(scalar(v)?),
MuzzleAngle => req.shot.muzzle_angle_rad = Some(scalar(v)?),
Altitude => req.atmosphere.altitude_m = Some(scalar(v)?),
Temperature => req.atmosphere.temperature_k = Some(scalar(v)?),
Pressure => req.atmosphere.pressure_pa = Some(scalar(v)?),
RelativeHumidity => req.atmosphere.relative_humidity = Some(scalar(v)?),
Latitude => req.atmosphere.latitude_rad = Some(scalar(v)?),
WindSpeed => req.wind.speed_mps = Some(scalar(v)?),
WindDirection => req.wind.direction_from_rad = Some(scalar(v)?),
WindVertical => req.wind.vertical_speed_mps = Some(scalar(v)?),
TargetDistance => req.shot.max_range_m = scalar(v)?,
ShootingAngle => req.shot.shooting_angle_rad = Some(scalar(v)?),
Cant => req.shot.cant_angle_rad = Some(scalar(v)?),
ShotAzimuth => req.shot.shot_azimuth_rad = Some(scalar(v)?),
AimAzimuth => req.shot.aim_azimuth_rad = Some(scalar(v)?),
TargetHeight => req.shot.target_height_m = Some(scalar(v)?),
MagnusEnabled => req.effects.magnus = Some(flag(v)?),
CoriolisEnabled => req.effects.coriolis = Some(flag(v)?),
EnhancedSpinDriftEnabled => req.effects.enhanced_spin_drift = Some(flag(v)?),
}
if axis_meta(axis).requires_rezero && req.shot.zero_distance_m.is_some() {
req.shot.muzzle_angle_rad = None;
}
Ok(req)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::perturbation::InputAxis;
fn resolved() -> 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, "length_m": 0.032},
"rifle": {"muzzle_velocity_mps": 823.0, "sight_height_m": 0.05,
"muzzle_height_m": 0.02, "twist_rate_m_per_turn": 0.2794,
"twist_direction": "left", "sight_offset_lateral_m": 0.03},
"shot": {"max_range_m": 900.0, "zero_distance_m": 100.0, "target_height_m": 0.3,
"zero_poi_up_m": 0.01, "zero_poi_right_m": 0.02},
"atmosphere": {"latitude_rad": 0.6},
"wind": {"speed_mps": 3.0, "direction_from_rad": std::f64::consts::FRAC_PI_2},
"solver": {}, "effects": {}, "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 read_then_write_is_identity() {
let r = resolved();
let v = read_axis(&r, InputAxis::MuzzleVelocityMps).expect("axis present");
let rebuilt = with_axis(&r, InputAxis::MuzzleVelocityMps, v).unwrap();
assert_eq!(rebuilt.rifle.muzzle_velocity_mps, r.rifle.muzzle_velocity_mps);
}
#[test]
fn writing_an_axis_changes_only_that_axis() {
let r = resolved();
let changed = with_axis(&r, InputAxis::MuzzleVelocityMps, AxisValue::Scalar(900.0)).unwrap();
let baseline: crate::solve_json::SolveRequestV1 = (&r).into();
assert_eq!(changed.rifle.muzzle_velocity_mps, 900.0);
assert_eq!(changed.atmosphere.pressure_pa, baseline.atmosphere.pressure_pa);
assert_eq!(changed.wind.speed_mps, baseline.wind.speed_mps);
assert_eq!(changed.shot.max_range_m, baseline.shot.max_range_m);
assert_eq!(changed.projectile.drag_model, baseline.projectile.drag_model);
assert_eq!(changed.rifle.twist_rate_m_per_turn, baseline.rifle.twist_rate_m_per_turn);
assert_eq!(changed.rifle.twist_direction, baseline.rifle.twist_direction);
assert_eq!(changed.rifle.muzzle_height_m, baseline.rifle.muzzle_height_m);
assert_eq!(changed.shot.target_height_m, baseline.shot.target_height_m);
}
#[test]
fn writing_a_flag_into_a_scalar_axis_is_a_type_error() {
let r = resolved();
let e = with_axis(&r, InputAxis::MuzzleVelocityMps, AxisValue::Flag(true));
assert!(matches!(e, Err(KernelError::TypeMismatch(_))));
}
#[test]
fn effect_flags_round_trip() {
let r = resolved();
let changed = with_axis(&r, InputAxis::CoriolisEnabled, AxisValue::Flag(true)).unwrap();
assert_eq!(changed.effects.coriolis, Some(true));
}
#[test]
fn drag_model_axis_reads_and_writes_the_enum_value() {
let r = resolved();
let v = read_axis(&r, InputAxis::DragModel).expect("axis present");
assert_eq!(v, AxisValue::DragModel(DragModelV1::G7));
let changed =
with_axis(&r, InputAxis::DragModel, AxisValue::DragModel(DragModelV1::G1)).unwrap();
assert_eq!(changed.projectile.drag_model, DragModelV1::G1);
assert_eq!(changed.projectile.mass_kg, r.projectile.mass_kg);
assert_eq!(
changed.projectile.ballistic_coefficient,
r.projectile.ballistic_coefficient
);
}
#[test]
fn twist_direction_axis_reads_and_writes_the_enum_value() {
let r = resolved();
let v = read_axis(&r, InputAxis::TwistDirection).expect("axis present");
assert_eq!(v, AxisValue::TwistDirection(TwistDirectionV1::Left));
let changed = with_axis(
&r,
InputAxis::TwistDirection,
AxisValue::TwistDirection(TwistDirectionV1::Right),
)
.unwrap();
assert_eq!(changed.rifle.twist_direction, Some(TwistDirectionV1::Right));
}
#[test]
fn writing_a_scalar_into_the_drag_model_axis_is_a_type_error() {
let r = resolved();
let e = with_axis(&r, InputAxis::DragModel, AxisValue::Scalar(1.0));
assert!(matches!(
e,
Err(KernelError::TypeMismatch(InputAxis::DragModel))
));
}
#[test]
fn writing_a_drag_model_into_a_scalar_axis_is_a_type_error() {
let r = resolved();
let e = with_axis(&r, InputAxis::Mass, AxisValue::DragModel(DragModelV1::G1));
assert!(matches!(e, Err(KernelError::TypeMismatch(InputAxis::Mass))));
}
#[test]
fn writing_a_twist_direction_into_the_drag_model_axis_is_a_type_error() {
let r = resolved();
let e = with_axis(
&r,
InputAxis::DragModel,
AxisValue::TwistDirection(TwistDirectionV1::Left),
);
assert!(matches!(
e,
Err(KernelError::TypeMismatch(InputAxis::DragModel))
));
}
#[test]
fn twist_rate_and_muzzle_height_and_target_height_round_trip() {
let r = resolved();
for axis in [InputAxis::TwistRate, InputAxis::MuzzleHeight, InputAxis::TargetHeight] {
let v = read_axis(&r, axis).unwrap_or_else(|| panic!("{axis:?} should be present"));
let rebuilt = with_axis(&r, axis, v).unwrap();
match (axis, v) {
(InputAxis::TwistRate, AxisValue::Scalar(x)) => {
assert_eq!(rebuilt.rifle.twist_rate_m_per_turn, Some(x))
}
(InputAxis::MuzzleHeight, AxisValue::Scalar(x)) => {
assert_eq!(rebuilt.rifle.muzzle_height_m, Some(x))
}
(InputAxis::TargetHeight, AxisValue::Scalar(x)) => {
assert_eq!(rebuilt.shot.target_height_m, Some(x))
}
_ => panic!("expected a scalar for {axis:?}"),
}
}
}
#[test]
fn wind_axes_are_absent_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;
assert!(matches!(r.wind, ResolvedWindV1::Segmented(_)));
assert_eq!(read_axis(&r, InputAxis::WindSpeed), None);
assert_eq!(read_axis(&r, InputAxis::WindDirection), None);
assert_eq!(read_axis(&r, InputAxis::WindVertical), None);
for axis in [
InputAxis::WindSpeed,
InputAxis::WindDirection,
InputAxis::WindVertical,
] {
let e = with_axis(&r, axis, AxisValue::Scalar(1.0));
assert!(
matches!(e, Err(KernelError::AxisAbsent(a)) if a == axis),
"{axis:?}: expected AxisAbsent, got {e:?}"
);
}
}
#[test]
fn writing_zero_distance_onto_an_angle_only_request_clears_the_carried_angle() {
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, "muzzle_angle_rad": 0.01},
"atmosphere": {}, "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;
assert_eq!(r.shot.zero_distance_m, None);
assert_eq!(r.shot.muzzle_angle_rad, 0.01);
let rebuilt = with_axis(&r, InputAxis::ZeroDistance, AxisValue::Scalar(100.0)).unwrap();
assert_eq!(rebuilt.shot.zero_distance_m, Some(100.0));
assert_eq!(
rebuilt.shot.muzzle_angle_rad, None,
"the carried angle must be cleared so solve_v1 actually re-zeroes at the new \
distance instead of skipping the elevation search because an explicit angle was \
still present"
);
}
#[test]
fn rezero_axis_clears_the_carried_muzzle_angle_when_a_zero_distance_is_present() {
let r = resolved(); assert!(r.shot.zero_distance_m.is_some());
assert!(
axis_meta(InputAxis::Mass).requires_rezero,
"fixture assumption: Mass must be a requires_rezero axis for this test to mean \
anything"
);
let changed = with_axis(&r, InputAxis::Mass, AxisValue::Scalar(0.02)).unwrap();
assert_eq!(changed.shot.muzzle_angle_rad, None);
}
#[test]
fn non_rezero_axis_preserves_the_carried_muzzle_angle() {
let r = resolved(); assert!(r.shot.zero_distance_m.is_some());
assert!(
!axis_meta(InputAxis::WindSpeed).requires_rezero,
"fixture assumption: WindSpeed must NOT be a requires_rezero axis for this test to \
mean anything"
);
let changed = with_axis(&r, InputAxis::WindSpeed, AxisValue::Scalar(5.0)).unwrap();
assert_eq!(changed.shot.muzzle_angle_rad, Some(r.shot.muzzle_angle_rad));
}
#[test]
fn rezero_axis_does_not_clear_the_angle_when_no_zero_distance_is_present() {
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, "muzzle_angle_rad": 0.01},
"atmosphere": {}, "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;
assert_eq!(r.shot.zero_distance_m, None);
assert!(axis_meta(InputAxis::Mass).requires_rezero);
let changed = with_axis(&r, InputAxis::Mass, AxisValue::Scalar(0.02)).unwrap();
assert_eq!(changed.shot.muzzle_angle_rad, Some(r.shot.muzzle_angle_rad));
}
#[test]
fn every_present_axis_round_trips_without_disturbing_any_other_field() {
let r = resolved();
let baseline: crate::solve_json::SolveRequestV1 = (&r).into();
assert!(r.shot.zero_distance_m.is_some());
let mut exercised = 0usize;
for &axis in InputAxis::ALL {
let Some(v) = read_axis(&r, axis) else {
continue;
};
exercised += 1;
let rebuilt = with_axis(&r, axis, v).unwrap();
let mut expected = baseline.clone();
if axis_meta(axis).requires_rezero {
expected.shot.muzzle_angle_rad = None;
}
assert_eq!(
rebuilt, expected,
"writing {axis:?} back onto its own current value changed a field other than \
itself (and, for a rezero axis, the carried angle)"
);
}
assert_eq!(
exercised,
InputAxis::ALL.len(),
"fixture does not make every axis readable -- this test is silently under-covering"
);
}
#[test]
fn every_axis_writes_to_its_own_named_destination_field() {
let r = resolved();
use InputAxis::*;
for &axis in InputAxis::ALL {
match axis {
Mass => {
let rebuilt = with_axis(&r, axis, AxisValue::Scalar(0.0199)).unwrap();
assert_eq!(rebuilt.projectile.mass_kg, 0.0199, "{axis:?}");
}
Diameter => {
let rebuilt = with_axis(&r, axis, AxisValue::Scalar(0.0090)).unwrap();
assert_eq!(rebuilt.projectile.diameter_m, 0.0090, "{axis:?}");
}
Length => {
let rebuilt = with_axis(&r, axis, AxisValue::Scalar(0.040)).unwrap();
assert_eq!(rebuilt.projectile.length_m, Some(0.040), "{axis:?}");
}
BallisticCoefficient => {
let rebuilt = with_axis(&r, axis, AxisValue::Scalar(0.300)).unwrap();
assert_eq!(rebuilt.projectile.ballistic_coefficient, 0.300, "{axis:?}");
}
TwistRate => {
let rebuilt = with_axis(&r, axis, AxisValue::Scalar(0.3556)).unwrap();
assert_eq!(rebuilt.rifle.twist_rate_m_per_turn, Some(0.3556), "{axis:?}");
}
TwistDirection => {
let rebuilt = with_axis(
&r,
axis,
AxisValue::TwistDirection(TwistDirectionV1::Right),
)
.unwrap();
assert_eq!(
rebuilt.rifle.twist_direction,
Some(TwistDirectionV1::Right),
"{axis:?}"
);
}
DragModel => {
let rebuilt =
with_axis(&r, axis, AxisValue::DragModel(DragModelV1::G1)).unwrap();
assert_eq!(rebuilt.projectile.drag_model, DragModelV1::G1, "{axis:?}");
}
MuzzleVelocityMps => {
let rebuilt = with_axis(&r, axis, AxisValue::Scalar(900.0)).unwrap();
assert_eq!(rebuilt.rifle.muzzle_velocity_mps, 900.0, "{axis:?}");
}
SightHeight => {
let rebuilt = with_axis(&r, axis, AxisValue::Scalar(0.06)).unwrap();
assert_eq!(rebuilt.rifle.sight_height_m, Some(0.06), "{axis:?}");
}
ZeroDistance => {
let rebuilt = with_axis(&r, axis, AxisValue::Scalar(200.0)).unwrap();
assert_eq!(rebuilt.shot.zero_distance_m, Some(200.0), "{axis:?}");
}
ZeroPoiUp => {
let rebuilt = with_axis(&r, axis, AxisValue::Scalar(0.05)).unwrap();
assert_eq!(rebuilt.shot.zero_poi_up_m, Some(0.05), "{axis:?}");
}
ZeroPoiRight => {
let rebuilt = with_axis(&r, axis, AxisValue::Scalar(0.06)).unwrap();
assert_eq!(rebuilt.shot.zero_poi_right_m, Some(0.06), "{axis:?}");
}
SightOffsetLateral => {
let rebuilt = with_axis(&r, axis, AxisValue::Scalar(0.10)).unwrap();
assert_eq!(rebuilt.rifle.sight_offset_lateral_m, Some(0.10), "{axis:?}");
}
MuzzleHeight => {
let rebuilt = with_axis(&r, axis, AxisValue::Scalar(0.05)).unwrap();
assert_eq!(rebuilt.rifle.muzzle_height_m, Some(0.05), "{axis:?}");
}
MuzzleAngle => {
let sentinel = r.shot.muzzle_angle_rad + 0.01;
let rebuilt = with_axis(&r, axis, AxisValue::Scalar(sentinel)).unwrap();
assert_eq!(rebuilt.shot.muzzle_angle_rad, Some(sentinel), "{axis:?}");
}
Altitude => {
let rebuilt = with_axis(&r, axis, AxisValue::Scalar(1500.0)).unwrap();
assert_eq!(rebuilt.atmosphere.altitude_m, Some(1500.0), "{axis:?}");
}
Temperature => {
let sentinel = r.atmosphere.temperature_k + 5.0;
let rebuilt = with_axis(&r, axis, AxisValue::Scalar(sentinel)).unwrap();
assert_eq!(rebuilt.atmosphere.temperature_k, Some(sentinel), "{axis:?}");
}
Pressure => {
let sentinel = r.atmosphere.pressure_pa + 500.0;
let rebuilt = with_axis(&r, axis, AxisValue::Scalar(sentinel)).unwrap();
assert_eq!(rebuilt.atmosphere.pressure_pa, Some(sentinel), "{axis:?}");
}
RelativeHumidity => {
let rebuilt = with_axis(&r, axis, AxisValue::Scalar(0.7)).unwrap();
assert_eq!(rebuilt.atmosphere.relative_humidity, Some(0.7), "{axis:?}");
}
Latitude => {
let rebuilt = with_axis(&r, axis, AxisValue::Scalar(0.3)).unwrap();
assert_eq!(rebuilt.atmosphere.latitude_rad, Some(0.3), "{axis:?}");
}
WindSpeed => {
let rebuilt = with_axis(&r, axis, AxisValue::Scalar(7.0)).unwrap();
assert_eq!(rebuilt.wind.speed_mps, Some(7.0), "{axis:?}");
}
WindDirection => {
let rebuilt = with_axis(&r, axis, AxisValue::Scalar(0.4)).unwrap();
assert_eq!(rebuilt.wind.direction_from_rad, Some(0.4), "{axis:?}");
}
WindVertical => {
let rebuilt = with_axis(&r, axis, AxisValue::Scalar(1.5)).unwrap();
assert_eq!(rebuilt.wind.vertical_speed_mps, Some(1.5), "{axis:?}");
}
TargetDistance => {
let rebuilt = with_axis(&r, axis, AxisValue::Scalar(1000.0)).unwrap();
assert_eq!(rebuilt.shot.max_range_m, 1000.0, "{axis:?}");
}
ShootingAngle => {
let rebuilt = with_axis(&r, axis, AxisValue::Scalar(0.2)).unwrap();
assert_eq!(rebuilt.shot.shooting_angle_rad, Some(0.2), "{axis:?}");
}
Cant => {
let rebuilt = with_axis(&r, axis, AxisValue::Scalar(0.15)).unwrap();
assert_eq!(rebuilt.shot.cant_angle_rad, Some(0.15), "{axis:?}");
}
ShotAzimuth => {
let rebuilt = with_axis(&r, axis, AxisValue::Scalar(0.25)).unwrap();
assert_eq!(rebuilt.shot.shot_azimuth_rad, Some(0.25), "{axis:?}");
}
AimAzimuth => {
let rebuilt = with_axis(&r, axis, AxisValue::Scalar(0.35)).unwrap();
assert_eq!(rebuilt.shot.aim_azimuth_rad, Some(0.35), "{axis:?}");
}
TargetHeight => {
let rebuilt = with_axis(&r, axis, AxisValue::Scalar(0.5)).unwrap();
assert_eq!(rebuilt.shot.target_height_m, Some(0.5), "{axis:?}");
}
MagnusEnabled => {
let rebuilt = with_axis(&r, axis, AxisValue::Flag(true)).unwrap();
assert_eq!(rebuilt.effects.magnus, Some(true), "{axis:?}");
}
CoriolisEnabled => {
let rebuilt = with_axis(&r, axis, AxisValue::Flag(true)).unwrap();
assert_eq!(rebuilt.effects.coriolis, Some(true), "{axis:?}");
}
EnhancedSpinDriftEnabled => {
let rebuilt = with_axis(&r, axis, AxisValue::Flag(true)).unwrap();
assert_eq!(rebuilt.effects.enhanced_spin_drift, Some(true), "{axis:?}");
}
}
}
}
#[test]
fn altitude_axis_is_unsupported_when_the_original_pressure_was_qnh() {
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;
assert_eq!(r.atmosphere.pressure_reference, Some(PressureReferenceV1::Qnh));
let e = with_axis(&r, InputAxis::Altitude, AxisValue::Scalar(600.0));
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 altitude_axis_is_perturbable_when_pressure_is_absolute() {
let r = resolved();
assert_eq!(r.atmosphere.pressure_reference, None);
let changed = with_axis(&r, InputAxis::Altitude, AxisValue::Scalar(1200.0)).unwrap();
assert_eq!(changed.atmosphere.altitude_m, Some(1200.0));
}
#[test]
fn altitude_axis_is_perturbable_when_pressure_is_explicitly_absolute() {
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": "absolute"},
"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;
assert_eq!(
r.atmosphere.pressure_reference,
Some(PressureReferenceV1::Absolute)
);
let changed = with_axis(&r, InputAxis::Altitude, AxisValue::Scalar(600.0)).unwrap();
assert_eq!(changed.atmosphere.altitude_m, Some(600.0));
}
#[test]
fn shot_azimuth_axis_is_unsupported_when_the_original_wind_was_compass_referenced() {
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, "shot_azimuth_rad": 0.3},
"atmosphere": {},
"wind": {"speed_mps": 3.0, "direction_from_rad": 1.0, "wind_reference": "compass"},
"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 = with_axis(&r, InputAxis::ShotAzimuth, AxisValue::Scalar(0.5));
match e {
Err(KernelError::AxisUnsupportedForRequest {
axis: InputAxis::ShotAzimuth,
reason,
}) => {
assert!(
reason.to_lowercase().contains("compass"),
"reason should name compass wind: {reason}"
);
}
other => panic!("expected AxisUnsupportedForRequest, got {other:?}"),
}
}
#[test]
fn shot_azimuth_axis_is_perturbable_when_wind_is_shooter_relative() {
let r = resolved();
assert_eq!(
match &r.wind {
ResolvedWindV1::Constant(c) => c.wind_reference,
ResolvedWindV1::Segmented(_) => panic!("constant wind expected"),
},
None
);
let changed = with_axis(&r, InputAxis::ShotAzimuth, AxisValue::Scalar(0.5)).unwrap();
assert_eq!(changed.shot.shot_azimuth_rad, Some(0.5));
}
#[test]
fn shot_azimuth_axis_is_perturbable_when_wind_is_explicitly_shooter_relative() {
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, "shot_azimuth_rad": 0.3},
"atmosphere": {},
"wind": {"speed_mps": 3.0, "direction_from_rad": 1.0, "wind_reference": "shooter"},
"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 ResolvedWindV1::Constant(wind) = &r.wind else {
panic!("constant wind expected");
};
assert_eq!(wind.wind_reference, Some(WindReferenceV1::Shooter));
let changed = with_axis(&r, InputAxis::ShotAzimuth, AxisValue::Scalar(0.5)).unwrap();
assert_eq!(changed.shot.shot_azimuth_rad, Some(0.5));
}
}