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
136pub fn mover_ring(target_speed_mps: f64, time_s: f64, downrange_m: f64) -> (f64, Option<f64>) {
155 let ring_m = target_speed_mps * time_s;
156 let ring_mil = if downrange_m > 0.0 {
157 Some(ring_m / downrange_m * MIL_PER_UNIT_RATIO)
158 } else {
159 None
160 };
161 (ring_m, ring_mil)
162}
163
164fn tof_at(points: &[TrajectoryPoint], x_m: f64) -> Option<f64> {
168 if points.is_empty() || x_m < 0.0 {
169 return None;
170 }
171 if x_m <= points[0].position.x {
172 return Some(points[0].time);
173 }
174 for w in points.windows(2) {
175 let (p1, p2) = (&w[0], &w[1]);
176 if p2.position.x >= x_m {
177 let dx = p2.position.x - p1.position.x;
178 let t = if dx.abs() < 1e-12 { 0.0 } else { (x_m - p1.position.x) / dx };
179 return Some(p1.time + t * (p2.time - p1.time));
180 }
181 }
182 None
183}
184
185pub fn calculate_lead(
192 inputs: BallisticInputs,
193 wind: WindConditions,
194 atmo: AtmosphericConditions,
195 speed_mps: f64,
196 angle_deg: f64,
197 range_m: f64,
198) -> Result<LeadSolution, LeadError> {
199 if !speed_mps.is_finite() || speed_mps < 0.0 {
200 return Err(LeadError::InvalidInput(format!(
201 "target speed must be finite and non-negative, got {speed_mps}"
202 )));
203 }
204 if !angle_deg.is_finite() {
205 return Err(LeadError::InvalidInput(format!(
206 "target angle must be finite, got {angle_deg}"
207 )));
208 }
209 if !range_m.is_finite() || range_m <= 0.0 {
210 return Err(LeadError::InvalidInput(format!(
211 "range must be finite and positive, got {range_m}"
212 )));
213 }
214
215 let (v_radial, _) = velocity_components(speed_mps, angle_deg);
216
217 let max_range = range_m + (v_radial.max(0.0) * 6.0).max(20.0);
220 let mut solver = TrajectorySolver::new(inputs, wind, atmo);
221 solver.set_max_range(max_range);
222 let result = solver.solve()?;
223 let points = &result.points;
224
225 let solved_span_m = points.last().map(|p| p.position.x).unwrap_or(0.0);
228 let beyond_solved = move |r: f64| LeadError::BeyondSolvedSpan {
229 corrected_range_m: r,
230 solved_span_m,
231 };
232
233 let mut corrected = range_m;
240 let mut iterations = 0u32;
241 if v_radial.abs() > 1e-9 && speed_mps > 0.0 {
242 loop {
243 let tof = tof_at(points, corrected).ok_or_else(|| beyond_solved(corrected))?;
244 let next = range_m + v_radial * tof;
245 if next <= 0.0 {
246 return Err(LeadError::TargetOvertakesShooter { corrected_range_m: next });
247 }
248 let residual = (next - corrected).abs();
249 iterations += 1;
250 corrected = next;
251 if residual < RANGE_TOLERANCE_M {
252 break;
253 }
254 if iterations >= MAX_ITERATIONS {
255 return Err(LeadError::Convergence { iterations, residual_m: residual });
256 }
257 }
258 }
259
260 let tof = tof_at(points, corrected).ok_or_else(|| beyond_solved(corrected))?;
261 let components = lead_from_tof(speed_mps, angle_deg, tof, corrected);
262 Ok(LeadSolution {
263 time_of_flight_s: tof,
264 lead_m: components.lead_m,
265 lead_mil: components.lead_mil,
266 lead_moa: components.lead_moa,
267 corrected_range_m: corrected,
268 iterations,
269 })
270}
271
272#[cfg(test)]
273mod tests {
274 use super::*;
275 use crate::{AtmosphericConditions, BallisticInputs, DragModel, WindConditions};
276
277 fn base_inputs() -> BallisticInputs {
278 BallisticInputs {
279 muzzle_velocity: 800.0,
280 bc_value: 0.5,
281 bc_type: DragModel::G7,
282 bullet_mass: 0.0109,
283 bullet_diameter: 0.00782,
284 bullet_length: 0.0309,
285 sight_height: 0.05,
286 use_rk4: true,
287 ..BallisticInputs::default()
288 }
289 }
290
291 #[test]
292 fn lead_from_tof_perpendicular_is_speed_times_tof() {
293 let c = lead_from_tof(3.0, 90.0, 0.5, 300.0);
294 assert!((c.lead_m - 1.5).abs() < 1e-12);
295 assert!((c.lead_mil - 1.5 / 300.0 * 1000.0).abs() < 1e-12);
296 assert!((c.lead_moa - 1.5 / 300.0 * 3438.0).abs() < 1e-12);
297 }
298
299 #[test]
300 fn moa_mil_ratio_is_exactly_3_438() {
301 let c = lead_from_tof(5.0, 90.0, 0.7, 400.0);
302 assert!((c.lead_moa / c.lead_mil - 3.438).abs() < 1e-12);
303 }
304
305 #[test]
306 fn pure_radial_motion_has_zero_lead() {
307 for angle in [0.0, 180.0] {
308 let c = lead_from_tof(10.0, angle, 0.5, 300.0);
309 assert!(c.lead_m.abs() < 1e-9, "angle {angle} must give zero lead");
310 }
311 }
312
313 #[test]
314 fn right_to_left_lead_is_negative() {
315 let c = lead_from_tof(3.0, 270.0, 0.5, 300.0);
316 assert!(c.lead_m < 0.0, "270 deg = target moving left => negative (hold left)");
317 }
318
319 #[test]
320 fn zero_speed_gives_zero_lead_solution() {
321 let s = calculate_lead(
322 base_inputs(),
323 WindConditions::default(),
324 AtmosphericConditions::default(),
325 0.0,
326 90.0,
327 300.0,
328 )
329 .expect("zero speed must solve");
330 assert_eq!(s.lead_m, 0.0);
331 assert_eq!(s.lead_mil, 0.0);
332 assert!((s.corrected_range_m - 300.0).abs() < 1e-9);
333 }
334
335 #[test]
336 fn outbound_45_converges_fast_and_grows_range() {
337 let s = calculate_lead(
338 base_inputs(),
339 WindConditions::default(),
340 AtmosphericConditions::default(),
341 15.0,
342 45.0,
343 600.0,
344 )
345 .expect("outbound must converge");
346 assert!(s.iterations < 10, "iterations {}", s.iterations);
347 assert!(s.corrected_range_m > 600.0, "outbound target => longer intercept");
348 }
350
351 #[test]
352 fn inbound_gives_shorter_corrected_range() {
353 let s = calculate_lead(
354 base_inputs(),
355 WindConditions::default(),
356 AtmosphericConditions::default(),
357 15.0,
358 180.0,
359 600.0,
360 )
361 .expect("inbound must converge");
362 assert!(s.corrected_range_m < 600.0);
363 assert!(s.lead_m.abs() < 1e-9, "pure inbound has no lateral lead");
364 }
365
366 #[test]
367 fn over_closing_target_is_a_typed_error() {
368 let r = calculate_lead(
370 base_inputs(),
371 WindConditions::default(),
372 AtmosphericConditions::default(),
373 2000.0,
374 180.0,
375 600.0,
376 );
377 match r {
378 Err(LeadError::TargetOvertakesShooter { .. }) | Err(LeadError::Convergence { .. }) => {}
379 other => panic!("expected typed overtake/convergence error, got {other:?}"),
380 }
381 }
382
383 #[test]
384 fn invalid_inputs_are_rejected() {
385 let bad = |speed: f64, angle: f64, range: f64| {
386 calculate_lead(
387 base_inputs(),
388 WindConditions::default(),
389 AtmosphericConditions::default(),
390 speed,
391 angle,
392 range,
393 )
394 };
395 assert!(matches!(bad(f64::NAN, 90.0, 300.0), Err(LeadError::InvalidInput(_))));
396 assert!(matches!(bad(-1.0, 90.0, 300.0), Err(LeadError::InvalidInput(_))));
397 assert!(matches!(bad(3.0, f64::INFINITY, 300.0), Err(LeadError::InvalidInput(_))));
398 assert!(matches!(bad(3.0, 90.0, 0.0), Err(LeadError::InvalidInput(_))));
399 }
400}