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/// Mover-ring radius for the "wait for the target to enter the ring" hold technique
137/// (MBA-1325): every trajectory point already carries its own time of flight, so the
138/// ring a shooter watches for a mover — radius `target_speed x ToF(range)` around the
139/// hold point — falls out of an already-solved trajectory as pure post-processing. A
140/// mover crossing INTO the ring during the bullet's flight arrives at the hold point at
141/// roughly the same moment the bullet does.
142///
143/// This is a WORST-CASE bound, not an exact intercept solution: it is track-angle-agnostic
144/// (unlike [`lead_from_tof`]'s explicit 90-degree-crossing assumption) and exact only when
145/// the mover's track actually passes through the hold point. Shared by the native CLI
146/// (`trajectory --target-speed`) and the WASM `trajectory` command so both surfaces apply
147/// the identical formula.
148///
149/// Returns `(ring_m, ring_mil)`. `ring_m` is the linear radius in meters — ALWAYS SI,
150/// independent of the caller's display units, because the JSON field it feeds
151/// (`mover_ring_m`) carries its unit in the name (MBA-1315 hygiene: no ambiguous units).
152/// `ring_mil = ring_m / downrange_m * 1000`, `None` at/before the muzzle (`downrange_m
153/// <= 0`) where the ratio is degenerate.
154pub 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
164/// Linear-interpolated time of flight at downrange distance `x_m`, from a solved
165/// trajectory's points. Returns `None` when `x_m` lies beyond the solved span.
166/// (MBA-1218 guarantees the final point sits exactly at the solve's max range.)
167fn 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
185/// Compute the lead solution for a target moving at `speed_mps` along `angle_deg`
186/// (see module docs for the datum) at initial range `range_m`.
187///
188/// Runs ONE wind-aware solve past the range (with outbound headroom), interpolates
189/// time of flight from its points, and fixed-point iterates the intercept range for
190/// radial motion. Errors are typed (`LeadError`).
191pub 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    // One solve with headroom for outbound intercepts: 6 s of target motion covers
218    // any realistic small-arms TOF at leadable ranges.
219    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    // The solve can end short of max_range (e.g. ground impact), so report the span
226    // the points actually cover, not the span that was requested.
227    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    // Fixed-point iteration on the intercept range (no-op for pure crossing motion).
234    // The radial guard uses an epsilon, NOT != 0.0: cos(90°.to_radians()) is ~6.1e-17
235    // in floating point, and a bare != 0.0 would make every perpendicular call "iterate"
236    // once (reporting iterations = 1 and a ~1e-14 m range shift) — the acceptance test
237    // requires iterations == 0 for pure crossing. 1e-9 m/s of radial speed is far below
238    // physical relevance.
239    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        // residual check: one more application of the map moves R by < 0.1 m
349    }
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        // Radial closing speed so large the intercept range collapses to <= 0.
369        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}