use std::error::Error;
use crate::cli_api::UnitSystem;
use crate::{
AtmosphericConditions, BCSegmentData, BallisticInputs, DragModel, TrajectorySolver,
WindConditions,
};
#[derive(Debug, Clone, Copy, PartialEq)]
#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
pub enum DragModelArg {
G1,
G7,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
pub enum DropUnit {
Mil,
Moa,
In,
}
impl DropUnit {
pub fn label(self) -> &'static str {
match self {
DropUnit::Mil => "mil",
DropUnit::Moa => "moa",
DropUnit::In => "in",
}
}
pub fn parse(s: &str) -> Result<Self, String> {
match s.to_ascii_lowercase().as_str() {
"mil" => Ok(DropUnit::Mil),
"moa" => Ok(DropUnit::Moa),
"in" => Ok(DropUnit::In),
_ => Err(format!("invalid drop unit '{s}': expected mil, moa, or in")),
}
}
pub fn express_drop_m(self, drop_m: f64, z_m: f64) -> f64 {
match self {
DropUnit::Mil => (drop_m / z_m) * 1000.0,
DropUnit::Moa => (drop_m / z_m) * (180.0 / std::f64::consts::PI) * 60.0,
DropUnit::In => drop_m / 0.0254,
}
}
}
pub fn fallback_bullet_length_m(diameter_m: f64, mass_kg: f64) -> f64 {
let est = crate::stability::estimate_bullet_length_m(diameter_m, mass_kg);
if est > 0.0 {
est
} else {
diameter_m * 4.5
}
}
#[allow(
clippy::too_many_arguments,
reason = "flat arguments preserve the existing velocity-truing compatibility helper"
)]
pub(crate) fn solve_trajectory_drop(
velocity_fps: f64,
bc: f64,
drag_model: DragModelArg,
mass_gr: f64,
diameter_in: f64,
zero_distance_yd: f64,
range_yd: f64,
sight_height_in: f64,
temperature_f: f64,
pressure_inhg: f64,
humidity: f64,
altitude_ft: f64,
bc_segments: &Option<Vec<BCSegmentData>>,
interpolate: bool,
) -> Result<(f64, f64), Box<dyn Error>> {
let velocity_ms = velocity_fps * 0.3048;
let mass_kg = mass_gr * 0.0000647989;
let diameter_m = diameter_in * 0.0254;
let zero_m = zero_distance_yd * 0.9144;
let range_m = range_yd * 0.9144;
let sight_height_m = sight_height_in * 0.0254;
let altitude_m = altitude_ft * 0.3048;
let temperature_c = (temperature_f - 32.0) * 5.0 / 9.0;
let pressure_hpa = pressure_inhg * 33.8639;
let drag_model_enum = match drag_model {
DragModelArg::G1 => DragModel::G1,
DragModelArg::G7 => DragModel::G7,
};
let mut inputs = BallisticInputs {
muzzle_velocity: velocity_ms,
bc_value: bc,
bc_type: drag_model_enum,
bullet_mass: mass_kg,
bullet_diameter: diameter_m,
bullet_length: fallback_bullet_length_m(diameter_m, mass_kg), sight_height: sight_height_m,
target_distance: range_m + 100.0, use_bc_segments: bc_segments.is_some(),
bc_segments_data: bc_segments.clone(),
use_rk4: true,
muzzle_angle: 0.0, ..Default::default() };
let atmosphere = AtmosphericConditions {
temperature: temperature_c,
pressure: pressure_hpa,
humidity, altitude: altitude_m,
};
let wind = WindConditions::default();
let zero_angle = crate::calculate_zero_angle_with_conditions(
inputs.clone(),
zero_m,
sight_height_m, wind.clone(),
atmosphere.clone(),
)?;
inputs.muzzle_angle = zero_angle;
let mut solver = TrajectorySolver::new(inputs, wind, atmosphere);
solver.set_max_range(range_m + 100.0);
solver.set_time_step(0.0001);
let result = solver.solve()?;
let idx = result
.points
.iter()
.position(|p| p.position.x >= range_m)
.ok_or("Trajectory didn't reach target range")?;
let (z, bullet_y) = if interpolate && idx > 0 {
let p0 = &result.points[idx - 1];
let p1 = &result.points[idx];
let x0 = p0.position.x;
let x1 = p1.position.x;
let denom = x1 - x0;
if denom.abs() < f64::EPSILON {
(p1.position.x, p1.position.y)
} else {
let frac = (range_m - x0) / denom;
let y = p0.position.y + frac * (p1.position.y - p0.position.y);
(range_m, y)
}
} else {
let p = &result.points[idx];
(p.position.x, p.position.y)
};
let drop_m = sight_height_m - bullet_y;
Ok((drop_m, z))
}
#[allow(
clippy::too_many_arguments,
reason = "flat arguments preserve the existing velocity-truing compatibility helper"
)]
pub(crate) fn calculate_drop_at_velocity(
velocity_fps: f64,
bc: f64,
drag_model: DragModelArg,
mass_gr: f64,
diameter_in: f64,
zero_distance_yd: f64,
range_yd: f64,
sight_height_in: f64,
temperature_f: f64,
pressure_inhg: f64,
humidity: f64,
altitude_ft: f64,
bc_segments: &Option<Vec<BCSegmentData>>,
) -> Result<f64, Box<dyn Error>> {
let (drop_m, z) = solve_trajectory_drop(
velocity_fps,
bc,
drag_model,
mass_gr,
diameter_in,
zero_distance_yd,
range_yd,
sight_height_in,
temperature_f,
pressure_inhg,
humidity,
altitude_ft,
bc_segments,
false,
)?;
let drop_mil = (drop_m / z) * 1000.0;
Ok(drop_mil)
}
#[derive(Debug, Clone)]
pub struct TrueVelocityLocalResult {
pub effective_velocity_fps: f64,
pub iterations: i32,
pub final_error_mil: f64,
pub calculated_drop_mil: f64,
pub confidence: String,
}
#[allow(
clippy::too_many_arguments,
reason = "flat arguments mirror the stable true-velocity CLI command shape"
)]
pub fn calculate_true_velocity_local(
measured_drop_mil: f64,
range_yd: f64,
bc: f64,
drag_model: DragModelArg,
mass_gr: f64,
diameter_in: f64,
zero_distance_yd: f64,
sight_height_in: f64,
temperature_f: f64,
pressure_inhg: f64,
humidity: f64,
altitude_ft: f64,
bc_segments: &Option<Vec<BCSegmentData>>,
) -> Result<TrueVelocityLocalResult, Box<dyn Error>> {
if !range_yd.is_finite() || range_yd <= 0.0 {
return Err("range must be positive and finite".into());
}
if !measured_drop_mil.is_finite() {
return Err("measured drop must be finite".into());
}
let mut velocity_low = 1500.0;
let mut velocity_high = 4500.0;
let tolerance_mil = 0.01; let max_iterations = 50;
let mut iterations = 0;
let mut last_error = 0.0;
let mut last_calculated_drop = 0.0;
for i in 0..max_iterations {
iterations = i + 1;
let test_velocity = (velocity_low + velocity_high) / 2.0;
let calculated_drop_mil = calculate_drop_at_velocity(
test_velocity,
bc,
drag_model,
mass_gr,
diameter_in,
zero_distance_yd,
range_yd,
sight_height_in,
temperature_f,
pressure_inhg,
humidity,
altitude_ft,
bc_segments,
)?;
last_calculated_drop = calculated_drop_mil;
let error = calculated_drop_mil - measured_drop_mil;
last_error = error;
if error.abs() < tolerance_mil {
let confidence = if error.abs() < 0.005 {
"high"
} else {
"medium"
};
return Ok(TrueVelocityLocalResult {
effective_velocity_fps: test_velocity,
iterations,
final_error_mil: error,
calculated_drop_mil,
confidence: confidence.to_string(),
});
}
if calculated_drop_mil > measured_drop_mil {
velocity_low = test_velocity;
} else {
velocity_high = test_velocity;
}
if (velocity_high - velocity_low).abs() < 0.5 {
break;
}
}
let final_velocity = (velocity_low + velocity_high) / 2.0;
let confidence = if last_error.abs() < 0.1 {
"medium"
} else {
"low"
};
Ok(TrueVelocityLocalResult {
effective_velocity_fps: final_velocity,
iterations,
final_error_mil: last_error,
calculated_drop_mil: last_calculated_drop,
confidence: confidence.to_string(),
})
}
pub(crate) const TRUING_MV_MIN_FPS: f64 = 1000.0;
pub(crate) const TRUING_MV_MAX_FPS: f64 = 5000.0;
pub(crate) const TRUING_BC_MIN: f64 = 0.05;
pub(crate) const TRUING_BC_MAX: f64 = 2.0;
pub(crate) const TRUING_MAX_ITERS: usize = 40;
pub(crate) const TRUING_MIN_BC_SENSITIVITY_RATIO: f64 = 0.20;
pub(crate) const TRUING_MAX_CONDITION_NUMBER: f64 = 1.0e3;
#[derive(Debug, Clone, Copy)]
pub struct TruingObservation {
pub range_yd: f64,
pub drop: f64,
}
pub fn parse_truing_observation(s: &str, units: UnitSystem) -> Result<TruingObservation, String> {
let parts: Vec<&str> = s.split(':').collect();
if parts.len() != 2 {
return Err(format!(
"invalid --observed '{s}': expected RANGE:DROP (e.g. 600:5.1)"
));
}
let range: f64 = parts[0]
.trim()
.parse()
.map_err(|_| format!("invalid --observed range '{}' in '{s}'", parts[0]))?;
let drop: f64 = parts[1]
.trim()
.parse()
.map_err(|_| format!("invalid --observed drop '{}' in '{s}'", parts[1]))?;
if !range.is_finite() || !drop.is_finite() {
return Err(format!("invalid --observed '{s}': values must be finite"));
}
let range_yd = match units {
UnitSystem::Imperial => range,
UnitSystem::Metric => range / 0.9144,
};
Ok(TruingObservation { range_yd, drop })
}
pub fn validate_truing_observations(observations: &[TruingObservation]) -> Result<(), String> {
for o in observations {
if !o.range_yd.is_finite() || o.range_yd <= 0.0 {
return Err(format!(
"observation range must be a positive finite distance (got {})",
o.range_yd
));
}
if !o.drop.is_finite() || o.drop == 0.0 {
return Err(
"observation drop must be non-zero (a zero drop carries no truing information)"
.to_string(),
);
}
}
for i in 0..observations.len() {
for j in (i + 1)..observations.len() {
if (observations[i].range_yd - observations[j].range_yd).abs() < 1e-6 {
return Err(format!(
"duplicate observation range ({:.3} yd internal): each observation must be at a distinct range",
observations[i].range_yd
));
}
}
}
Ok(())
}
pub(crate) struct TruingForwardModel<'a> {
pub drag_model: DragModelArg,
pub mass_gr: f64,
pub diameter_in: f64,
pub zero_yd: f64,
pub sight_in: f64,
pub temp_f: f64,
pub press_inhg: f64,
pub humidity: f64,
pub alt_ft: f64,
pub bc_segments: &'a Option<Vec<BCSegmentData>>,
pub drop_unit: DropUnit,
}
impl TruingForwardModel<'_> {
pub fn predict(&self, mv_fps: f64, bc: f64, range_yd: f64) -> Result<f64, Box<dyn Error>> {
self.predict_in_unit(mv_fps, bc, range_yd, self.drop_unit)
}
pub fn predict_in_unit(
&self,
mv_fps: f64,
bc: f64,
range_yd: f64,
unit: DropUnit,
) -> Result<f64, Box<dyn Error>> {
let (drop_m, z_m) = solve_trajectory_drop(
mv_fps,
bc,
self.drag_model,
self.mass_gr,
self.diameter_in,
self.zero_yd,
range_yd,
self.sight_in,
self.temp_f,
self.press_inhg,
self.humidity,
self.alt_ft,
self.bc_segments,
true, )?;
Ok(unit.express_drop_m(drop_m, z_m))
}
pub fn cost(&self, mv: f64, bc: f64, obs: &[TruingObservation]) -> Result<f64, Box<dyn Error>> {
let mut c = 0.0;
for o in obs {
let r = self.predict(mv, bc, o.range_yd)? - o.drop;
c += r * r;
}
Ok(c)
}
}
pub(crate) fn fit_truing_mv_only(
model: &TruingForwardModel<'_>,
obs: &[TruingObservation],
bc: f64,
mv_init: f64,
) -> Result<(f64, usize, bool), Box<dyn Error>> {
let mut mv = mv_init.clamp(TRUING_MV_MIN_FPS, TRUING_MV_MAX_FPS);
let mut converged = false;
let mut iters = 0;
for i in 0..TRUING_MAX_ITERS {
iters = i + 1;
let h = (mv * 1e-3).max(0.5);
let mut num = 0.0;
let mut den = 0.0;
for o in obs {
let r = model.predict(mv, bc, o.range_yd)? - o.drop;
let dp = model.predict(mv + h, bc, o.range_yd)?;
let dm = model.predict(mv - h, bc, o.range_yd)?;
let j = (dp - dm) / (2.0 * h);
num += j * r;
den += j * j;
}
if den < 1e-12 {
break;
}
let step = (-num / den).clamp(-300.0, 300.0);
let new_mv = (mv + step).clamp(TRUING_MV_MIN_FPS, TRUING_MV_MAX_FPS);
if (new_mv - mv).abs() < 0.05 {
mv = new_mv;
converged = true;
break;
}
mv = new_mv;
}
Ok((mv, iters, converged))
}
pub(crate) fn truing_identifiability(
model: &TruingForwardModel<'_>,
obs: &[TruingObservation],
mv: f64,
bc: f64,
) -> Result<(f64, f64), Box<dyn Error>> {
let hmv = (mv * 1e-3).max(0.5);
let hbc = (bc * 1e-3).max(1e-4);
let (mut n_mv, mut n_bc, mut cross) = (0.0, 0.0, 0.0);
let unit = DropUnit::Mil;
for o in obs {
let jmv = (model.predict_in_unit(mv + hmv, bc, o.range_yd, unit)?
- model.predict_in_unit(mv - hmv, bc, o.range_yd, unit)?)
/ (2.0 * hmv);
let jbc = (model.predict_in_unit(mv, bc + hbc, o.range_yd, unit)?
- model.predict_in_unit(mv, bc - hbc, o.range_yd, unit)?)
/ (2.0 * hbc);
n_mv += jmv * jmv;
n_bc += jbc * jbc;
cross += jmv * jbc;
}
let norm_mv = n_mv.sqrt();
let norm_bc = n_bc.sqrt();
let sensitivity_ratio = if mv * norm_mv > 0.0 {
(bc * norm_bc) / (mv * norm_mv)
} else {
0.0
};
let condition_number = if norm_mv > 0.0 && norm_bc > 0.0 {
let c = (cross / (norm_mv * norm_bc)).clamp(-1.0, 1.0).abs();
if (1.0 - c) > 1e-15 {
(1.0 + c) / (1.0 - c)
} else {
f64::INFINITY
}
} else {
f64::INFINITY
};
Ok((sensitivity_ratio, condition_number))
}
pub(crate) fn fit_truing_joint(
model: &TruingForwardModel<'_>,
obs: &[TruingObservation],
mv_init: f64,
bc_init: f64,
) -> Result<(f64, f64, usize, bool), Box<dyn Error>> {
let mut mv = mv_init.clamp(TRUING_MV_MIN_FPS, TRUING_MV_MAX_FPS);
let mut bc = bc_init.clamp(TRUING_BC_MIN, TRUING_BC_MAX);
let mut lambda = 1e-3;
let mut cur_cost = model.cost(mv, bc, obs)?;
let mut converged = false;
let mut iters = 0;
for it in 0..TRUING_MAX_ITERS {
iters = it + 1;
let hmv = (mv * 1e-3).max(0.5);
let hbc = (bc * 1e-3).max(1e-4);
let (mut a00, mut a01, mut a11) = (0.0, 0.0, 0.0);
let (mut g0, mut g1) = (0.0, 0.0);
for o in obs {
let r = model.predict(mv, bc, o.range_yd)? - o.drop;
let jmv = (model.predict(mv + hmv, bc, o.range_yd)?
- model.predict(mv - hmv, bc, o.range_yd)?)
/ (2.0 * hmv);
let jbc = (model.predict(mv, bc + hbc, o.range_yd)?
- model.predict(mv, bc - hbc, o.range_yd)?)
/ (2.0 * hbc);
a00 += jmv * jmv;
a01 += jmv * jbc;
a11 += jbc * jbc;
g0 += jmv * r;
g1 += jbc * r;
}
let mut accepted = false;
for _ in 0..30 {
let m00 = a00 + lambda * a00.max(1e-12);
let m11 = a11 + lambda * a11.max(1e-12);
let det = m00 * m11 - a01 * a01;
if det.abs() < 1e-20 {
lambda *= 10.0;
continue;
}
let dmv = -(m11 * g0 - a01 * g1) / det;
let dbc = -(-a01 * g0 + m00 * g1) / det;
let nmv = (mv + dmv).clamp(TRUING_MV_MIN_FPS, TRUING_MV_MAX_FPS);
let nbc = (bc + dbc).clamp(TRUING_BC_MIN, TRUING_BC_MAX);
let nc = model.cost(nmv, nbc, obs)?;
if nc < cur_cost {
let rel_change =
(nmv - mv).abs() / mv.max(1.0) + (nbc - bc).abs() / bc.max(1e-3);
mv = nmv;
bc = nbc;
cur_cost = nc;
lambda = (lambda * 0.5).max(1e-9);
accepted = true;
if rel_change < 1e-6 {
converged = true;
}
break;
}
lambda *= 4.0;
if lambda > 1e12 {
break;
}
}
if !accepted {
converged = true;
break;
}
if converged {
break;
}
}
Ok((mv, bc, iters, converged))
}
#[derive(Debug, Clone)]
pub struct MultiTruingReport {
pub fitted_mv_fps: f64,
pub fitted_bc: f64,
pub bc_input: f64,
pub bc_fitted: bool,
pub observations: Vec<TruingObservation>,
pub predicted: Vec<f64>,
pub residuals: Vec<f64>,
pub rms: f64,
pub iterations: usize,
pub converged: bool,
pub sensitivity_ratio: f64,
pub condition_number: f64,
pub quality: String,
pub reason: String,
}
#[allow(
clippy::too_many_arguments,
reason = "flat arguments mirror the stable true-velocity CLI command shape"
)]
pub fn run_multi_observation_truing_core(
observations: &[TruingObservation],
drop_unit: DropUnit,
bc_input: f64,
drag_model: DragModelArg,
mass_gr: f64,
diameter_in: f64,
zero_yd: f64,
sight_in: f64,
temp_f: f64,
press_inhg: f64,
humidity: f64,
alt_ft: f64,
bc_segments: &Option<Vec<BCSegmentData>>,
) -> Result<MultiTruingReport, Box<dyn Error>> {
validate_truing_observations(observations)?;
let observations: Vec<TruingObservation> = observations.to_vec();
let model = TruingForwardModel {
drag_model,
mass_gr,
diameter_in,
zero_yd,
sight_in,
temp_f,
press_inhg,
humidity,
alt_ft,
bc_segments,
drop_unit,
};
let mv_init = (TRUING_MV_MIN_FPS + TRUING_MV_MAX_FPS) / 2.0;
let (mv0, mv_iters, mv_conv) = fit_truing_mv_only(&model, &observations, bc_input, mv_init)?;
let rms_mv_only = rms_at(&model, &observations, mv0, bc_input)?;
let (sensitivity_ratio, condition_number) =
truing_identifiability(&model, &observations, mv0, bc_input)?;
let bc_identifiable = sensitivity_ratio >= TRUING_MIN_BC_SENSITIVITY_RATIO
&& condition_number <= TRUING_MAX_CONDITION_NUMBER
&& condition_number.is_finite();
let mut fitted_mv = mv0;
let mut fitted_bc = bc_input;
let mut bc_fitted = false;
let mut iterations = mv_iters;
let mut converged = mv_conv;
let mut reason = String::new();
if bc_identifiable {
let (mv_j, bc_j, iters_j, conv_j) =
fit_truing_joint(&model, &observations, mv0, bc_input)?;
let rms_joint = rms_at(&model, &observations, mv_j, bc_j)?;
let bc_at_bound = bc_j <= TRUING_BC_MIN * 1.001 || bc_j >= TRUING_BC_MAX * 0.999;
if !bc_at_bound && rms_joint <= rms_mv_only + 1e-9 {
fitted_mv = mv_j;
fitted_bc = bc_j;
bc_fitted = true;
iterations = iters_j;
converged = conv_j;
} else {
reason = if bc_at_bound {
format!(
"joint fit drove BC to a bound ({bc_j:.3}); BC held at input {bc_input:.3}"
)
} else {
format!(
"joint fit did not improve on the MV-only solution; BC held at input {bc_input:.3}"
)
};
}
} else {
reason = if !condition_number.is_finite() || condition_number > TRUING_MAX_CONDITION_NUMBER
{
format!(
"observation ranges are too similar to separate MV from BC (condition {condition_number:.3e} > {TRUING_MAX_CONDITION_NUMBER:.0e}); BC held at input {bc_input:.3}"
)
} else {
format!(
"observations do not constrain BC (BC sensitivity ratio {sensitivity_ratio:.4} < {TRUING_MIN_BC_SENSITIVITY_RATIO:.2} threshold); BC held at input {bc_input:.3}. Add a longer-range / transonic observation to fit BC."
)
};
}
let mut predicted = Vec::with_capacity(observations.len());
let mut residuals = Vec::with_capacity(observations.len());
let mut sse = 0.0;
let mut sse_mil = 0.0;
for o in &observations {
let p = model.predict(fitted_mv, fitted_bc, o.range_yd)?;
let r = p - o.drop;
let r_mil = match drop_unit {
DropUnit::Mil => r,
DropUnit::Moa => r / ((180.0 / std::f64::consts::PI) * 60.0 / 1000.0),
DropUnit::In => r * 0.0254 / (o.range_yd * 0.9144) * 1000.0,
};
predicted.push(p);
residuals.push(r);
sse += r * r;
sse_mil += r_mil * r_mil;
}
let rms = (sse / observations.len() as f64).sqrt();
let rms_mil = (sse_mil / observations.len() as f64).sqrt();
let quality = truing_quality_line(
bc_fitted,
rms,
rms_mil,
drop_unit,
condition_number,
converged,
observations.len(),
);
let report = MultiTruingReport {
fitted_mv_fps: fitted_mv,
fitted_bc,
bc_input,
bc_fitted,
observations,
predicted,
residuals,
rms,
iterations,
converged,
sensitivity_ratio,
condition_number,
quality,
reason,
};
Ok(report)
}
pub(crate) fn rms_at(
model: &TruingForwardModel<'_>,
obs: &[TruingObservation],
mv: f64,
bc: f64,
) -> Result<f64, Box<dyn Error>> {
let mut sse = 0.0;
for o in obs {
let r = model.predict(mv, bc, o.range_yd)? - o.drop;
sse += r * r;
}
Ok((sse / obs.len() as f64).sqrt())
}
pub(crate) fn truing_quality_line(
bc_fitted: bool,
rms: f64,
rms_mil: f64,
drop_unit: DropUnit,
condition_number: f64,
converged: bool,
n_obs: usize,
) -> String {
let unit = drop_unit.label();
let n_params = if bc_fitted { 2 } else { 1 };
if n_obs == n_params {
return format!(
"{} fit is exactly determined ({n_obs} observations, {n_params} fitted \
parameters): residuals are zero by construction and do not validate the \
fit; add an observation to assess quality",
if bc_fitted { "Joint MV+BC" } else { "MV-only" }
);
}
let quality = if rms_mil < 0.05 {
"excellent"
} else if rms_mil < 0.15 {
"good"
} else if rms_mil < 0.4 {
"fair"
} else {
"poor (observations may be inconsistent)"
};
let nonconv = if converged { "" } else { " (did not fully converge)" };
if bc_fitted {
let cond = if condition_number.is_finite() {
format!("{condition_number:.0}")
} else {
"inf".to_string()
};
format!(
"Joint MV+BC fit, {quality}: RMS residual {rms:.3} {unit}, conditioning {cond}{nonconv}"
)
} else {
format!("MV-only fit, {quality}: RMS residual {rms:.3} {unit} (BC held fixed){nonconv}")
}
}