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(
202 inputs: BallisticInputs,
203 wind: WindConditions,
204 atmo: AtmosphericConditions,
205 speed_mps: f64,
206 angle_deg: f64,
207 range_m: f64,
208) -> Result<LeadSolution, LeadError> {
209 calculate_lead_with_station_presence(
210 inputs,
211 wind,
212 atmo,
213 speed_mps,
214 angle_deg,
215 range_m,
216 crate::atmosphere::StationAtmospherePresence::NEITHER,
217 )
218}
219
220pub fn calculate_lead_with_station_presence(
225 inputs: BallisticInputs,
226 wind: WindConditions,
227 atmo: AtmosphericConditions,
228 speed_mps: f64,
229 angle_deg: f64,
230 range_m: f64,
231 station_presence: crate::atmosphere::StationAtmospherePresence,
232) -> Result<LeadSolution, LeadError> {
233 if !speed_mps.is_finite() || speed_mps < 0.0 {
234 return Err(LeadError::InvalidInput(format!(
235 "target speed must be finite and non-negative, got {speed_mps}"
236 )));
237 }
238 if !angle_deg.is_finite() {
239 return Err(LeadError::InvalidInput(format!(
240 "target angle must be finite, got {angle_deg}"
241 )));
242 }
243 if !range_m.is_finite() || range_m <= 0.0 {
244 return Err(LeadError::InvalidInput(format!(
245 "range must be finite and positive, got {range_m}"
246 )));
247 }
248
249 let (v_radial, _) = velocity_components(speed_mps, angle_deg);
250
251 let max_range = range_m + (v_radial.max(0.0) * 6.0).max(20.0);
254 let mut solver =
255 TrajectorySolver::new_with_station_presence(inputs, wind, atmo, station_presence);
256 solver.set_max_range(max_range);
257 let result = solver.solve()?;
258 let points = &result.points;
259
260 let solved_span_m = points.last().map(|p| p.position.x).unwrap_or(0.0);
263 let beyond_solved = move |r: f64| LeadError::BeyondSolvedSpan {
264 corrected_range_m: r,
265 solved_span_m,
266 };
267
268 let mut corrected = range_m;
275 let mut iterations = 0u32;
276 if v_radial.abs() > 1e-9 && speed_mps > 0.0 {
277 loop {
278 let tof = tof_at(points, corrected).ok_or_else(|| beyond_solved(corrected))?;
279 let next = range_m + v_radial * tof;
280 if next <= 0.0 {
281 return Err(LeadError::TargetOvertakesShooter {
282 corrected_range_m: next,
283 });
284 }
285 let residual = (next - corrected).abs();
286 iterations += 1;
287 corrected = next;
288 if residual < RANGE_TOLERANCE_M {
289 break;
290 }
291 if iterations >= MAX_ITERATIONS {
292 return Err(LeadError::Convergence {
293 iterations,
294 residual_m: residual,
295 });
296 }
297 }
298 }
299
300 let tof = tof_at(points, corrected).ok_or_else(|| beyond_solved(corrected))?;
301 let components = lead_from_tof(speed_mps, angle_deg, tof, corrected);
302 Ok(LeadSolution {
303 time_of_flight_s: tof,
304 lead_m: components.lead_m,
305 lead_mil: components.lead_mil,
306 lead_moa: components.lead_moa,
307 corrected_range_m: corrected,
308 iterations,
309 })
310}
311
312#[cfg(test)]
313mod tests {
314 use super::*;
315 use crate::{AtmosphericConditions, BallisticInputs, DragModel, WindConditions};
316
317 fn base_inputs() -> BallisticInputs {
318 BallisticInputs {
319 muzzle_velocity: 800.0,
320 bc_value: 0.5,
321 bc_type: DragModel::G7,
322 bullet_mass: 0.0109,
323 bullet_diameter: 0.00782,
324 bullet_length: 0.0309,
325 sight_height: 0.05,
326 use_rk4: true,
327 ..BallisticInputs::default()
328 }
329 }
330
331 #[test]
332 fn lead_from_tof_perpendicular_is_speed_times_tof() {
333 let c = lead_from_tof(3.0, 90.0, 0.5, 300.0);
334 assert!((c.lead_m - 1.5).abs() < 1e-12);
335 assert!((c.lead_mil - 1.5 / 300.0 * 1000.0).abs() < 1e-12);
336 assert!((c.lead_moa - 1.5 / 300.0 * 3438.0).abs() < 1e-12);
337 }
338
339 #[test]
340 fn moa_mil_ratio_is_exactly_3_438() {
341 let c = lead_from_tof(5.0, 90.0, 0.7, 400.0);
342 assert!((c.lead_moa / c.lead_mil - 3.438).abs() < 1e-12);
343 }
344
345 #[test]
346 fn pure_radial_motion_has_zero_lead() {
347 for angle in [0.0, 180.0] {
348 let c = lead_from_tof(10.0, angle, 0.5, 300.0);
349 assert!(c.lead_m.abs() < 1e-9, "angle {angle} must give zero lead");
350 }
351 }
352
353 #[test]
354 fn right_to_left_lead_is_negative() {
355 let c = lead_from_tof(3.0, 270.0, 0.5, 300.0);
356 assert!(
357 c.lead_m < 0.0,
358 "270 deg = target moving left => negative (hold left)"
359 );
360 }
361
362 #[test]
363 fn zero_speed_gives_zero_lead_solution() {
364 let s = calculate_lead(
365 base_inputs(),
366 WindConditions::default(),
367 AtmosphericConditions::default(),
368 0.0,
369 90.0,
370 300.0,
371 )
372 .expect("zero speed must solve");
373 assert_eq!(s.lead_m, 0.0);
374 assert_eq!(s.lead_mil, 0.0);
375 assert!((s.corrected_range_m - 300.0).abs() < 1e-9);
376 }
377
378 #[test]
379 fn outbound_45_converges_fast_and_grows_range() {
380 let s = calculate_lead(
381 base_inputs(),
382 WindConditions::default(),
383 AtmosphericConditions::default(),
384 15.0,
385 45.0,
386 600.0,
387 )
388 .expect("outbound must converge");
389 assert!(s.iterations < 10, "iterations {}", s.iterations);
390 assert!(
391 s.corrected_range_m > 600.0,
392 "outbound target => longer intercept"
393 );
394 }
396
397 #[test]
398 fn inbound_gives_shorter_corrected_range() {
399 let s = calculate_lead(
400 base_inputs(),
401 WindConditions::default(),
402 AtmosphericConditions::default(),
403 15.0,
404 180.0,
405 600.0,
406 )
407 .expect("inbound must converge");
408 assert!(s.corrected_range_m < 600.0);
409 assert!(s.lead_m.abs() < 1e-9, "pure inbound has no lateral lead");
410 }
411
412 #[test]
413 fn over_closing_target_is_a_typed_error() {
414 let r = calculate_lead(
416 base_inputs(),
417 WindConditions::default(),
418 AtmosphericConditions::default(),
419 2000.0,
420 180.0,
421 600.0,
422 );
423 match r {
424 Err(LeadError::TargetOvertakesShooter { .. }) | Err(LeadError::Convergence { .. }) => {}
425 other => panic!("expected typed overtake/convergence error, got {other:?}"),
426 }
427 }
428
429 #[test]
430 fn invalid_inputs_are_rejected() {
431 let bad = |speed: f64, angle: f64, range: f64| {
432 calculate_lead(
433 base_inputs(),
434 WindConditions::default(),
435 AtmosphericConditions::default(),
436 speed,
437 angle,
438 range,
439 )
440 };
441 assert!(matches!(
442 bad(f64::NAN, 90.0, 300.0),
443 Err(LeadError::InvalidInput(_))
444 ));
445 assert!(matches!(
446 bad(-1.0, 90.0, 300.0),
447 Err(LeadError::InvalidInput(_))
448 ));
449 assert!(matches!(
450 bad(3.0, f64::INFINITY, 300.0),
451 Err(LeadError::InvalidInput(_))
452 ));
453 assert!(matches!(
454 bad(3.0, 90.0, 0.0),
455 Err(LeadError::InvalidInput(_))
456 ));
457 }
458}