1use crate::cli_api::{
22 AtmosphericConditions, BallisticInputs, BallisticsError, TrajectoryPoint, TrajectorySolver,
23 WindConditions,
24};
25use std::fmt;
26
27pub const MIL_PER_UNIT_RATIO: f64 = 1000.0;
29pub const MOA_PER_UNIT_RATIO: f64 = 3438.0;
32
33const RANGE_TOLERANCE_M: f64 = 0.1;
35const MAX_ITERATIONS: u32 = 10;
37
38#[derive(Debug, Clone, Copy, PartialEq)]
40pub struct LeadComponents {
41 pub lead_m: f64,
43 pub lead_mil: f64,
45 pub lead_moa: f64,
47}
48
49#[derive(Debug, Clone, Copy, PartialEq)]
51pub struct LeadSolution {
52 pub time_of_flight_s: f64,
54 pub lead_m: f64,
56 pub lead_mil: f64,
58 pub lead_moa: f64,
60 pub corrected_range_m: f64,
62 pub iterations: u32,
64}
65
66#[derive(Debug)]
68pub enum LeadError {
69 InvalidInput(String),
71 TargetOvertakesShooter { corrected_range_m: f64 },
74 Convergence { iterations: u32, residual_m: f64 },
78 BeyondSolvedSpan { corrected_range_m: f64, solved_span_m: f64 },
82 Solver(BallisticsError),
84}
85
86impl fmt::Display for LeadError {
87 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
88 match self {
89 LeadError::InvalidInput(msg) => write!(f, "invalid lead input: {msg}"),
90 LeadError::TargetOvertakesShooter { corrected_range_m } => write!(
91 f,
92 "target reaches the shooter before intercept (corrected range {corrected_range_m:.1} m)"
93 ),
94 LeadError::Convergence { iterations, residual_m } => write!(
95 f,
96 "intercept range failed to converge after {iterations} iterations (residual {residual_m:.2} m)"
97 ),
98 LeadError::BeyondSolvedSpan { corrected_range_m, solved_span_m } => write!(
99 f,
100 "intercept range {corrected_range_m:.1} m lies beyond the solved trajectory span ({solved_span_m:.1} m)"
101 ),
102 LeadError::Solver(e) => write!(f, "trajectory solve failed: {e}"),
103 }
104 }
105}
106
107impl std::error::Error for LeadError {}
108
109impl From<BallisticsError> for LeadError {
110 fn from(e: BallisticsError) -> Self {
111 LeadError::Solver(e)
112 }
113}
114
115fn velocity_components(speed_mps: f64, angle_deg: f64) -> (f64, f64) {
118 let a = angle_deg.to_radians();
119 (speed_mps * a.cos(), speed_mps * a.sin())
120}
121
122pub fn lead_from_tof(speed_mps: f64, angle_deg: f64, tof_s: f64, range_m: f64) -> LeadComponents {
126 let (_, v_lateral) = velocity_components(speed_mps, angle_deg);
127 let lead_m = v_lateral * tof_s;
128 let ratio = if range_m < 1.0 { 0.0 } else { lead_m / range_m };
129 LeadComponents {
130 lead_m,
131 lead_mil: ratio * MIL_PER_UNIT_RATIO,
132 lead_moa: ratio * MOA_PER_UNIT_RATIO,
133 }
134}
135
136fn tof_at(points: &[TrajectoryPoint], x_m: f64) -> Option<f64> {
140 if points.is_empty() || x_m < 0.0 {
141 return None;
142 }
143 if x_m <= points[0].position.x {
144 return Some(points[0].time);
145 }
146 for w in points.windows(2) {
147 let (p1, p2) = (&w[0], &w[1]);
148 if p2.position.x >= x_m {
149 let dx = p2.position.x - p1.position.x;
150 let t = if dx.abs() < 1e-12 { 0.0 } else { (x_m - p1.position.x) / dx };
151 return Some(p1.time + t * (p2.time - p1.time));
152 }
153 }
154 None
155}
156
157pub fn calculate_lead(
164 inputs: BallisticInputs,
165 wind: WindConditions,
166 atmo: AtmosphericConditions,
167 speed_mps: f64,
168 angle_deg: f64,
169 range_m: f64,
170) -> Result<LeadSolution, LeadError> {
171 if !speed_mps.is_finite() || speed_mps < 0.0 {
172 return Err(LeadError::InvalidInput(format!(
173 "target speed must be finite and non-negative, got {speed_mps}"
174 )));
175 }
176 if !angle_deg.is_finite() {
177 return Err(LeadError::InvalidInput(format!(
178 "target angle must be finite, got {angle_deg}"
179 )));
180 }
181 if !range_m.is_finite() || range_m <= 0.0 {
182 return Err(LeadError::InvalidInput(format!(
183 "range must be finite and positive, got {range_m}"
184 )));
185 }
186
187 let (v_radial, _) = velocity_components(speed_mps, angle_deg);
188
189 let max_range = range_m + (v_radial.max(0.0) * 6.0).max(20.0);
192 let mut solver = TrajectorySolver::new(inputs, wind, atmo);
193 solver.set_max_range(max_range);
194 let result = solver.solve()?;
195 let points = &result.points;
196
197 let solved_span_m = points.last().map(|p| p.position.x).unwrap_or(0.0);
200 let beyond_solved = move |r: f64| LeadError::BeyondSolvedSpan {
201 corrected_range_m: r,
202 solved_span_m,
203 };
204
205 let mut corrected = range_m;
212 let mut iterations = 0u32;
213 if v_radial.abs() > 1e-9 && speed_mps > 0.0 {
214 loop {
215 let tof = tof_at(points, corrected).ok_or_else(|| beyond_solved(corrected))?;
216 let next = range_m + v_radial * tof;
217 if next <= 0.0 {
218 return Err(LeadError::TargetOvertakesShooter { corrected_range_m: next });
219 }
220 let residual = (next - corrected).abs();
221 iterations += 1;
222 corrected = next;
223 if residual < RANGE_TOLERANCE_M {
224 break;
225 }
226 if iterations >= MAX_ITERATIONS {
227 return Err(LeadError::Convergence { iterations, residual_m: residual });
228 }
229 }
230 }
231
232 let tof = tof_at(points, corrected).ok_or_else(|| beyond_solved(corrected))?;
233 let components = lead_from_tof(speed_mps, angle_deg, tof, corrected);
234 Ok(LeadSolution {
235 time_of_flight_s: tof,
236 lead_m: components.lead_m,
237 lead_mil: components.lead_mil,
238 lead_moa: components.lead_moa,
239 corrected_range_m: corrected,
240 iterations,
241 })
242}
243
244#[cfg(test)]
245mod tests {
246 use super::*;
247 use crate::{AtmosphericConditions, BallisticInputs, DragModel, WindConditions};
248
249 fn base_inputs() -> BallisticInputs {
250 let mut i = BallisticInputs::default();
251 i.muzzle_velocity = 800.0;
252 i.bc_value = 0.5;
253 i.bc_type = DragModel::G7;
254 i.bullet_mass = 0.0109;
255 i.bullet_diameter = 0.00782;
256 i.bullet_length = 0.0309;
257 i.sight_height = 0.05;
258 i.use_rk4 = true;
259 i
260 }
261
262 #[test]
263 fn lead_from_tof_perpendicular_is_speed_times_tof() {
264 let c = lead_from_tof(3.0, 90.0, 0.5, 300.0);
265 assert!((c.lead_m - 1.5).abs() < 1e-12);
266 assert!((c.lead_mil - 1.5 / 300.0 * 1000.0).abs() < 1e-12);
267 assert!((c.lead_moa - 1.5 / 300.0 * 3438.0).abs() < 1e-12);
268 }
269
270 #[test]
271 fn moa_mil_ratio_is_exactly_3_438() {
272 let c = lead_from_tof(5.0, 90.0, 0.7, 400.0);
273 assert!((c.lead_moa / c.lead_mil - 3.438).abs() < 1e-12);
274 }
275
276 #[test]
277 fn pure_radial_motion_has_zero_lead() {
278 for angle in [0.0, 180.0] {
279 let c = lead_from_tof(10.0, angle, 0.5, 300.0);
280 assert!(c.lead_m.abs() < 1e-9, "angle {angle} must give zero lead");
281 }
282 }
283
284 #[test]
285 fn right_to_left_lead_is_negative() {
286 let c = lead_from_tof(3.0, 270.0, 0.5, 300.0);
287 assert!(c.lead_m < 0.0, "270 deg = target moving left => negative (hold left)");
288 }
289
290 #[test]
291 fn zero_speed_gives_zero_lead_solution() {
292 let s = calculate_lead(
293 base_inputs(),
294 WindConditions::default(),
295 AtmosphericConditions::default(),
296 0.0,
297 90.0,
298 300.0,
299 )
300 .expect("zero speed must solve");
301 assert_eq!(s.lead_m, 0.0);
302 assert_eq!(s.lead_mil, 0.0);
303 assert!((s.corrected_range_m - 300.0).abs() < 1e-9);
304 }
305
306 #[test]
307 fn outbound_45_converges_fast_and_grows_range() {
308 let s = calculate_lead(
309 base_inputs(),
310 WindConditions::default(),
311 AtmosphericConditions::default(),
312 15.0,
313 45.0,
314 600.0,
315 )
316 .expect("outbound must converge");
317 assert!(s.iterations < 10, "iterations {}", s.iterations);
318 assert!(s.corrected_range_m > 600.0, "outbound target => longer intercept");
319 }
321
322 #[test]
323 fn inbound_gives_shorter_corrected_range() {
324 let s = calculate_lead(
325 base_inputs(),
326 WindConditions::default(),
327 AtmosphericConditions::default(),
328 15.0,
329 180.0,
330 600.0,
331 )
332 .expect("inbound must converge");
333 assert!(s.corrected_range_m < 600.0);
334 assert!(s.lead_m.abs() < 1e-9, "pure inbound has no lateral lead");
335 }
336
337 #[test]
338 fn over_closing_target_is_a_typed_error() {
339 let r = calculate_lead(
341 base_inputs(),
342 WindConditions::default(),
343 AtmosphericConditions::default(),
344 2000.0,
345 180.0,
346 600.0,
347 );
348 match r {
349 Err(LeadError::TargetOvertakesShooter { .. }) | Err(LeadError::Convergence { .. }) => {}
350 other => panic!("expected typed overtake/convergence error, got {other:?}"),
351 }
352 }
353
354 #[test]
355 fn invalid_inputs_are_rejected() {
356 let bad = |speed: f64, angle: f64, range: f64| {
357 calculate_lead(
358 base_inputs(),
359 WindConditions::default(),
360 AtmosphericConditions::default(),
361 speed,
362 angle,
363 range,
364 )
365 };
366 assert!(matches!(bad(f64::NAN, 90.0, 300.0), Err(LeadError::InvalidInput(_))));
367 assert!(matches!(bad(-1.0, 90.0, 300.0), Err(LeadError::InvalidInput(_))));
368 assert!(matches!(bad(3.0, f64::INFINITY, 300.0), Err(LeadError::InvalidInput(_))));
369 assert!(matches!(bad(3.0, 90.0, 0.0), Err(LeadError::InvalidInput(_))));
370 }
371}