use serde::{Deserialize, Serialize};
use crate::adjustment::{drop_to_adjustment, AdjustmentUnit};
use crate::cli_api::TrajectoryResult;
use crate::optic::{plan_corrections, AngularCorrection, DialPlanReportV1, OpticError, OpticProfile, Preferences};
use crate::truing::{DropUnit, TruingModelInputsV1};
use crate::truing_dsf::{
dsf_observation_warrants_90pct_warning, interpolate_position_and_velocity,
mach_1_crossing_range_m, solve_for_dsf, DsfSolveInputs, DSF_MACH_CEILING,
};
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TallTargetRequestV1 {
pub dialed: f64,
pub measured: f64,
pub range: f64,
pub unit: AdjustmentUnit,
pub metric: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
pub struct TallTargetResultV1 {
pub dialed: f64,
pub actual: f64,
pub correction_factor: f64,
pub within_accepted_band: bool,
}
#[derive(Debug, thiserror::Error, PartialEq)]
pub enum TallTargetErrorV1 {
#[error("clicks is not an angular unit; enter the dialed travel in mil, moa, smoa, or iphy")]
ClicksNotAngular,
#[error("dialed must be a positive angular travel")]
InvalidDialed,
#[error("measured must be a positive measured travel")]
InvalidMeasured,
#[error("range must be at least 1 yard/meter")]
InvalidRange,
}
pub fn tall_target_v1(req: &TallTargetRequestV1) -> Result<TallTargetResultV1, TallTargetErrorV1> {
if req.unit == AdjustmentUnit::Clicks {
return Err(TallTargetErrorV1::ClicksNotAngular);
}
if !req.dialed.is_finite() || req.dialed <= 0.0 {
return Err(TallTargetErrorV1::InvalidDialed);
}
if !req.measured.is_finite() || req.measured <= 0.0 {
return Err(TallTargetErrorV1::InvalidMeasured);
}
if !req.range.is_finite() || req.range < 1.0 {
return Err(TallTargetErrorV1::InvalidRange);
}
let drop_len = if req.metric {
req.measured / 100.0
} else {
req.measured / 36.0
};
let actual = drop_to_adjustment(drop_len, req.range, req.unit);
let correction_factor = actual / req.dialed;
Ok(TallTargetResultV1 {
dialed: req.dialed,
actual,
correction_factor,
within_accepted_band: crate::profile::validate_tracking_cf(
correction_factor,
"the computed factor",
)
.is_ok(),
})
}
const DSF_DEFAULT_SOLVE_ENVELOPE_YD: f64 = 1000.0;
fn dsf_solve_envelope_m(range_yd: f64) -> f64 {
let default_envelope_m = DSF_DEFAULT_SOLVE_ENVELOPE_YD * 0.9144;
let range_m = range_yd * 0.9144;
if range_m > default_envelope_m {
range_m * 1.05
} else {
default_envelope_m
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DsfDeriveRequestV1 {
pub model: TruingModelInputsV1,
pub range_yd: f64,
pub observed_drop: f64,
pub drop_unit: DropUnit,
}
#[derive(Debug, Clone, Serialize)]
pub struct DsfPointResultV1 {
pub mach: f64,
pub dsf: f64,
pub predicted_drop: f64,
pub observed_drop: f64,
pub drop_unit: DropUnit,
pub warnings: Vec<String>,
}
#[derive(Debug, thiserror::Error, PartialEq)]
pub enum DsfServiceErrorV1 {
#[error("invalid dsf request: {0}")]
InvalidInput(String),
#[error(
"observation at Mach {mach:.2} is supersonic (ceiling {ceiling:.2}); \
use true.fit to correct muzzle velocity instead"
)]
Supersonic { mach: f64, ceiling: f64 },
#[error("trajectory does not reach {range_yd:.0} yd (solved to {solved_yd:.0} yd)")]
OutOfRange { range_yd: f64, solved_yd: f64 },
#[error("predicted drop at {range_yd:.0} yd is zero or non-finite; no DSF ratio exists")]
DegenerateDrop { range_yd: f64 },
#[error("dsf forward model failed: {0}")]
ForwardModel(String),
}
impl DsfServiceErrorV1 {
pub fn failure_details(&self) -> Option<serde_json::Value> {
Some(match self {
Self::InvalidInput(_) => serde_json::json!({"reason": "invalid_input"}),
Self::Supersonic { mach, ceiling } => {
serde_json::json!({"reason": "supersonic", "mach": mach, "ceiling": ceiling})
}
Self::OutOfRange {
range_yd,
solved_yd,
} => serde_json::json!({
"reason": "out_of_range",
"range_yd": range_yd,
"solved_yd": solved_yd
}),
Self::DegenerateDrop { range_yd } => {
serde_json::json!({"reason": "degenerate_drop", "range_yd": range_yd})
}
Self::ForwardModel(_) => serde_json::json!({"reason": "forward_model"}),
})
}
}
pub fn derive_dsf_point_v1(req: &DsfDeriveRequestV1) -> Result<DsfPointResultV1, DsfServiceErrorV1> {
req.model
.validate()
.map_err(DsfServiceErrorV1::InvalidInput)?;
if !req.range_yd.is_finite() || req.range_yd <= 0.0 {
return Err(DsfServiceErrorV1::InvalidInput(
"range_yd must be a positive, finite distance".to_string(),
));
}
if !req.observed_drop.is_finite() {
return Err(DsfServiceErrorV1::InvalidInput(
"observed_drop must be finite".to_string(),
));
}
let solve_inputs: DsfSolveInputs = (&req.model).into();
let max_range_m = dsf_solve_envelope_m(req.range_yd);
let result =
solve_for_dsf(&solve_inputs, max_range_m).map_err(DsfServiceErrorV1::ForwardModel)?;
let range_m = req.range_yd * 0.9144;
derive_dsf_point_from_solve_v1(&result, range_m, req.observed_drop, req.drop_unit)
}
pub fn derive_dsf_point_from_solve_v1(
result: &TrajectoryResult,
range_m: f64,
observed_drop: f64,
drop_unit: DropUnit,
) -> Result<DsfPointResultV1, DsfServiceErrorV1> {
let range_yd = range_m / 0.9144;
let (position_y, velocity_mag) = interpolate_position_and_velocity(&result.points, range_m)
.ok_or(DsfServiceErrorV1::OutOfRange {
range_yd,
solved_yd: result.max_range / 0.9144,
})?;
let predicted_drop_m = result.line_of_sight_height_m - position_y;
let mach = if result.station_speed_of_sound_mps > 0.0 {
velocity_mag / result.station_speed_of_sound_mps
} else {
0.0
};
if mach > DSF_MACH_CEILING {
return Err(DsfServiceErrorV1::Supersonic {
mach,
ceiling: DSF_MACH_CEILING,
});
}
let mut warnings = Vec::new();
let crossing_m = mach_1_crossing_range_m(result);
if dsf_observation_warrants_90pct_warning(range_m, crossing_m, result.max_range) {
warnings.push(format!(
"observation at {range_yd:.0} yd is beyond 90% of the solved range; solution \
reliability degrades past this point"
));
}
let predicted_value = drop_unit.express_drop_m(predicted_drop_m, range_m);
if !predicted_value.is_finite() || predicted_value == 0.0 {
return Err(DsfServiceErrorV1::DegenerateDrop { range_yd });
}
let dsf = observed_drop / predicted_value;
Ok(DsfPointResultV1 {
mach,
dsf,
predicted_drop: predicted_value,
observed_drop,
drop_unit,
warnings,
})
}
const YARDS_TO_METRES: f64 = 0.9144;
fn unity_cf() -> f64 {
1.0
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DialPlanRequestV1 {
pub correction: AngularCorrection,
pub optic: OpticProfile,
pub range_yd: f64,
#[serde(default = "unity_cf")]
pub elevation_cf: f64,
#[serde(default = "unity_cf")]
pub windage_cf: f64,
#[serde(default)]
pub preferences: Preferences,
}
pub fn dial_plan_v1(req: &DialPlanRequestV1) -> Result<DialPlanReportV1, OpticError> {
plan_corrections(
req.correction,
&req.optic,
req.range_yd * YARDS_TO_METRES,
req.elevation_cf,
req.windage_cf,
&req.preferences,
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tall_target_matches_the_cli_arithmetic_and_rejects_clicks() {
let req = TallTargetRequestV1 {
dialed: 10.0,
measured: 30.0,
range: 100.0,
unit: AdjustmentUnit::Mil,
metric: false,
};
let r = tall_target_v1(&req).expect("computes");
let expected_actual =
crate::adjustment::drop_to_adjustment(30.0 / 36.0, 100.0, AdjustmentUnit::Mil);
assert!((r.actual - expected_actual).abs() < 1e-12);
assert!((r.correction_factor - expected_actual / 10.0).abs() < 1e-12);
let bad = TallTargetRequestV1 {
unit: AdjustmentUnit::Clicks,
..req
};
assert_eq!(tall_target_v1(&bad), Err(TallTargetErrorV1::ClicksNotAngular));
assert_eq!(
tall_target_v1(&TallTargetRequestV1 {
dialed: 0.0,
..req
}),
Err(TallTargetErrorV1::InvalidDialed)
);
assert_eq!(
tall_target_v1(&TallTargetRequestV1 {
measured: -1.0,
..req
}),
Err(TallTargetErrorV1::InvalidMeasured)
);
assert_eq!(
tall_target_v1(&TallTargetRequestV1 {
range: 0.5,
..req
}),
Err(TallTargetErrorV1::InvalidRange)
);
}
#[test]
fn service_reproduces_the_cli_imperial_and_metric_ratios() {
for (metric, measured, divisor) in [(false, 30.0, 36.0), (true, 76.2, 100.0)] {
let r = tall_target_v1(&TallTargetRequestV1 {
dialed: 10.0,
measured,
range: 100.0,
unit: AdjustmentUnit::Mil,
metric,
})
.expect("computes");
let expected = crate::adjustment::drop_to_adjustment(
measured / divisor,
100.0,
AdjustmentUnit::Mil,
);
assert!((r.actual - expected).abs() < 1e-12, "metric={metric}");
}
}
use crate::truing::DragModelArg;
fn test_model() -> TruingModelInputsV1 {
TruingModelInputsV1 {
muzzle_velocity_fps: 1600.0,
ballistic_coefficient: 0.243,
drag_model: DragModelArg::G7,
mass_gr: 168.0,
diameter_in: 0.308,
zero_distance_yd: 100.0,
sight_height_in: 2.0,
temperature_f: 59.0,
pressure_inhg: 29.92,
humidity_pct: 50.0,
altitude_ft: 0.0,
}
}
#[test]
fn dsf_derives_a_point_and_refuses_a_supersonic_observation() {
let model = test_model();
let ok = derive_dsf_point_v1(&DsfDeriveRequestV1 {
model,
range_yd: 200.0,
observed_drop: 12.0,
drop_unit: DropUnit::Mil,
})
.expect("derives");
assert!(ok.mach <= crate::truing_dsf::DSF_MACH_CEILING);
assert!(ok.dsf > 0.0);
let err = derive_dsf_point_v1(&DsfDeriveRequestV1 {
model,
range_yd: 50.0,
observed_drop: 0.5,
drop_unit: DropUnit::Mil,
});
assert!(err.is_err(), "a supersonic observation must be refused");
assert!(matches!(err, Err(DsfServiceErrorV1::Supersonic { .. })));
}
#[test]
fn dsf_rejects_invalid_range_and_observed_drop() {
let model = test_model();
assert!(matches!(
derive_dsf_point_v1(&DsfDeriveRequestV1 {
model,
range_yd: 0.0,
observed_drop: 1.0,
drop_unit: DropUnit::Mil,
}),
Err(DsfServiceErrorV1::InvalidInput(_))
));
assert!(matches!(
derive_dsf_point_v1(&DsfDeriveRequestV1 {
model,
range_yd: 200.0,
observed_drop: f64::NAN,
drop_unit: DropUnit::Mil,
}),
Err(DsfServiceErrorV1::InvalidInput(_))
));
let mut bad_model = model;
bad_model.mass_gr = -1.0;
assert!(matches!(
derive_dsf_point_v1(&DsfDeriveRequestV1 {
model: bad_model,
range_yd: 200.0,
observed_drop: 1.0,
drop_unit: DropUnit::Mil,
}),
Err(DsfServiceErrorV1::InvalidInput(_))
));
}
#[test]
fn dsf_derive_from_solve_matches_derive_dsf_point_v1() {
let model = test_model();
let range_yd = 200.0;
let observed_drop = 12.0;
let drop_unit = DropUnit::Mil;
let via_v1 = derive_dsf_point_v1(&DsfDeriveRequestV1 {
model,
range_yd,
observed_drop,
drop_unit,
})
.expect("derives");
let solve_inputs: DsfSolveInputs = (&model).into();
let max_range_m = dsf_solve_envelope_m(range_yd);
let result = solve_for_dsf(&solve_inputs, max_range_m).expect("solves");
let via_solve =
derive_dsf_point_from_solve_v1(&result, range_yd * 0.9144, observed_drop, drop_unit)
.expect("derives");
assert_eq!(via_v1.mach, via_solve.mach);
assert_eq!(via_v1.dsf, via_solve.dsf);
assert_eq!(via_v1.predicted_drop, via_solve.predicted_drop);
assert_eq!(via_v1.warnings, via_solve.warnings);
}
#[test]
fn dsf_out_of_range_and_degenerate_drop_errors() {
let model = test_model();
let err = derive_dsf_point_v1(&DsfDeriveRequestV1 {
model,
range_yd: 20_000.0,
observed_drop: 12.0,
drop_unit: DropUnit::Mil,
});
assert!(matches!(err, Err(DsfServiceErrorV1::OutOfRange { .. })));
let ok = derive_dsf_point_v1(&DsfDeriveRequestV1 {
model,
range_yd: 200.0,
observed_drop: 0.0,
drop_unit: DropUnit::Mil,
})
.expect("zero observed drop still derives");
assert_eq!(ok.dsf, 0.0);
}
}