use crate::constants::GRAINS_TO_KG;
use crate::trajectory_observation::{bracket_param, Bracket};
use crate::truing::fallback_bullet_length_m;
use crate::truing_dsf::DsfTable;
use crate::{
trajectory_sampling, AtmosphericConditions, BCSegmentData, BallisticInputs, DragModel,
TrajectorySolver, WindConditions,
};
use std::error::Error;
#[allow(
clippy::too_many_arguments,
reason = "flat arguments preserve the shared CLI trajectory compatibility helper"
)]
pub fn build_trajectory_components(
velocity: f64,
bc: f64,
mass: f64,
diameter: f64,
drag_model: DragModel,
sight_height: f64,
temperature: f64,
pressure: f64,
humidity: f64,
altitude: f64,
wind_speed: f64,
wind_direction: f64,
max_range: f64,
sample_interval: f64,
bc_segments_data: Option<Vec<BCSegmentData>>,
custom_drag_table: Option<crate::drag::DragTable>,
zero_poi_vertical_m: f64,
zero_poi_horizontal_m: f64,
sight_offset_lateral_m: f64,
) -> (BallisticInputs, WindConditions, AtmosphericConditions) {
let drag_model_enum = drag_model;
let wind_direction_rad = wind_direction.to_radians();
let use_bc_segments = bc_segments_data.is_some();
let inputs = BallisticInputs {
bc_value: bc,
bc_type: drag_model_enum,
bullet_mass: mass,
muzzle_velocity: velocity,
bullet_diameter: diameter,
bullet_length: fallback_bullet_length_m(diameter, mass),
muzzle_angle: 0.0,
target_distance: max_range,
sight_height,
sight_offset_lateral_m,
zero_poi_vertical_m,
zero_poi_horizontal_m,
altitude,
temperature,
pressure,
humidity,
wind_speed,
wind_angle: wind_direction_rad,
use_rk4: true, use_adaptive_rk45: true, enable_trajectory_sampling: true,
sample_interval,
caliber_inches: diameter / 0.0254,
weight_grains: mass / GRAINS_TO_KG,
twist_rate: 12.0,
is_twist_right: true,
use_bc_segments,
bc_segments_data,
custom_drag_table,
..Default::default()
};
let wind = WindConditions {
speed: wind_speed,
direction: wind_direction_rad,
..Default::default()
};
let atmosphere = AtmosphericConditions {
temperature,
pressure,
humidity,
altitude,
};
(inputs, wind, atmosphere)
}
pub struct SampledFlight {
pub samples: Vec<trajectory_sampling::TrajectorySample>,
pub reach_m: f64,
}
#[allow(
clippy::too_many_arguments,
reason = "flat arguments preserve the shared sampled-trajectory compatibility helper"
)]
pub fn run_sampled_trajectory(
velocity: f64,
bc: f64,
mass: f64,
diameter: f64,
drag_model: DragModel,
sight_height: f64,
temperature: f64,
pressure: f64,
humidity: f64,
altitude: f64,
wind_speed: f64,
wind_direction: f64,
max_range: f64,
sample_interval: f64,
zero_angle_rad: f64,
bc_segments_data: Option<Vec<BCSegmentData>>,
custom_drag_table: Option<crate::drag::DragTable>,
dsf_table: Option<&DsfTable>,
zero_poi_vertical_m: f64,
zero_poi_horizontal_m: f64,
sight_offset_lateral_m: f64,
zero_solve_distance_m: Option<f64>,
) -> Result<Vec<trajectory_sampling::TrajectorySample>, Box<dyn Error>> {
Ok(run_sampled_flight(
velocity,
bc,
mass,
diameter,
drag_model,
sight_height,
temperature,
pressure,
humidity,
altitude,
wind_speed,
wind_direction,
max_range,
sample_interval,
zero_angle_rad,
bc_segments_data,
custom_drag_table,
dsf_table,
zero_poi_vertical_m,
zero_poi_horizontal_m,
sight_offset_lateral_m,
zero_solve_distance_m,
)?
.samples)
}
#[allow(
clippy::too_many_arguments,
reason = "flat arguments preserve the shared sampled-trajectory compatibility helper"
)]
pub fn run_sampled_flight(
velocity: f64,
bc: f64,
mass: f64,
diameter: f64,
drag_model: DragModel,
sight_height: f64,
temperature: f64,
pressure: f64,
humidity: f64,
altitude: f64,
wind_speed: f64,
wind_direction: f64,
max_range: f64,
sample_interval: f64,
zero_angle_rad: f64,
bc_segments_data: Option<Vec<BCSegmentData>>,
custom_drag_table: Option<crate::drag::DragTable>,
dsf_table: Option<&DsfTable>,
zero_poi_vertical_m: f64,
zero_poi_horizontal_m: f64,
sight_offset_lateral_m: f64,
zero_solve_distance_m: Option<f64>,
) -> Result<SampledFlight, Box<dyn Error>> {
let (mut inputs, wind, atmosphere) = build_trajectory_components(
velocity,
bc,
mass,
diameter,
drag_model,
sight_height,
temperature,
pressure,
humidity,
altitude,
wind_speed,
wind_direction,
max_range,
sample_interval,
bc_segments_data,
custom_drag_table,
zero_poi_vertical_m,
zero_poi_horizontal_m,
sight_offset_lateral_m,
);
inputs.muzzle_angle = zero_angle_rad;
if let Some(zero_distance_m) = zero_solve_distance_m {
inputs.azimuth_angle += inputs.windage_zero_bias_rad(zero_distance_m);
}
let mut solver = TrajectorySolver::new(inputs, wind, atmosphere);
solver.set_max_range(max_range);
solver.set_time_step(0.001);
let result = solver.solve()?;
let mut samples = result.sampled_points.unwrap_or_default();
if let Some(table) = dsf_table {
let station_sos = result.station_speed_of_sound_mps;
for s in samples.iter_mut() {
let mach = if station_sos > 0.0 {
s.velocity_mps / station_sos
} else {
0.0
};
s.drop_m *= table.factor_at(mach);
}
}
Ok(SampledFlight {
samples,
reach_m: result.max_range,
})
}
pub const CARD_SAMPLE_INTERVAL_M: f64 = HoldCurve::SAMPLE_INTERVAL_M;
pub fn sample_at_range(
samples: &[trajectory_sampling::TrajectorySample],
range_m: f64,
) -> Option<trajectory_sampling::TrajectorySample> {
if !range_m.is_finite() {
return None;
}
let Bracket::Inside { lo, t } = bracket_param(samples.len(), |i| samples[i].distance_m, range_m)
else {
return None;
};
let hi = lo + 1;
let lerp = |a: f64, b: f64| a + (b - a) * t;
Some(trajectory_sampling::TrajectorySample {
distance_m: range_m,
drop_m: lerp(samples[lo].drop_m, samples[hi].drop_m),
wind_drift_m: lerp(samples[lo].wind_drift_m, samples[hi].wind_drift_m),
velocity_mps: lerp(samples[lo].velocity_mps, samples[hi].velocity_mps),
energy_j: lerp(samples[lo].energy_j, samples[hi].energy_j),
time_s: lerp(samples[lo].time_s, samples[hi].time_s),
flags: Vec::new(),
})
}
pub fn card_sample_max_range_m(end_m: f64) -> f64 {
(end_m * 1.1).max(end_m + CARD_SAMPLE_INTERVAL_M)
}
pub fn range_not_sampled_message(requested_display: f64, reach_display: f64, unit: &str) -> String {
format!(
"no trajectory sample at {requested_display:.0} {unit}: this load's solved flight \
reaches only {reach_display:.0} {unit}"
)
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct CardTruncation {
pub requested_end: f64,
pub last_row: f64,
pub reach: f64,
}
impl CardTruncation {
pub fn message(&self, unit: &str) -> String {
let Self {
requested_end,
last_row,
reach,
} = *self;
format!(
"card truncated at {last_row:.0} {unit}: {requested_end:.0} {unit} was requested, \
but this load's solved flight reaches only {reach:.0} {unit}; the rows past that \
are left out rather than filled in from a range the bullet does reach"
)
}
pub fn printed_note(&self, unit: &str) -> String {
let Self {
requested_end,
last_row,
reach,
} = *self;
format!(
"TRUNCATED at {last_row:.0} {unit}: {requested_end:.0} {unit} requested, this load \
reaches only {reach:.0} {unit}"
)
}
}
#[derive(Debug, Clone)]
pub struct HoldCurveLoad {
pub velocity_mps: f64,
pub bc: f64,
pub mass_kg: f64,
pub diameter_m: f64,
pub drag_model: DragModel,
pub sight_height_m: f64,
pub zero_distance_m: f64,
pub temperature_c: f64,
pub pressure_hpa: f64,
pub humidity: f64,
pub altitude_m: f64,
pub wind_speed_mps: f64,
pub wind_direction_deg: f64,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct HoldPoint {
pub range_m: f64,
pub drop_mil: f64,
pub wind_mil: f64,
pub velocity_mps: f64,
pub energy_j: f64,
pub time_s: f64,
}
pub struct HoldCurve {
samples: Vec<trajectory_sampling::TrajectorySample>,
reach_m: f64,
}
#[derive(Debug, Clone, PartialEq)]
pub enum MarkToRangeOutcome {
Reached(Box<HoldPoint>),
InsideZero { far_zero_range_m: f64 },
BeyondSearch {
max_range_m: f64,
max_drop_mil: f64,
},
}
impl HoldCurve {
pub const SAMPLE_INTERVAL_M: f64 = 0.9144;
pub fn solve(load: &HoldCurveLoad, max_range_m: f64) -> Result<Self, Box<dyn Error>> {
if !max_range_m.is_finite() || max_range_m <= 0.0 {
return Err("hold curve max range must be finite and greater than zero".into());
}
let zero_inputs = BallisticInputs {
bc_value: load.bc,
bc_type: load.drag_model,
bullet_mass: load.mass_kg,
muzzle_velocity: load.velocity_mps,
bullet_diameter: load.diameter_m,
bullet_length: fallback_bullet_length_m(load.diameter_m, load.mass_kg),
sight_height: load.sight_height_m,
use_rk4: true,
..Default::default()
};
let atmosphere = AtmosphericConditions {
temperature: load.temperature_c,
pressure: load.pressure_hpa,
humidity: load.humidity,
altitude: load.altitude_m,
};
let zero_angle = crate::calculate_zero_angle_with_conditions(
zero_inputs,
load.zero_distance_m,
load.sight_height_m,
WindConditions::default(),
atmosphere,
)?;
let flight = run_sampled_flight(
load.velocity_mps,
load.bc,
load.mass_kg,
load.diameter_m,
load.drag_model,
load.sight_height_m,
load.temperature_c,
load.pressure_hpa,
load.humidity,
load.altitude_m,
load.wind_speed_mps,
load.wind_direction_deg,
max_range_m,
Self::SAMPLE_INTERVAL_M,
zero_angle,
None,
None,
None,
0.0,
0.0,
0.0,
Some(load.zero_distance_m),
)?;
let SampledFlight { samples, reach_m } = flight;
if samples.len() < 2 {
return Err(
"the trajectory produced too few sampled points to read a hold from".into(),
);
}
Ok(Self { samples, reach_m })
}
pub fn max_sampled_range_m(&self) -> f64 {
self.samples.last().map_or(0.0, |s| s.distance_m)
}
pub fn reach_m(&self) -> f64 {
self.reach_m
}
pub fn sample_ranges_m(&self) -> Vec<f64> {
self.samples.iter().map(|s| s.distance_m).collect()
}
pub fn at_range(&self, range_m: f64) -> Option<HoldPoint> {
if !range_m.is_finite() || range_m <= 0.0 {
return None;
}
let samples = &self.samples;
let Bracket::Inside { lo, t } =
bracket_param(samples.len(), |i| samples[i].distance_m, range_m)
else {
return None;
};
let hi = lo + 1;
let lerp = |a: f64, b: f64| a + (b - a) * t;
let drop_m = lerp(samples[lo].drop_m, samples[hi].drop_m);
let drift_m = lerp(samples[lo].wind_drift_m, samples[hi].wind_drift_m);
Some(HoldPoint {
range_m,
drop_mil: drop_m / range_m * 1000.0,
wind_mil: drift_m / range_m * 1000.0,
velocity_mps: lerp(samples[lo].velocity_mps, samples[hi].velocity_mps),
energy_j: lerp(samples[lo].energy_j, samples[hi].energy_j),
time_s: lerp(samples[lo].time_s, samples[hi].time_s),
})
}
pub fn far_zero_range_m(&self) -> f64 {
let mut far = self.samples.first().map_or(0.0, |s| s.distance_m);
for sample in &self.samples {
if sample.distance_m > 0.0 && sample.drop_m <= 0.0 {
far = sample.distance_m;
}
}
far
}
const INVERSE_MAX_ITERATIONS: u32 = 80;
const INVERSE_TOLERANCE_M: f64 = 1.0e-5;
pub fn range_for_angular_drop_mil(&self, target_mil: f64) -> MarkToRangeOutcome {
let far_zero_range_m = self.far_zero_range_m();
let max_range_m = self.max_sampled_range_m();
let drop_at = |range_m: f64| self.at_range(range_m).map(|p| p.drop_mil);
let max_drop_mil = drop_at(max_range_m).unwrap_or(f64::NEG_INFINITY);
if !target_mil.is_finite() || target_mil <= 0.0 {
return MarkToRangeOutcome::InsideZero { far_zero_range_m };
}
if target_mil > max_drop_mil {
return MarkToRangeOutcome::BeyondSearch {
max_range_m,
max_drop_mil,
};
}
let mut lo = far_zero_range_m.max(Self::SAMPLE_INTERVAL_M);
if drop_at(lo).is_none_or(|drop| drop >= target_mil) {
return MarkToRangeOutcome::InsideZero { far_zero_range_m };
}
let mut hi = max_range_m;
for _ in 0..Self::INVERSE_MAX_ITERATIONS {
if hi - lo <= Self::INVERSE_TOLERANCE_M {
break;
}
let mid = 0.5 * (lo + hi);
match drop_at(mid) {
Some(drop) if drop < target_mil => lo = mid,
Some(_) => hi = mid,
None => break,
}
}
match self.at_range(0.5 * (lo + hi)) {
Some(point) => MarkToRangeOutcome::Reached(Box::new(point)),
None => MarkToRangeOutcome::BeyondSearch {
max_range_m,
max_drop_mil,
},
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn oracle_test_load() -> HoldCurveLoad {
HoldCurveLoad {
velocity_mps: 800.0,
bc: 0.223,
mass_kg: 0.0109,
diameter_m: 0.00782,
drag_model: DragModel::G7,
sight_height_m: 0.045,
zero_distance_m: 100.0,
temperature_c: 15.0,
pressure_hpa: 1013.25,
humidity: 50.0,
altitude_m: 0.0,
wind_speed_mps: 3.0,
wind_direction_deg: 90.0,
}
}
fn independent_direct_solve(
load: &HoldCurveLoad,
max_range_m: f64,
) -> Vec<trajectory_sampling::TrajectorySample> {
let zero_inputs = BallisticInputs {
bc_value: load.bc,
bc_type: load.drag_model,
bullet_mass: load.mass_kg,
muzzle_velocity: load.velocity_mps,
bullet_diameter: load.diameter_m,
bullet_length: fallback_bullet_length_m(load.diameter_m, load.mass_kg),
sight_height: load.sight_height_m,
use_rk4: true,
..Default::default()
};
let atmosphere = AtmosphericConditions {
temperature: load.temperature_c,
pressure: load.pressure_hpa,
humidity: load.humidity,
altitude: load.altitude_m,
};
let zero_angle = crate::calculate_zero_angle_with_conditions(
zero_inputs,
load.zero_distance_m,
load.sight_height_m,
WindConditions::default(),
atmosphere,
)
.expect("zero solve should succeed for a realistic load");
run_sampled_trajectory(
load.velocity_mps,
load.bc,
load.mass_kg,
load.diameter_m,
load.drag_model,
load.sight_height_m,
load.temperature_c,
load.pressure_hpa,
load.humidity,
load.altitude_m,
load.wind_speed_mps,
load.wind_direction_deg,
max_range_m,
HoldCurve::SAMPLE_INTERVAL_M,
zero_angle,
None,
None,
None,
0.0,
0.0,
0.0,
Some(load.zero_distance_m),
)
.expect("sampled trajectory should succeed for a realistic load")
}
#[test]
fn at_range_matches_an_independent_direct_solves_sampled_observation() {
let load = oracle_test_load();
let max_range_m = 1500.0;
let hold_curve = HoldCurve::solve(&load, max_range_m).expect("hold curve should solve");
let oracle_samples = independent_direct_solve(&load, max_range_m);
let probe = &oracle_samples[oracle_samples.len() / 2];
let probe_range_m = probe.distance_m;
assert!(probe_range_m > 0.0 && probe_range_m.is_finite());
let point = hold_curve
.at_range(probe_range_m)
.expect("probe range must be inside the sampled span");
let expected_drop_mil = probe.drop_m / probe_range_m * 1000.0;
let expected_wind_mil = probe.wind_drift_m / probe_range_m * 1000.0;
assert!((point.range_m - probe_range_m).abs() < 1e-9);
assert!((point.drop_mil - expected_drop_mil).abs() < 1e-9);
assert!((point.wind_mil - expected_wind_mil).abs() < 1e-9);
assert!((point.velocity_mps - probe.velocity_mps).abs() < 1e-9);
assert!((point.energy_j - probe.energy_j).abs() < 1e-9);
assert!((point.time_s - probe.time_s).abs() < 1e-9);
}
#[test]
fn at_range_outside_the_sampled_span_returns_none() {
let load = oracle_test_load();
let max_range_m = 1500.0;
let hold_curve = HoldCurve::solve(&load, max_range_m).expect("hold curve should solve");
let beyond = hold_curve.max_sampled_range_m() + 10_000.0;
assert_eq!(hold_curve.at_range(beyond), None);
}
#[test]
fn sample_ranges_m_is_the_exact_multiples_of_the_sample_interval() {
let load = oracle_test_load();
let hold_curve = HoldCurve::solve(&load, 1500.0).expect("hold curve should solve");
let ranges = hold_curve.sample_ranges_m();
assert!(!ranges.is_empty());
assert_eq!(ranges[0], 0.0);
assert_eq!(*ranges.last().unwrap(), hold_curve.max_sampled_range_m());
for (i, &r) in ranges.iter().enumerate() {
let expected = i as f64 * HoldCurve::SAMPLE_INTERVAL_M;
assert!(
(r - expected).abs() < 1e-9,
"index {i}: got {r}, expected {expected} ({} * SAMPLE_INTERVAL_M)",
i
);
}
}
}