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