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 {
82        corrected_range_m: f64,
83        solved_span_m: f64,
84    },
85    /// The underlying trajectory solve failed (invalid ballistic inputs, etc.).
86    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
118/// Signed lateral/radial split of the target velocity, m/s.
119/// Radial positive = receding; lateral positive = moving right.
120fn 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
125/// Pure lead math from a known time of flight — shared by `calculate_lead` and the
126/// PDF dope-card path (which already has per-row TOF from its sampled trajectory).
127/// Uses the small-angle linear ratio `lead/range`, matching every other hold table.
128pub 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
139/// Mover-ring radius for the "wait for the target to enter the ring" hold technique
140/// (MBA-1325): every trajectory point already carries its own time of flight, so the
141/// ring a shooter watches for a mover — radius `target_speed x ToF(range)` around the
142/// hold point — falls out of an already-solved trajectory as pure post-processing. A
143/// mover crossing INTO the ring during the bullet's flight arrives at the hold point at
144/// roughly the same moment the bullet does.
145///
146/// This is a WORST-CASE bound, not an exact intercept solution: it is track-angle-agnostic
147/// (unlike [`lead_from_tof`]'s explicit 90-degree-crossing assumption) and exact only when
148/// the mover's track actually passes through the hold point. Shared by the native CLI
149/// (`trajectory --target-speed`) and the WASM `trajectory` command so both surfaces apply
150/// the identical formula.
151///
152/// Returns `(ring_m, ring_mil)`. `ring_m` is the linear radius in meters — ALWAYS SI,
153/// independent of the caller's display units, because the JSON field it feeds
154/// (`mover_ring_m`) carries its unit in the name (MBA-1315 hygiene: no ambiguous units).
155/// `ring_mil = ring_m / downrange_m * 1000`, `None` at/before the muzzle (`downrange_m
156/// <= 0`) where the ratio is degenerate.
157pub 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
167/// Linear-interpolated time of flight at downrange distance `x_m`, from a solved
168/// trajectory's points. Returns `None` when `x_m` lies beyond the solved span.
169/// (MBA-1218 guarantees the final point sits exactly at the solve's max range.)
170fn 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
192/// Compute the lead solution for a target moving at `speed_mps` along `angle_deg`
193/// (see module docs for the datum) at initial range `range_m`.
194///
195/// Runs ONE wind-aware solve past the range (with outbound headroom), interpolates
196/// time of flight from its points, and fixed-point iterates the intercept range for
197/// radial motion. Errors are typed (`LeadError`).
198pub 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    // One solve with headroom for outbound intercepts: 6 s of target motion covers
225    // any realistic small-arms TOF at leadable ranges.
226    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    // The solve can end short of max_range (e.g. ground impact), so report the span
233    // the points actually cover, not the span that was requested.
234    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    // Fixed-point iteration on the intercept range (no-op for pure crossing motion).
241    // The radial guard uses an epsilon, NOT != 0.0: cos(90°.to_radians()) is ~6.1e-17
242    // in floating point, and a bare != 0.0 would make every perpendicular call "iterate"
243    // once (reporting iterations = 1 and a ~1e-14 m range shift) — the acceptance test
244    // requires iterations == 0 for pure crossing. 1e-9 m/s of radial speed is far below
245    // physical relevance.
246    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        // residual check: one more application of the map moves R by < 0.1 m
367    }
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        // Radial closing speed so large the intercept range collapses to <= 0.
387        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}