pub mod taxonomy;
pub use taxonomy::{axes_in_group, axis_meta, AxisKind, AxisMeta, InputAxis, InputGroup};
pub mod access;
pub use access::{read_axis, with_axis, AxisValue, KernelError};
use crate::solve_json::{SolveErrorEnvelopeV1, SolveRequestV1};
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Observation {
pub range_m: f64,
pub drop_m: f64,
pub windage_m: f64,
pub time_s: f64,
pub velocity_mps: f64,
}
pub fn evaluate(req: &SolveRequestV1, ranges_m: &[f64]) -> Result<Vec<Observation>, KernelError> {
let prepared = crate::solve_v1::prepare_request(req).map_err(kernel_solve_error)?;
let max_range_m = prepared.resolved_request.shot.max_range_m;
let time_step_s = prepared.resolved_request.solver.time_step_s;
let zero_distance_m = prepared.resolved_request.shot.zero_distance_m;
let target_height_m = prepared.resolved_request.shot.target_height_m;
let (solver, _effective_angle) = crate::solve_v1::build_zeroed_solver(
prepared.inputs,
prepared.wind,
prepared.atmosphere,
prepared.wind_segments,
max_range_m,
time_step_s,
zero_distance_m,
target_height_m,
req.shot.muzzle_angle_rad,
)
.map_err(kernel_solve_error)?;
let result = solver
.solve()
.map_err(crate::solve_v1::solve_failed)
.map_err(kernel_solve_error)?;
let mut out = Vec::with_capacity(ranges_m.len());
for &range_m in ranges_m {
let o = result
.observation_at_range_checked(range_m)
.map_err(KernelError::Observation)?;
out.push(Observation {
range_m,
drop_m: o.drop_m,
windage_m: o.windage_m,
time_s: o.time_s,
velocity_mps: o.speed_mps,
});
}
Ok(out)
}
pub(crate) fn kernel_solve_error(e: SolveErrorEnvelopeV1) -> KernelError {
KernelError::Solve { code: e.error.code, message: e.error.message }
}
pub mod derive;
pub use derive::{
bisect_axis, central_difference, DifferenceScheme, Derivative, BISECTION_MAX_ITERATIONS,
};
#[cfg(test)]
mod eval_tests {
use super::*;
fn base_request_json(max_range_m: f64, zero_distance_m: f64, interval_m: f64) -> String {
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": max_range_m, "zero_distance_m": zero_distance_m},
"atmosphere": {},
"wind": {"speed_mps": 3.0, "direction_from_rad": std::f64::consts::FRAC_PI_2},
"solver": {}, "effects": {}, "sampling": {"interval_m": interval_m}
})
.to_string()
}
#[test]
fn evaluate_returns_one_observation_per_requested_range() {
let req =
crate::solve_json::decode_solve_request_v1(&base_request_json(900.0, 100.0, 25.0))
.unwrap();
let obs = evaluate(&req, &[300.0, 600.0, 800.0]).expect("evaluate");
assert_eq!(obs.len(), 3);
assert!((obs[0].range_m - 300.0).abs() < 1e-6);
assert!((obs[1].range_m - 600.0).abs() < 1e-6);
assert!((obs[2].range_m - 800.0).abs() < 1e-6);
assert!(obs[2].drop_m > obs[0].drop_m);
assert!(obs[2].velocity_mps < obs[0].velocity_mps);
assert!(obs
.iter()
.all(|o| o.time_s.is_finite() && o.windage_m.is_finite()));
}
#[test]
fn a_range_beyond_the_trajectory_is_an_error_not_a_clamp() {
let req =
crate::solve_json::decode_solve_request_v1(&base_request_json(300.0, 100.0, 25.0))
.unwrap();
match evaluate(&req, &[5000.0]) {
Err(KernelError::Observation(
crate::trajectory_observation::TrajectoryObservationError::OutOfRange {
requested_m,
..
},
)) => {
assert_eq!(requested_m, 5000.0);
}
other => panic!(
"expected Err(KernelError::Observation(OutOfRange {{ .. }})) naming an \
out-of-range query, got {other:?}"
),
}
}
#[test]
fn evaluate_matches_solve_v1_when_zero_distance_alone_searches_the_elevation() {
let json = base_request_json(900.0, 100.0, 300.0);
let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
assert_eq!(
req.shot.muzzle_angle_rad, None,
"fixture assumption: no explicit angle, exercising the (Some, None) arm"
);
let via_solve_v1 = crate::solve_v1::solve_v1(req.clone()).expect("solve_v1");
let sample_at_300 = via_solve_v1
.samples
.iter()
.find(|s| s.distance_m == 300.0)
.expect("300 m must be an exact grid point for this fixture");
assert!(sample_at_300.drop_m.abs() > 0.1);
assert!(sample_at_300.windage_m.abs() > 0.01);
assert!(
(sample_at_300.drop_m - sample_at_300.windage_m).abs() > 0.05,
"drop_m ({}) and windage_m ({}) must differ meaningfully, or a swap between them \
would be invisible to the two independent-threshold checks above",
sample_at_300.drop_m,
sample_at_300.windage_m
);
let obs = evaluate(&req, &[300.0]).expect("evaluate");
assert_eq!(obs.len(), 1);
assert_eq!(
obs[0].drop_m, sample_at_300.drop_m,
"drop_m diverged from solve_v1's own reported sample"
);
assert_eq!(
obs[0].windage_m, sample_at_300.windage_m,
"windage_m diverged from solve_v1's own reported sample (possible field swap)"
);
assert_eq!(obs[0].time_s, sample_at_300.time_s);
assert_eq!(obs[0].velocity_mps, sample_at_300.speed_mps);
}
#[test]
fn evaluate_matches_solve_v1_when_an_explicit_angle_and_zero_distance_are_both_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,
"sight_offset_lateral_m": 0.03},
"shot": {"max_range_m": 900.0, "zero_distance_m": 100.0, "muzzle_angle_rad": 0.01,
"zero_poi_right_m": 0.02},
"atmosphere": {},
"wind": {"speed_mps": 3.0, "direction_from_rad": std::f64::consts::FRAC_PI_2},
"solver": {}, "effects": {}, "sampling": {"interval_m": 300.0}
})
.to_string();
let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
assert!(
req.shot.zero_distance_m.is_some() && req.shot.muzzle_angle_rad.is_some(),
"fixture assumption: both fields present, exercising the (Some, Some) arm"
);
let via_solve_v1 = crate::solve_v1::solve_v1(req.clone()).expect("solve_v1");
let sample_at_300 = via_solve_v1
.samples
.iter()
.find(|s| s.distance_m == 300.0)
.expect("300 m must be an exact grid point for this fixture");
assert!(
sample_at_300.windage_m.abs() > 0.005,
"fixture must produce a non-negligible windage-zero bias, got {}",
sample_at_300.windage_m
);
let obs = evaluate(&req, &[300.0]).expect("evaluate");
assert_eq!(obs.len(), 1);
assert_eq!(obs[0].drop_m, sample_at_300.drop_m);
assert_eq!(
obs[0].windage_m, sample_at_300.windage_m,
"windage-zero bias diverged from solve_v1 -- apply_windage_zero_bias may not have \
run for this arm"
);
assert_eq!(obs[0].time_s, sample_at_300.time_s);
assert_eq!(obs[0].velocity_mps, sample_at_300.speed_mps);
}
#[test]
fn evaluate_matches_solve_v1_when_no_zero_distance_is_present_at_all() {
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": {"speed_mps": 3.0, "direction_from_rad": std::f64::consts::FRAC_PI_2},
"solver": {}, "effects": {}, "sampling": {"interval_m": 300.0}
})
.to_string();
let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
assert_eq!(
req.shot.zero_distance_m, None,
"fixture assumption: no zero distance at all, exercising the (None, _) arm"
);
let via_solve_v1 = crate::solve_v1::solve_v1(req.clone()).expect("solve_v1");
let sample_at_300 = via_solve_v1
.samples
.iter()
.find(|s| s.distance_m == 300.0)
.expect("300 m must be an exact grid point for this fixture");
let obs = evaluate(&req, &[300.0]).expect("evaluate");
assert_eq!(obs.len(), 1);
assert_eq!(obs[0].drop_m, sample_at_300.drop_m);
assert_eq!(obs[0].windage_m, sample_at_300.windage_m);
assert_eq!(obs[0].time_s, sample_at_300.time_s);
assert_eq!(obs[0].velocity_mps, sample_at_300.speed_mps);
}
#[test]
fn evaluate_preserves_the_caller_supplied_range_order_and_repeats() {
let req =
crate::solve_json::decode_solve_request_v1(&base_request_json(900.0, 100.0, 25.0))
.unwrap();
let requested = [800.0, 300.0, 800.0, 500.0];
let obs = evaluate(&req, &requested).expect("evaluate");
assert_eq!(obs.len(), requested.len());
for (o, &want) in obs.iter().zip(requested.iter()) {
assert_eq!(o.range_m, want);
}
assert!(
obs[0].velocity_mps < obs[1].velocity_mps,
"800 m must be slower than 300 m"
);
assert_eq!(
obs[0].velocity_mps, obs[2].velocity_mps,
"two queries at the same 800 m range must agree exactly"
);
assert!(
obs[1].velocity_mps > obs[3].velocity_mps,
"300 m must be faster than 500 m"
);
}
#[test]
fn evaluate_actually_zeroes_drop_is_near_zero_at_the_zero_distance() {
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.0},
"shot": {"max_range_m": 900.0, "zero_distance_m": 137.0},
"atmosphere": {}, "wind": {},
"solver": {}, "effects": {}, "sampling": {"interval_m": 25.0}
})
.to_string();
let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
let obs = evaluate(&req, &[137.0]).expect("evaluate");
assert_eq!(obs.len(), 1);
assert!(
obs[0].drop_m.abs() < 1e-3,
"a rifle zeroed at 137 m must show ~0 drop there, got {}",
obs[0].drop_m
);
}
#[test]
fn an_invalid_request_is_a_solve_error_not_a_panic_or_silent_success() {
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": 300.0},
"atmosphere": {"relative_humidity": 1.5},
"wind": {}, "solver": {}, "effects": {}, "sampling": {"interval_m": 25.0}
})
.to_string();
let req = crate::solve_json::decode_solve_request_v1(&json).unwrap();
match evaluate(&req, &[100.0]) {
Err(KernelError::Solve { code, .. }) => {
assert_eq!(code, crate::solve_json::SolveErrorCodeV1::InvalidValue);
}
other => panic!("expected Err(KernelError::Solve {{ .. }}), got {other:?}"),
}
}
#[test]
fn evaluate_ignores_drops_reference_and_always_reports_los_drop() {
let shooting_angle_rad: f64 = 30.0_f64.to_radians();
let build = |drops_reference: Option<&str>| -> String {
let mut shot = serde_json::json!({
"max_range_m": 900.0,
"muzzle_angle_rad": 0.02,
"shooting_angle_rad": shooting_angle_rad,
});
if let Some(dr) = drops_reference {
shot["drops_reference"] = serde_json::json!(dr);
}
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": shot,
"atmosphere": {}, "wind": {},
"solver": {}, "effects": {}, "sampling": {"interval_m": 300.0}
})
.to_string()
};
let req_los = crate::solve_json::decode_solve_request_v1(&build(None)).unwrap();
let req_target =
crate::solve_json::decode_solve_request_v1(&build(Some("target"))).unwrap();
let obs_los = evaluate(&req_los, &[300.0]).expect("evaluate los");
let obs_target = evaluate(&req_target, &[300.0]).expect("evaluate target");
assert_eq!(
obs_los[0].drop_m, obs_target[0].drop_m,
"evaluate must report the same LOS-perpendicular drop_m regardless of \
shot.drops_reference"
);
let via_solve_v1_target =
crate::solve_v1::solve_v1(req_target.clone()).expect("solve_v1 target");
let resolved_angle = via_solve_v1_target.resolved_request.shot.shooting_angle_rad;
let sample = via_solve_v1_target
.samples
.iter()
.find(|s| s.distance_m == 300.0)
.expect("300 m must be an exact grid point for this fixture");
let expected_target_drop = obs_los[0].drop_m / resolved_angle.cos();
assert!(
(sample.drop_m - expected_target_drop).abs() < 1e-9,
"solve_v1's target-plane drop_m ({}) should equal evaluate's LOS drop_m ({}) \
divided by cos(shooting_angle_rad) ({})",
sample.drop_m,
obs_los[0].drop_m,
resolved_angle.cos()
);
assert!(
(sample.drop_m - obs_los[0].drop_m).abs() > 0.05,
"LOS and target-plane drop must differ meaningfully at a 30 degree shooting angle"
);
}
}