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`).
198///
199/// Presence-blind: `atmo` goes through the legacy default-sentinel rule (see
200/// [`TrajectorySolver::new`]). See [`calculate_lead_with_station_presence`].
201pub 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
220/// [`calculate_lead`] knowing which of `atmo`'s temperature and pressure the caller supplied
221/// (0.49.0): a supplied value flies as given, an omitted one keeps the legacy default-sentinel
222/// rule (see [`TrajectorySolver::new_with_station_presence`]). `NEITHER` is bit-identical to
223/// [`calculate_lead`].
224pub 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    // One solve with headroom for outbound intercepts: 6 s of target motion covers
252    // any realistic small-arms TOF at leadable ranges.
253    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    // The solve can end short of max_range (e.g. ground impact), so report the span
261    // the points actually cover, not the span that was requested.
262    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    // Fixed-point iteration on the intercept range (no-op for pure crossing motion).
269    // The radial guard uses an epsilon, NOT != 0.0: cos(90°.to_radians()) is ~6.1e-17
270    // in floating point, and a bare != 0.0 would make every perpendicular call "iterate"
271    // once (reporting iterations = 1 and a ~1e-14 m range shift) — the acceptance test
272    // requires iterations == 0 for pure crossing. 1e-9 m/s of radial speed is far below
273    // physical relevance.
274    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        // residual check: one more application of the map moves R by < 0.1 m
395    }
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        // Radial closing speed so large the intercept range collapses to <= 0.
415        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}