Skip to main content

ballistics_engine/
moving_target.rs

1//! Moving-target lead calculation (MBA-1287).
2//!
3//! Computes the hold ("lead") required to hit a target moving at constant speed along a
4//! straight ground track, using the engine's own wind-aware time of flight.
5//!
6//! # Conventions (locked — see CLI_USAGE.md "Moving-Target Lead")
7//! - `angle_deg` is the direction of target TRAVEL relative to the line of sight:
8//!   `0` = directly away (outbound), `90` = full crossing left→right,
9//!   `180` = directly toward (inbound), `270` = crossing right→left.
10//! - Positive lead = hold in the target's direction of travel (right for `90°`).
11//! - `lead_mil = lead/range·1000`; `lead_moa = lead/range·3438.0` — the CLI dial
12//!   convention shared with the dope-card/come-up tables (MBA-724), so MOA/MIL is
13//!   exactly 3.438, not the exact-angle 3437.7467.
14//! - The lead is PURE target motion, additive to the wind-corrected hold: time of
15//!   flight comes from the wind-aware solve, but wind drift itself stays in the
16//!   wind column of your dope, not in the lead.
17//! - Non-perpendicular motion changes the intercept distance; `calculate_lead`
18//!   iterates `R = R₀ + v_radial·TOF(R)` until the correction is below 0.1 m and
19//!   reports TOF/angular lead at the corrected (intercept) range.
20
21use crate::cli_api::{
22    AtmosphericConditions, BallisticInputs, BallisticsError, TrajectoryPoint, TrajectorySolver,
23    WindConditions,
24};
25use std::fmt;
26
27/// Milliradian hold per unit offset/range ratio.
28pub const MIL_PER_UNIT_RATIO: f64 = 1000.0;
29/// MOA hold per unit offset/range ratio — the engine's CLI dial convention (MBA-724),
30/// deliberately 3438.0 (not 3437.7467) to match every printed table.
31pub const MOA_PER_UNIT_RATIO: f64 = 3438.0;
32
33/// Convergence tolerance for the intercept-range iteration, meters.
34const RANGE_TOLERANCE_M: f64 = 0.1;
35/// Iteration cap; the ticket's acceptance demands convergence in <10 for 45° outbound.
36const MAX_ITERATIONS: u32 = 10;
37
38/// Pure angular/linear lead from an already-known time of flight (no solve).
39#[derive(Debug, Clone, Copy, PartialEq)]
40pub struct LeadComponents {
41    /// Signed linear lead at the target, meters (positive = direction of travel).
42    pub lead_m: f64,
43    /// Signed angular lead, milliradians.
44    pub lead_mil: f64,
45    /// Signed angular lead, MOA (CLI dial convention: ratio × 3438.0).
46    pub lead_moa: f64,
47}
48
49/// Full lead solution from `calculate_lead`.
50#[derive(Debug, Clone, Copy, PartialEq)]
51pub struct LeadSolution {
52    /// Time of flight to the corrected (intercept) range, seconds — wind-aware.
53    pub time_of_flight_s: f64,
54    /// Signed linear lead at the target, meters (positive = direction of travel).
55    pub lead_m: f64,
56    /// Signed angular lead, milliradians.
57    pub lead_mil: f64,
58    /// Signed angular lead, MOA (ratio × 3438.0 — see module docs).
59    pub lead_moa: f64,
60    /// Intercept distance after radial-motion correction, meters.
61    pub corrected_range_m: f64,
62    /// Fixed-point iterations used (0 when the target has no radial component).
63    pub iterations: u32,
64}
65
66/// Typed failure modes for `calculate_lead`.
67#[derive(Debug)]
68pub enum LeadError {
69    /// Non-finite or out-of-domain input (negative speed, range ≤ 0, non-finite angle).
70    InvalidInput(String),
71    /// An inbound target closes to (or past) the shooter before intercept: the
72    /// corrected range collapsed to ≤ 0 m.
73    TargetOvertakesShooter { corrected_range_m: f64 },
74    /// The intercept-range iteration failed to reach `RANGE_TOLERANCE_M` within
75    /// `MAX_ITERATIONS` (physically requires a radial speed comparable to the
76    /// projectile's remaining velocity).
77    Convergence { iterations: u32, residual_m: f64 },
78    /// The intercept range moved beyond the distance the trajectory solve covered
79    /// (an outbound target outrunning the solve's headroom, or a solve that
80    /// terminated early — e.g. ground impact — before reaching it).
81    BeyondSolvedSpan { corrected_range_m: f64, solved_span_m: f64 },
82    /// The underlying trajectory solve failed (invalid ballistic inputs, etc.).
83    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
115/// Signed lateral/radial split of the target velocity, m/s.
116/// Radial positive = receding; lateral positive = moving right.
117fn 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
122/// Pure lead math from a known time of flight — shared by `calculate_lead` and the
123/// PDF dope-card path (which already has per-row TOF from its sampled trajectory).
124/// Uses the small-angle linear ratio `lead/range`, matching every other hold table.
125pub 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
136/// Linear-interpolated time of flight at downrange distance `x_m`, from a solved
137/// trajectory's points. Returns `None` when `x_m` lies beyond the solved span.
138/// (MBA-1218 guarantees the final point sits exactly at the solve's max range.)
139fn 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
157/// Compute the lead solution for a target moving at `speed_mps` along `angle_deg`
158/// (see module docs for the datum) at initial range `range_m`.
159///
160/// Runs ONE wind-aware solve past the range (with outbound headroom), interpolates
161/// time of flight from its points, and fixed-point iterates the intercept range for
162/// radial motion. Errors are typed (`LeadError`).
163pub 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    // One solve with headroom for outbound intercepts: 6 s of target motion covers
190    // any realistic small-arms TOF at leadable ranges.
191    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    // The solve can end short of max_range (e.g. ground impact), so report the span
198    // the points actually cover, not the span that was requested.
199    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    // Fixed-point iteration on the intercept range (no-op for pure crossing motion).
206    // The radial guard uses an epsilon, NOT != 0.0: cos(90°.to_radians()) is ~6.1e-17
207    // in floating point, and a bare != 0.0 would make every perpendicular call "iterate"
208    // once (reporting iterations = 1 and a ~1e-14 m range shift) — the acceptance test
209    // requires iterations == 0 for pure crossing. 1e-9 m/s of radial speed is far below
210    // physical relevance.
211    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        // residual check: one more application of the map moves R by < 0.1 m
320    }
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        // Radial closing speed so large the intercept range collapses to <= 0.
340        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}