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 {
82 corrected_range_m: f64,
83 solved_span_m: f64,
84 },
85 Solver(BallisticsError),
87}
88
89impl fmt::Display for LeadError {
90 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
91 match self {
92 LeadError::InvalidInput(msg) => write!(f, "invalid lead input: {msg}"),
93 LeadError::TargetOvertakesShooter { corrected_range_m } => write!(
94 f,
95 "target reaches the shooter before intercept (corrected range {corrected_range_m:.1} m)"
96 ),
97 LeadError::Convergence { iterations, residual_m } => write!(
98 f,
99 "intercept range failed to converge after {iterations} iterations (residual {residual_m:.2} m)"
100 ),
101 LeadError::BeyondSolvedSpan { corrected_range_m, solved_span_m } => write!(
102 f,
103 "intercept range {corrected_range_m:.1} m lies beyond the solved trajectory span ({solved_span_m:.1} m)"
104 ),
105 LeadError::Solver(e) => write!(f, "trajectory solve failed: {e}"),
106 }
107 }
108}
109
110impl std::error::Error for LeadError {}
111
112impl From<BallisticsError> for LeadError {
113 fn from(e: BallisticsError) -> Self {
114 LeadError::Solver(e)
115 }
116}
117
118fn velocity_components(speed_mps: f64, angle_deg: f64) -> (f64, f64) {
121 let a = angle_deg.to_radians();
122 (speed_mps * a.cos(), speed_mps * a.sin())
123}
124
125pub fn lead_from_tof(speed_mps: f64, angle_deg: f64, tof_s: f64, range_m: f64) -> LeadComponents {
129 let (_, v_lateral) = velocity_components(speed_mps, angle_deg);
130 let lead_m = v_lateral * tof_s;
131 let ratio = if range_m < 1.0 { 0.0 } else { lead_m / range_m };
132 LeadComponents {
133 lead_m,
134 lead_mil: ratio * MIL_PER_UNIT_RATIO,
135 lead_moa: ratio * MOA_PER_UNIT_RATIO,
136 }
137}
138
139pub fn mover_ring(target_speed_mps: f64, time_s: f64, downrange_m: f64) -> (f64, Option<f64>) {
158 let ring_m = target_speed_mps * time_s;
159 let ring_mil = if downrange_m > 0.0 {
160 Some(ring_m / downrange_m * MIL_PER_UNIT_RATIO)
161 } else {
162 None
163 };
164 (ring_m, ring_mil)
165}
166
167fn tof_at(points: &[TrajectoryPoint], x_m: f64) -> Option<f64> {
171 if points.is_empty() || x_m < 0.0 {
172 return None;
173 }
174 if x_m <= points[0].position.x {
175 return Some(points[0].time);
176 }
177 for w in points.windows(2) {
178 let (p1, p2) = (&w[0], &w[1]);
179 if p2.position.x >= x_m {
180 let dx = p2.position.x - p1.position.x;
181 let t = if dx.abs() < 1e-12 {
182 0.0
183 } else {
184 (x_m - p1.position.x) / dx
185 };
186 return Some(p1.time + t * (p2.time - p1.time));
187 }
188 }
189 None
190}
191
192pub fn calculate_lead(
199 inputs: BallisticInputs,
200 wind: WindConditions,
201 atmo: AtmosphericConditions,
202 speed_mps: f64,
203 angle_deg: f64,
204 range_m: f64,
205) -> Result<LeadSolution, LeadError> {
206 if !speed_mps.is_finite() || speed_mps < 0.0 {
207 return Err(LeadError::InvalidInput(format!(
208 "target speed must be finite and non-negative, got {speed_mps}"
209 )));
210 }
211 if !angle_deg.is_finite() {
212 return Err(LeadError::InvalidInput(format!(
213 "target angle must be finite, got {angle_deg}"
214 )));
215 }
216 if !range_m.is_finite() || range_m <= 0.0 {
217 return Err(LeadError::InvalidInput(format!(
218 "range must be finite and positive, got {range_m}"
219 )));
220 }
221
222 let (v_radial, _) = velocity_components(speed_mps, angle_deg);
223
224 let max_range = range_m + (v_radial.max(0.0) * 6.0).max(20.0);
227 let mut solver = TrajectorySolver::new(inputs, wind, atmo);
228 solver.set_max_range(max_range);
229 let result = solver.solve()?;
230 let points = &result.points;
231
232 let solved_span_m = points.last().map(|p| p.position.x).unwrap_or(0.0);
235 let beyond_solved = move |r: f64| LeadError::BeyondSolvedSpan {
236 corrected_range_m: r,
237 solved_span_m,
238 };
239
240 let mut corrected = range_m;
247 let mut iterations = 0u32;
248 if v_radial.abs() > 1e-9 && speed_mps > 0.0 {
249 loop {
250 let tof = tof_at(points, corrected).ok_or_else(|| beyond_solved(corrected))?;
251 let next = range_m + v_radial * tof;
252 if next <= 0.0 {
253 return Err(LeadError::TargetOvertakesShooter {
254 corrected_range_m: next,
255 });
256 }
257 let residual = (next - corrected).abs();
258 iterations += 1;
259 corrected = next;
260 if residual < RANGE_TOLERANCE_M {
261 break;
262 }
263 if iterations >= MAX_ITERATIONS {
264 return Err(LeadError::Convergence {
265 iterations,
266 residual_m: residual,
267 });
268 }
269 }
270 }
271
272 let tof = tof_at(points, corrected).ok_or_else(|| beyond_solved(corrected))?;
273 let components = lead_from_tof(speed_mps, angle_deg, tof, corrected);
274 Ok(LeadSolution {
275 time_of_flight_s: tof,
276 lead_m: components.lead_m,
277 lead_mil: components.lead_mil,
278 lead_moa: components.lead_moa,
279 corrected_range_m: corrected,
280 iterations,
281 })
282}
283
284#[cfg(test)]
285mod tests {
286 use super::*;
287 use crate::{AtmosphericConditions, BallisticInputs, DragModel, WindConditions};
288
289 fn base_inputs() -> BallisticInputs {
290 BallisticInputs {
291 muzzle_velocity: 800.0,
292 bc_value: 0.5,
293 bc_type: DragModel::G7,
294 bullet_mass: 0.0109,
295 bullet_diameter: 0.00782,
296 bullet_length: 0.0309,
297 sight_height: 0.05,
298 use_rk4: true,
299 ..BallisticInputs::default()
300 }
301 }
302
303 #[test]
304 fn lead_from_tof_perpendicular_is_speed_times_tof() {
305 let c = lead_from_tof(3.0, 90.0, 0.5, 300.0);
306 assert!((c.lead_m - 1.5).abs() < 1e-12);
307 assert!((c.lead_mil - 1.5 / 300.0 * 1000.0).abs() < 1e-12);
308 assert!((c.lead_moa - 1.5 / 300.0 * 3438.0).abs() < 1e-12);
309 }
310
311 #[test]
312 fn moa_mil_ratio_is_exactly_3_438() {
313 let c = lead_from_tof(5.0, 90.0, 0.7, 400.0);
314 assert!((c.lead_moa / c.lead_mil - 3.438).abs() < 1e-12);
315 }
316
317 #[test]
318 fn pure_radial_motion_has_zero_lead() {
319 for angle in [0.0, 180.0] {
320 let c = lead_from_tof(10.0, angle, 0.5, 300.0);
321 assert!(c.lead_m.abs() < 1e-9, "angle {angle} must give zero lead");
322 }
323 }
324
325 #[test]
326 fn right_to_left_lead_is_negative() {
327 let c = lead_from_tof(3.0, 270.0, 0.5, 300.0);
328 assert!(
329 c.lead_m < 0.0,
330 "270 deg = target moving left => negative (hold left)"
331 );
332 }
333
334 #[test]
335 fn zero_speed_gives_zero_lead_solution() {
336 let s = calculate_lead(
337 base_inputs(),
338 WindConditions::default(),
339 AtmosphericConditions::default(),
340 0.0,
341 90.0,
342 300.0,
343 )
344 .expect("zero speed must solve");
345 assert_eq!(s.lead_m, 0.0);
346 assert_eq!(s.lead_mil, 0.0);
347 assert!((s.corrected_range_m - 300.0).abs() < 1e-9);
348 }
349
350 #[test]
351 fn outbound_45_converges_fast_and_grows_range() {
352 let s = calculate_lead(
353 base_inputs(),
354 WindConditions::default(),
355 AtmosphericConditions::default(),
356 15.0,
357 45.0,
358 600.0,
359 )
360 .expect("outbound must converge");
361 assert!(s.iterations < 10, "iterations {}", s.iterations);
362 assert!(
363 s.corrected_range_m > 600.0,
364 "outbound target => longer intercept"
365 );
366 }
368
369 #[test]
370 fn inbound_gives_shorter_corrected_range() {
371 let s = calculate_lead(
372 base_inputs(),
373 WindConditions::default(),
374 AtmosphericConditions::default(),
375 15.0,
376 180.0,
377 600.0,
378 )
379 .expect("inbound must converge");
380 assert!(s.corrected_range_m < 600.0);
381 assert!(s.lead_m.abs() < 1e-9, "pure inbound has no lateral lead");
382 }
383
384 #[test]
385 fn over_closing_target_is_a_typed_error() {
386 let r = calculate_lead(
388 base_inputs(),
389 WindConditions::default(),
390 AtmosphericConditions::default(),
391 2000.0,
392 180.0,
393 600.0,
394 );
395 match r {
396 Err(LeadError::TargetOvertakesShooter { .. }) | Err(LeadError::Convergence { .. }) => {}
397 other => panic!("expected typed overtake/convergence error, got {other:?}"),
398 }
399 }
400
401 #[test]
402 fn invalid_inputs_are_rejected() {
403 let bad = |speed: f64, angle: f64, range: f64| {
404 calculate_lead(
405 base_inputs(),
406 WindConditions::default(),
407 AtmosphericConditions::default(),
408 speed,
409 angle,
410 range,
411 )
412 };
413 assert!(matches!(
414 bad(f64::NAN, 90.0, 300.0),
415 Err(LeadError::InvalidInput(_))
416 ));
417 assert!(matches!(
418 bad(-1.0, 90.0, 300.0),
419 Err(LeadError::InvalidInput(_))
420 ));
421 assert!(matches!(
422 bad(3.0, f64::INFINITY, 300.0),
423 Err(LeadError::InvalidInput(_))
424 ));
425 assert!(matches!(
426 bad(3.0, 90.0, 0.0),
427 Err(LeadError::InvalidInput(_))
428 ));
429 }
430}