Skip to main content

ballistics_engine/
hold_curve.rs

1//! `HoldCurve`: a solved, sampled drop-vs-range curve read by interpolation, and the
2//! sampled-trajectory helpers it is built on (MBA-1361/MBA-1362).
3//!
4//! Promoted out of the CLI binary (0.33.0 decision-support Task 8) so a library consumer can
5//! reuse it without the `cli` feature -- starting with the constant-drop range-card work
6//! [`HoldCurve`]'s own doc comment already nominates as its next consumer. No feature gate:
7//! must compile for wasm32 (pure math over already-resolved inputs; no fs, no clap -- CLI
8//! argument resolution, `InverseSolverLoadArgs::resolve`, stays in `main.rs` and constructs
9//! [`HoldCurveLoad`] there).
10
11use crate::constants::GRAINS_TO_KG;
12use crate::trajectory_observation::{bracket_param, Bracket};
13use crate::truing::fallback_bullet_length_m;
14use crate::truing_dsf::DsfTable;
15use crate::{
16    trajectory_sampling, AtmosphericConditions, BCSegmentData, BallisticInputs, DragModel,
17    TrajectorySolver, WindConditions,
18};
19use std::error::Error;
20
21/// Shared: build BallisticInputs + atmosphere + wind from common parameters (all in metric)
22#[allow(
23    clippy::too_many_arguments,
24    reason = "flat arguments preserve the shared CLI trajectory compatibility helper"
25)]
26pub fn build_trajectory_components(
27    velocity: f64,
28    bc: f64,
29    mass: f64,
30    diameter: f64,
31    drag_model: DragModel,
32    sight_height: f64,
33    temperature: f64,
34    pressure: f64,
35    humidity: f64,
36    altitude: f64,
37    wind_speed: f64,
38    wind_direction: f64,
39    max_range: f64,
40    sample_interval: f64,
41    // MBA-1323 Phase 2: saved-profile velocity-BC segments / Mach-Cd drag curve, already
42    // resolved to engine shapes by the caller (bc_segments_from_profile /
43    // drag_table_from_profile). `None` for every caller that does not (yet) source these from
44    // a saved profile — see handle_come_ups/handle_lead for the callers that do.
45    bc_segments_data: Option<Vec<BCSegmentData>>,
46    custom_drag_table: Option<crate::drag::DragTable>,
47    // MBA-1359: deliberate POI offset at the zero range (meters), carried on the inputs so
48    // the zero solves that reuse these components inherit it. 0.0 = no offset.
49    zero_poi_vertical_m: f64,
50    zero_poi_horizontal_m: f64,
51    // MBA-1396: lateral sight-to-bore mount offset (meters, positive = sight right of
52    // bore). 0.0 = sight directly above the bore.
53    sight_offset_lateral_m: f64,
54) -> (BallisticInputs, WindConditions, AtmosphericConditions) {
55    let drag_model_enum = drag_model;
56    let wind_direction_rad = wind_direction.to_radians();
57    let use_bc_segments = bc_segments_data.is_some();
58
59    let inputs = BallisticInputs {
60        bc_value: bc,
61        bc_type: drag_model_enum,
62        bullet_mass: mass,
63        muzzle_velocity: velocity,
64        bullet_diameter: diameter,
65        bullet_length: fallback_bullet_length_m(diameter, mass),
66        muzzle_angle: 0.0,
67        target_distance: max_range,
68        sight_height,
69        sight_offset_lateral_m,
70        zero_poi_vertical_m,
71        zero_poi_horizontal_m,
72        altitude,
73        temperature,
74        pressure,
75        humidity,
76        wind_speed,
77        wind_angle: wind_direction_rad,
78        use_rk4: true,           // Required for non-Euler solver
79        use_adaptive_rk45: true, // Use RK45 adaptive (default solver)
80        enable_trajectory_sampling: true,
81        sample_interval,
82        caliber_inches: diameter / 0.0254,
83        weight_grains: mass / GRAINS_TO_KG,
84        twist_rate: 12.0,
85        is_twist_right: true,
86        use_bc_segments,
87        bc_segments_data,
88        custom_drag_table,
89        ..Default::default()
90    };
91
92    // wind_direction enters from the CLI in degrees; both engine structures use radians.
93    let wind = WindConditions {
94        speed: wind_speed,
95        direction: wind_direction_rad,
96        ..Default::default()
97    };
98
99    let atmosphere = AtmosphericConditions {
100        temperature,
101        pressure,
102        humidity,
103        altitude,
104    };
105
106    (inputs, wind, atmosphere)
107}
108
109/// A solved flight: the sampled grid, plus how far the bullet ACTUALLY got (MBA-1476).
110///
111/// `reach_m` is the solved trajectory's own terminal downrange distance — where it hit the
112/// ground, ran out of flight time, or was stopped by the caller's `max_range` — not the last
113/// point on the sampling grid. The two differ by up to one grid cell, and only `reach_m` is
114/// independent of the grid the caller chose: it is the figure a card quotes when it has to
115/// tell a shooter the bullet does not get out to the row they asked for. Quoting the last
116/// SAMPLE there instead made that number a function of the request (`--end` sets the sampled
117/// span), so the same load's "reaches only …" figure moved when nothing about the load had.
118pub struct SampledFlight {
119    pub samples: Vec<trajectory_sampling::TrajectorySample>,
120    /// Terminal downrange distance of the solved flight, meters.
121    pub reach_m: f64,
122}
123
124/// Run a trajectory and return sampled points at the given zero angle.
125///
126/// Discards the flight's own reach; [`run_sampled_flight`] keeps it, and every card surface
127/// uses that one because a truncated card has to state how far the load actually flew.
128#[allow(
129    clippy::too_many_arguments,
130    reason = "flat arguments preserve the shared sampled-trajectory compatibility helper"
131)]
132pub fn run_sampled_trajectory(
133    velocity: f64,
134    bc: f64,
135    mass: f64,
136    diameter: f64,
137    drag_model: DragModel,
138    sight_height: f64,
139    temperature: f64,
140    pressure: f64,
141    humidity: f64,
142    altitude: f64,
143    wind_speed: f64,
144    wind_direction: f64,
145    max_range: f64,
146    sample_interval: f64,
147    zero_angle_rad: f64,
148    bc_segments_data: Option<Vec<BCSegmentData>>,
149    custom_drag_table: Option<crate::drag::DragTable>,
150    dsf_table: Option<&DsfTable>,
151    zero_poi_vertical_m: f64,
152    zero_poi_horizontal_m: f64,
153    sight_offset_lateral_m: f64,
154    zero_solve_distance_m: Option<f64>,
155) -> Result<Vec<trajectory_sampling::TrajectorySample>, Box<dyn Error>> {
156    Ok(run_sampled_flight(
157        velocity,
158        bc,
159        mass,
160        diameter,
161        drag_model,
162        sight_height,
163        temperature,
164        pressure,
165        humidity,
166        altitude,
167        wind_speed,
168        wind_direction,
169        max_range,
170        sample_interval,
171        zero_angle_rad,
172        bc_segments_data,
173        custom_drag_table,
174        dsf_table,
175        zero_poi_vertical_m,
176        zero_poi_horizontal_m,
177        sight_offset_lateral_m,
178        zero_solve_distance_m,
179    )?
180    .samples)
181}
182
183/// Run a trajectory and return its sampled points AND its terminal reach at the given zero
184/// angle. See [`SampledFlight`] for why the reach is carried out alongside the samples.
185#[allow(
186    clippy::too_many_arguments,
187    reason = "flat arguments preserve the shared sampled-trajectory compatibility helper"
188)]
189pub fn run_sampled_flight(
190    velocity: f64,
191    bc: f64,
192    mass: f64,
193    diameter: f64,
194    drag_model: DragModel,
195    sight_height: f64,
196    temperature: f64,
197    pressure: f64,
198    humidity: f64,
199    altitude: f64,
200    wind_speed: f64,
201    wind_direction: f64,
202    max_range: f64,
203    sample_interval: f64,
204    zero_angle_rad: f64,
205    // MBA-1323 Phase 2: see build_trajectory_components's doc comment on these two.
206    bc_segments_data: Option<Vec<BCSegmentData>>,
207    custom_drag_table: Option<crate::drag::DragTable>,
208    // MBA-1357: saved profile's DSF table, already validated by the caller. `None` for
209    // every call site except come-ups (the only sampled-trajectory command with a DSF
210    // auto-apply story so far).
211    dsf_table: Option<&DsfTable>,
212    // MBA-1359: deliberate POI offset at the zero range (meters) plus the distance the
213    // caller's zero solve used (Some iff a zero was solved). The vertical offset already
214    // rides inside `zero_angle_rad` (the zero solve returns it biased); the horizontal
215    // offset becomes an azimuth bias here, exactly as calculate_and_set_zero_angle applies
216    // it to its own solver. (0.0, 0.0, _) and (_, _, None) are exact no-ops.
217    zero_poi_vertical_m: f64,
218    zero_poi_horizontal_m: f64,
219    // MBA-1396: lateral sight-mount offset (meters). Physically displaces the initial
220    // lateral position, and (via windage_zero_bias_rad) joins the azimuth convergence
221    // below when a zero was solved.
222    sight_offset_lateral_m: f64,
223    zero_solve_distance_m: Option<f64>,
224) -> Result<SampledFlight, Box<dyn Error>> {
225    let (mut inputs, wind, atmosphere) = build_trajectory_components(
226        velocity,
227        bc,
228        mass,
229        diameter,
230        drag_model,
231        sight_height,
232        temperature,
233        pressure,
234        humidity,
235        altitude,
236        wind_speed,
237        wind_direction,
238        max_range,
239        sample_interval,
240        bc_segments_data,
241        custom_drag_table,
242        zero_poi_vertical_m,
243        zero_poi_horizontal_m,
244        sight_offset_lateral_m,
245    );
246    inputs.muzzle_angle = zero_angle_rad;
247    if let Some(zero_distance_m) = zero_solve_distance_m {
248        inputs.azimuth_angle += inputs.windage_zero_bias_rad(zero_distance_m);
249    }
250
251    let mut solver = TrajectorySolver::new(inputs, wind, atmosphere);
252    solver.set_max_range(max_range);
253    solver.set_time_step(0.001);
254    let result = solver.solve()?;
255
256    let mut samples = result.sampled_points.unwrap_or_default();
257    // MBA-1357: drop-only correction, same convention as apply_dsf — TrajectorySample's
258    // `drop_m` uses the identical `LOS - actual` sign (see sample_trajectory's own doc
259    // comment in trajectory_sampling.rs), so scaling it directly by the DSF factor is
260    // the exact per-point transform apply_dsf performs on a full TrajectoryResult.
261    // Per-point Mach uses the SAME frozen station speed of sound divisor apply_dsf uses
262    // (velocity_mps / station_speed_of_sound_mps), not a per-altitude recompute.
263    if let Some(table) = dsf_table {
264        let station_sos = result.station_speed_of_sound_mps;
265        for s in samples.iter_mut() {
266            let mach = if station_sos > 0.0 {
267                s.velocity_mps / station_sos
268            } else {
269                0.0
270            };
271            s.drop_m *= table.factor_at(mach);
272        }
273    }
274
275    Ok(SampledFlight {
276        samples,
277        // `TrajectoryResult::max_range` is the terminal point's downrange distance, not the
278        // ceiling that was requested — the flight's own reach.
279        reach_m: result.max_range,
280    })
281}
282
283/// Distance interval every DOPE-card surface samples its trajectory on, meters (~1 yard).
284///
285/// **Deliberately independent of the card's row spacing** (MBA-1476). Sampling on a grid
286/// whose spacing was the card's own `--step` made the value printed against a range depend
287/// on the step, the start and the end of the card that asked for it: the grid is anchored at
288/// zero, so a card starting anywhere that is not a multiple of its step put every requested
289/// row between two samples, and [`sample_at_range`]'s predecessor -- a nearest-sample search
290/// with a tolerance of one and a half whole steps -- then substituted a neighbouring range's
291/// data without saying so. A shooter dialing `300 yd` off a `--start 300 --step 200` card was
292/// reading the 200-yard line. The grid spacing is now a constant of the engine, and the value
293/// at a range is read off it by interpolation, so a range's row is the same row on every card
294/// that contains it.
295///
296/// Same grid as [`HoldCurve::SAMPLE_INTERVAL_M`], and for the same reason: fine enough that
297/// linear interpolation between neighbours is orders of magnitude below what any card
298/// resolves, coarse enough that a 1500 m card is a couple of thousand points. Sampling is
299/// pure post-integration interpolation over the solver's knots -- it does not re-integrate --
300/// so refining it is measurably free next to the solve that produced those knots.
301pub const CARD_SAMPLE_INTERVAL_M: f64 = HoldCurve::SAMPLE_INTERVAL_M;
302
303/// Read a sampled trajectory at an EXACT range by linear interpolation between the two
304/// samples that bracket it (MBA-1476).
305///
306/// `None` when `range_m` lies outside the sampled span (or the span is degenerate) — that is
307/// a row the solved flight genuinely cannot supply, and every caller reports it as an error.
308/// It must never be answered with the closest sample lying around: the substitution is
309/// invisible in the output, and a card row is a number someone dials.
310///
311/// The returned sample carries `distance_m == range_m` — the range asked for, which is also
312/// the range the row is labelled with — so the angular conversion downstream divides by the
313/// same distance the shooter reads. `flags` are dropped: they mark grid points (zero
314/// crossing, apex, Mach transition), not arbitrary interpolated ranges.
315pub fn sample_at_range(
316    samples: &[trajectory_sampling::TrajectorySample],
317    range_m: f64,
318) -> Option<trajectory_sampling::TrajectorySample> {
319    if !range_m.is_finite() {
320        return None;
321    }
322    let Bracket::Inside { lo, t } =
323        bracket_param(samples.len(), |i| samples[i].distance_m, range_m)
324    else {
325        return None;
326    };
327    let hi = lo + 1;
328    let lerp = |a: f64, b: f64| a + (b - a) * t;
329    Some(trajectory_sampling::TrajectorySample {
330        distance_m: range_m,
331        drop_m: lerp(samples[lo].drop_m, samples[hi].drop_m),
332        wind_drift_m: lerp(samples[lo].wind_drift_m, samples[hi].wind_drift_m),
333        velocity_mps: lerp(samples[lo].velocity_mps, samples[hi].velocity_mps),
334        energy_j: lerp(samples[lo].energy_j, samples[hi].energy_j),
335        time_s: lerp(samples[lo].time_s, samples[hi].time_s),
336        flags: Vec::new(),
337    })
338}
339
340/// How far out a card's trajectory must be solved and sampled, given the furthest row it
341/// asked for (`end_m`). Meters in, meters out.
342///
343/// **The headroom past the last row is load-bearing, not a leftover.** The sampling grid is
344/// anchored at zero with a fixed [`CARD_SAMPLE_INTERVAL_M`] spacing, so the last grid point
345/// inside a span is the largest multiple of that interval the span contains: a span ending
346/// only 10 % past the last requested row leaves less than one whole cell of headroom as soon
347/// as `end_m` drops below ten intervals (~10 yd), and the last grid point then lands SHORT of
348/// the last row. That row is genuinely unsampled, and the card correctly declines to invent
349/// it — which is how a request as ordinary as a 5.1 yd card ended up with no rows at all.
350///
351/// Trimming the 10 % — reading it as vestigial because the row-spacing grid it once fed is
352/// gone — widens that hole rather than closing it. The floor is what closes it: at least one
353/// whole grid cell beyond the last row, so a flight that gets there is always sampled there.
354pub fn card_sample_max_range_m(end_m: f64) -> f64 {
355    (end_m * 1.1).max(end_m + CARD_SAMPLE_INTERVAL_M)
356}
357
358/// The one wording every card surface uses when its solved flight supplies NONE of the rows
359/// asked for (MBA-1476) — stated in the caller's display unit, because that is the unit the
360/// request named the range in.
361///
362/// A flight that supplies some but not all of them is not an error: the card prints the rows
363/// it has and says what it left out, via [`CardTruncation`].
364pub fn range_not_sampled_message(requested_display: f64, reach_display: f64, unit: &str) -> String {
365    format!(
366        "no trajectory sample at {requested_display:.0} {unit}: this load's solved flight \
367         reaches only {reach_display:.0} {unit}"
368    )
369}
370
371/// A card that stopped short of the rows it was asked for, in the caller's DISPLAY unit
372/// (MBA-1476 follow-up).
373///
374/// Refusing to FABRICATE a row the flight never reached is right, and stays. Refusing to
375/// print the rows it did reach is not: a `.22 LR` asked for the default 1200 yd card should
376/// see its hundred-yard lines and learn where the bullet stops, not a bare exit code. So the
377/// card truncates, and this is the record of that — carried to stderr for a CLI reader and as
378/// a structured field on every JSON/bridge surface, because a truncation a caller cannot
379/// detect is its own silent failure.
380#[derive(Debug, Clone, Copy, PartialEq)]
381pub struct CardTruncation {
382    /// The furthest row the card asked for (its `--end`).
383    pub requested_end: f64,
384    /// The last row it could actually print.
385    pub last_row: f64,
386    /// How far the solved flight itself got — a property of the load, not of the request.
387    pub reach: f64,
388}
389
390impl CardTruncation {
391    /// The one wording every truncated card surface uses. `unit` labels the display distance
392    /// unit ("yd" / "m").
393    pub fn message(&self, unit: &str) -> String {
394        let Self {
395            requested_end,
396            last_row,
397            reach,
398        } = *self;
399        format!(
400            "card truncated at {last_row:.0} {unit}: {requested_end:.0} {unit} was requested, \
401             but this load's solved flight reaches only {reach:.0} {unit}; the rows past that \
402             are left out rather than filled in from a range the bullet does reach"
403        )
404    }
405
406    /// The same notice sized for PAPER: the one line a printed card carries in its footer
407    /// (MBA-1477).
408    ///
409    /// [`Self::message`] is written for a terminal, where a 180-character sentence wraps and
410    /// costs nothing. A dope card footer is a single centred line about eighty characters
411    /// wide at the fixed footer size, drawn on every page beside the engine/table provenance,
412    /// so the printed form states the same three facts — where the card stops, what was
413    /// asked for, how far the load flies — and drops the sentence explaining that the missing
414    /// rows were not fabricated. Both live on this type so the paper and the terminal cannot
415    /// come to say different things about the same card.
416    pub fn printed_note(&self, unit: &str) -> String {
417        let Self {
418            requested_end,
419            last_row,
420            reach,
421        } = *self;
422        format!(
423            "TRUNCATED at {last_row:.0} {unit}: {requested_end:.0} {unit} requested, this load \
424             reaches only {reach:.0} {unit}"
425        )
426    }
427}
428
429/// Everything one sampled hold curve needs, already in METRIC (MBA-1361/MBA-1362).
430///
431/// Flat and small on purpose: it is built once at a CLI boundary and handed to
432/// [`HoldCurve::solve`], which owns the zero solve so every consumer zeroes identically.
433#[derive(Debug, Clone)]
434pub struct HoldCurveLoad {
435    pub velocity_mps: f64,
436    pub bc: f64,
437    pub mass_kg: f64,
438    pub diameter_m: f64,
439    pub drag_model: DragModel,
440    pub sight_height_m: f64,
441    pub zero_distance_m: f64,
442    pub temperature_c: f64,
443    pub pressure_hpa: f64,
444    pub humidity: f64,
445    pub altitude_m: f64,
446    pub wind_speed_mps: f64,
447    pub wind_direction_deg: f64,
448}
449
450/// One point read off a [`HoldCurve`], in the ANGULAR units a hold is expressed in.
451#[derive(Debug, Clone, Copy, PartialEq)]
452pub struct HoldPoint {
453    pub range_m: f64,
454    /// Milliradians below the line of sight (positive = the shooter holds over).
455    pub drop_mil: f64,
456    /// Milliradians of lateral deflection, positive = the bullet goes RIGHT.
457    pub wind_mil: f64,
458    pub velocity_mps: f64,
459    pub energy_j: f64,
460    pub time_s: f64,
461}
462
463/// A solved, sampled drop-vs-range curve expressed in ANGULAR units (MBA-1361/MBA-1362).
464///
465/// One solve, sampled finely, then read by interpolation — the same shape `come-ups` uses,
466/// but keyed on angle rather than linear drop because that is what a reticle mark is.
467///
468/// **This is THE drop-vs-range helper**, forward ([`Self::at_range`]) and inverse
469/// ([`Self::range_for_angular_drop_mil`]), and all four consumers go through it:
470/// `reticle hold --range` (MBA-1361) plus `mark-to-range`, `bdc-match` and `optimal-zero`
471/// (MBA-1362). Sharing it is the point — three separately-written root finds would be
472/// three chances for "the drop at 500 yards" to mean three different things. The future
473/// constant-drop range-card ticket is mark-to-range with uniformly spaced marks and should
474/// reuse it too rather than growing a fourth.
475pub struct HoldCurve {
476    samples: Vec<trajectory_sampling::TrajectorySample>,
477    /// The solved flight's own terminal distance, meters — see [`Self::reach_m`].
478    reach_m: f64,
479}
480
481/// Result of inverting the hold curve for one mark subtension (MBA-1362).
482///
483/// The two failure arms are REPORTED, never silently dropped: a mark the load cannot reach
484/// is information a shooter needs (it is the reticle's usable range limit), and dropping it
485/// would quietly shorten the answer table.
486#[derive(Debug, Clone, PartialEq)]
487pub enum MarkToRangeOutcome {
488    /// The mark's subtension is matched at this range.
489    Reached(Box<HoldPoint>),
490    /// The subtension corresponds to no range past the far zero. Angular drop is exactly
491    /// zero AT the far zero and only grows past it, so a mark at or ABOVE the optical
492    /// center (a non-positive subtension) is never matched — those are under-holds for
493    /// ranges inside the zero, where drop-vs-range is not monotone and "the range for this
494    /// mark" is not well defined.
495    InsideZero { far_zero_range_m: f64 },
496    /// Angular drop never grows to the subtension within the searched trajectory.
497    BeyondSearch { max_range_m: f64, max_drop_mil: f64 },
498}
499
500impl HoldCurve {
501    /// Sample interval used by every hold curve, meters (~1 yard).
502    ///
503    /// Fine enough that linear interpolation between neighbours is well below the
504    /// resolution any reticle can be read to, and coarse enough that a 1500 m curve is a
505    /// few thousand points.
506    pub const SAMPLE_INTERVAL_M: f64 = 0.9144;
507
508    /// Solve once and sample out to `max_range_m`.
509    pub fn solve(load: &HoldCurveLoad, max_range_m: f64) -> Result<Self, Box<dyn Error>> {
510        if !max_range_m.is_finite() || max_range_m <= 0.0 {
511            return Err("hold curve max range must be finite and greater than zero".into());
512        }
513        let zero_inputs = BallisticInputs {
514            bc_value: load.bc,
515            bc_type: load.drag_model,
516            bullet_mass: load.mass_kg,
517            muzzle_velocity: load.velocity_mps,
518            bullet_diameter: load.diameter_m,
519            bullet_length: fallback_bullet_length_m(load.diameter_m, load.mass_kg),
520            sight_height: load.sight_height_m,
521            use_rk4: true,
522            ..Default::default()
523        };
524        let atmosphere = AtmosphericConditions {
525            temperature: load.temperature_c,
526            pressure: load.pressure_hpa,
527            humidity: load.humidity,
528            altitude: load.altitude_m,
529        };
530        let zero_angle = crate::calculate_zero_angle_with_conditions(
531            zero_inputs,
532            load.zero_distance_m,
533            load.sight_height_m,
534            WindConditions::default(),
535            atmosphere,
536        )?;
537
538        // MBA-1478: the FLIGHT, not just its samples. A card built on this curve has to be
539        // able to quote how far the load actually flew (see `SampledFlight::reach_m`), and
540        // the last sample is not that figure — it moves with the span the caller asked for.
541        let flight = run_sampled_flight(
542            load.velocity_mps,
543            load.bc,
544            load.mass_kg,
545            load.diameter_m,
546            load.drag_model,
547            load.sight_height_m,
548            load.temperature_c,
549            load.pressure_hpa,
550            load.humidity,
551            load.altitude_m,
552            load.wind_speed_mps,
553            load.wind_direction_deg,
554            max_range_m,
555            Self::SAMPLE_INTERVAL_M,
556            zero_angle,
557            None,
558            None,
559            None,
560            0.0,
561            0.0,
562            0.0,
563            Some(load.zero_distance_m),
564        )?;
565        let SampledFlight { samples, reach_m } = flight;
566        if samples.len() < 2 {
567            return Err(
568                "the trajectory produced too few sampled points to read a hold from".into(),
569            );
570        }
571        Ok(Self { samples, reach_m })
572    }
573
574    /// The furthest range this curve reaches, meters.
575    pub fn max_sampled_range_m(&self) -> f64 {
576        self.samples.last().map_or(0.0, |s| s.distance_m)
577    }
578
579    /// The solved flight's own terminal downrange distance, meters (MBA-1478).
580    ///
581    /// Distinct from [`Self::max_sampled_range_m`], which is the last point on the sampling
582    /// grid and therefore a function of the span the CALLER asked to sample. This one is a
583    /// property of the load: it is the figure a truncated card quotes when it tells a shooter
584    /// the bullet does not get out to the row they asked for. See [`SampledFlight`], whose
585    /// doc comment records why quoting the last sample there was wrong.
586    pub fn reach_m(&self) -> f64 {
587        self.reach_m
588    }
589
590    /// This curve's own sample ranges, in order, meters.
591    ///
592    /// Exact multiples of [`Self::SAMPLE_INTERVAL_M`] (`i as f64 * SAMPLE_INTERVAL_M` for
593    /// `i = 0..N`), the same arithmetic sequence a caller would otherwise have to reproduce by
594    /// hand to reason about where this curve was actually verified -- an additive accessor so
595    /// no consumer needs read access to the private `samples` field just to answer "which
596    /// ranges did this curve solve at."
597    pub fn sample_ranges_m(&self) -> Vec<f64> {
598        self.samples.iter().map(|s| s.distance_m).collect()
599    }
600
601    /// Linearly interpolate the angular hold at `range_m`.
602    ///
603    /// `None` when the range is outside the sampled span or non-positive (an angular drop
604    /// is undefined at the muzzle — it divides by the range).
605    pub fn at_range(&self, range_m: f64) -> Option<HoldPoint> {
606        if !range_m.is_finite() || range_m <= 0.0 {
607            return None;
608        }
609        let samples = &self.samples;
610        let Bracket::Inside { lo, t } =
611            bracket_param(samples.len(), |i| samples[i].distance_m, range_m)
612        else {
613            return None;
614        };
615        let hi = lo + 1;
616        let lerp = |a: f64, b: f64| a + (b - a) * t;
617        let drop_m = lerp(samples[lo].drop_m, samples[hi].drop_m);
618        let drift_m = lerp(samples[lo].wind_drift_m, samples[hi].wind_drift_m);
619        Some(HoldPoint {
620            range_m,
621            // Milliradian small-angle definition: 1 mil subtends 1/1000 of the range.
622            drop_mil: drop_m / range_m * 1000.0,
623            wind_mil: drift_m / range_m * 1000.0,
624            velocity_mps: lerp(samples[lo].velocity_mps, samples[hi].velocity_mps),
625            energy_j: lerp(samples[lo].energy_j, samples[hi].energy_j),
626            time_s: lerp(samples[lo].time_s, samples[hi].time_s),
627        })
628    }
629
630    /// The downrange distance of the FAR zero crossing, meters — the point past which
631    /// angular drop grows monotonically with range.
632    ///
633    /// Angular drop is not monotone over the whole flight: it starts large and positive at
634    /// the muzzle (the bullet is a sight height below the line of sight, divided by a tiny
635    /// range), falls through zero at the near zero, goes negative while the bullet rides
636    /// above the line of sight, and returns through zero at the far zero. Only past that
637    /// second crossing is the inverse below single-valued, so the search domain starts
638    /// there rather than at the muzzle.
639    pub fn far_zero_range_m(&self) -> f64 {
640        let mut far = self.samples.first().map_or(0.0, |s| s.distance_m);
641        for sample in &self.samples {
642            if sample.distance_m > 0.0 && sample.drop_m <= 0.0 {
643                far = sample.distance_m;
644            }
645        }
646        far
647    }
648
649    /// Bisection cap for the inverse below. The curve is monotone on the searched interval,
650    /// so bisection halves the bracket every step; 80 iterations takes any realistic
651    /// bracket far below the tolerance and exists only as a runaway guard.
652    const INVERSE_MAX_ITERATIONS: u32 = 80;
653
654    /// Bracket width (meters) at which the inverse stops regardless of residual — a
655    /// hundredth of a millimeter, four orders below anything a range card resolves.
656    const INVERSE_TOLERANCE_M: f64 = 1.0e-5;
657
658    /// Invert the curve: the range at which the angular drop equals `target_mil`.
659    ///
660    /// Bisection over the interpolated curve on `[far zero, furthest sample]`, where drop
661    /// is monotone increasing in range. Both out-of-domain cases come back as their own
662    /// outcome rather than as a clamped range.
663    pub fn range_for_angular_drop_mil(&self, target_mil: f64) -> MarkToRangeOutcome {
664        let far_zero_range_m = self.far_zero_range_m();
665        let max_range_m = self.max_sampled_range_m();
666        let drop_at = |range_m: f64| self.at_range(range_m).map(|p| p.drop_mil);
667        let max_drop_mil = drop_at(max_range_m).unwrap_or(f64::NEG_INFINITY);
668
669        if !target_mil.is_finite() || target_mil <= 0.0 {
670            return MarkToRangeOutcome::InsideZero { far_zero_range_m };
671        }
672        if target_mil > max_drop_mil {
673            return MarkToRangeOutcome::BeyondSearch {
674                max_range_m,
675                max_drop_mil,
676            };
677        }
678        // Start the bracket just past the far zero: exactly at it the drop is 0, and any
679        // positive target is therefore bracketed.
680        let mut lo = far_zero_range_m.max(Self::SAMPLE_INTERVAL_M);
681        if drop_at(lo).is_none_or(|drop| drop >= target_mil) {
682            return MarkToRangeOutcome::InsideZero { far_zero_range_m };
683        }
684        let mut hi = max_range_m;
685        for _ in 0..Self::INVERSE_MAX_ITERATIONS {
686            if hi - lo <= Self::INVERSE_TOLERANCE_M {
687                break;
688            }
689            let mid = 0.5 * (lo + hi);
690            match drop_at(mid) {
691                Some(drop) if drop < target_mil => lo = mid,
692                Some(_) => hi = mid,
693                None => break,
694            }
695        }
696        match self.at_range(0.5 * (lo + hi)) {
697            Some(point) => MarkToRangeOutcome::Reached(Box::new(point)),
698            None => MarkToRangeOutcome::BeyondSearch {
699                max_range_m,
700                max_drop_mil,
701            },
702        }
703    }
704}
705
706#[cfg(test)]
707mod tests {
708    use super::*;
709
710    /// A representative metric load (a .308-class 168 gr match bullet at a 100 m zero),
711    /// solved with a nonzero crosswind so both `drop_mil` and `wind_mil` are exercised.
712    fn oracle_test_load() -> HoldCurveLoad {
713        HoldCurveLoad {
714            velocity_mps: 800.0,
715            bc: 0.223,
716            mass_kg: 0.0109,
717            diameter_m: 0.00782,
718            drag_model: DragModel::G7,
719            sight_height_m: 0.045,
720            zero_distance_m: 100.0,
721            temperature_c: 15.0,
722            pressure_hpa: 1013.25,
723            humidity: 50.0,
724            altitude_m: 0.0,
725            wind_speed_mps: 3.0,
726            wind_direction_deg: 90.0,
727        }
728    }
729
730    /// Reproduces exactly what [`HoldCurve::solve`] does internally, as a SEPARATE, from-
731    /// scratch call -- it never reads any `HoldCurve`'s private `samples` field. This gives
732    /// `at_range` an independent direct-solve oracle to be checked against, rather than a
733    /// self-consistency check of `HoldCurve` against its own stored state.
734    fn independent_direct_solve(
735        load: &HoldCurveLoad,
736        max_range_m: f64,
737    ) -> Vec<trajectory_sampling::TrajectorySample> {
738        let zero_inputs = BallisticInputs {
739            bc_value: load.bc,
740            bc_type: load.drag_model,
741            bullet_mass: load.mass_kg,
742            muzzle_velocity: load.velocity_mps,
743            bullet_diameter: load.diameter_m,
744            bullet_length: fallback_bullet_length_m(load.diameter_m, load.mass_kg),
745            sight_height: load.sight_height_m,
746            use_rk4: true,
747            ..Default::default()
748        };
749        let atmosphere = AtmosphericConditions {
750            temperature: load.temperature_c,
751            pressure: load.pressure_hpa,
752            humidity: load.humidity,
753            altitude: load.altitude_m,
754        };
755        let zero_angle = crate::calculate_zero_angle_with_conditions(
756            zero_inputs,
757            load.zero_distance_m,
758            load.sight_height_m,
759            WindConditions::default(),
760            atmosphere,
761        )
762        .expect("zero solve should succeed for a realistic load");
763
764        run_sampled_trajectory(
765            load.velocity_mps,
766            load.bc,
767            load.mass_kg,
768            load.diameter_m,
769            load.drag_model,
770            load.sight_height_m,
771            load.temperature_c,
772            load.pressure_hpa,
773            load.humidity,
774            load.altitude_m,
775            load.wind_speed_mps,
776            load.wind_direction_deg,
777            max_range_m,
778            HoldCurve::SAMPLE_INTERVAL_M,
779            zero_angle,
780            None,
781            None,
782            None,
783            0.0,
784            0.0,
785            0.0,
786            Some(load.zero_distance_m),
787        )
788        .expect("sampled trajectory should succeed for a realistic load")
789    }
790
791    #[test]
792    fn at_range_matches_an_independent_direct_solves_sampled_observation() {
793        let load = oracle_test_load();
794        let max_range_m = 1500.0;
795
796        let hold_curve = HoldCurve::solve(&load, max_range_m).expect("hold curve should solve");
797        let oracle_samples = independent_direct_solve(&load, max_range_m);
798
799        // A probe range read off the oracle's own grid rather than a hand-picked constant --
800        // it only needs to land exactly on a sample node so `at_range`'s bracket search
801        // resolves with t == 1.0 (the exact-endpoint-hit convention Task 7 disclosed: an exact
802        // key hit lands on the upper endpoint of its bracket, not the lower one) and its own
803        // linear interpolation is not itself a source of disagreement with the oracle.
804        let probe = &oracle_samples[oracle_samples.len() / 2];
805        let probe_range_m = probe.distance_m;
806        assert!(probe_range_m > 0.0 && probe_range_m.is_finite());
807
808        let point = hold_curve
809            .at_range(probe_range_m)
810            .expect("probe range must be inside the sampled span");
811
812        let expected_drop_mil = probe.drop_m / probe_range_m * 1000.0;
813        let expected_wind_mil = probe.wind_drift_m / probe_range_m * 1000.0;
814
815        assert!((point.range_m - probe_range_m).abs() < 1e-9);
816        assert!((point.drop_mil - expected_drop_mil).abs() < 1e-9);
817        assert!((point.wind_mil - expected_wind_mil).abs() < 1e-9);
818        assert!((point.velocity_mps - probe.velocity_mps).abs() < 1e-9);
819        assert!((point.energy_j - probe.energy_j).abs() < 1e-9);
820        assert!((point.time_s - probe.time_s).abs() < 1e-9);
821    }
822
823    #[test]
824    fn at_range_outside_the_sampled_span_returns_none() {
825        let load = oracle_test_load();
826        let max_range_m = 1500.0;
827        let hold_curve = HoldCurve::solve(&load, max_range_m).expect("hold curve should solve");
828
829        let beyond = hold_curve.max_sampled_range_m() + 10_000.0;
830        assert_eq!(hold_curve.at_range(beyond), None);
831    }
832
833    /// `sample_ranges_m` must reproduce the exact arithmetic sequence `HoldCurve::solve` built
834    /// internally: index 0 at the muzzle (`0.0`), every later index an exact multiple of
835    /// `SAMPLE_INTERVAL_M`, and the last entry equal to `max_sampled_range_m()`.
836    #[test]
837    fn sample_ranges_m_is_the_exact_multiples_of_the_sample_interval() {
838        let load = oracle_test_load();
839        let hold_curve = HoldCurve::solve(&load, 1500.0).expect("hold curve should solve");
840
841        let ranges = hold_curve.sample_ranges_m();
842        assert!(!ranges.is_empty());
843        assert_eq!(ranges[0], 0.0);
844        assert_eq!(*ranges.last().unwrap(), hold_curve.max_sampled_range_m());
845        for (i, &r) in ranges.iter().enumerate() {
846            let expected = i as f64 * HoldCurve::SAMPLE_INTERVAL_M;
847            assert!(
848                (r - expected).abs() < 1e-9,
849                "index {i}: got {r}, expected {expected} ({} * SAMPLE_INTERVAL_M)",
850                i
851            );
852        }
853    }
854}