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/// Run a trajectory and return sampled points at the given zero angle
110#[allow(
111    clippy::too_many_arguments,
112    reason = "flat arguments preserve the shared sampled-trajectory compatibility helper"
113)]
114pub fn run_sampled_trajectory(
115    velocity: f64,
116    bc: f64,
117    mass: f64,
118    diameter: f64,
119    drag_model: DragModel,
120    sight_height: f64,
121    temperature: f64,
122    pressure: f64,
123    humidity: f64,
124    altitude: f64,
125    wind_speed: f64,
126    wind_direction: f64,
127    max_range: f64,
128    sample_interval: f64,
129    zero_angle_rad: f64,
130    // MBA-1323 Phase 2: see build_trajectory_components's doc comment on these two.
131    bc_segments_data: Option<Vec<BCSegmentData>>,
132    custom_drag_table: Option<crate::drag::DragTable>,
133    // MBA-1357: saved profile's DSF table, already validated by the caller. `None` for
134    // every call site except come-ups (the only sampled-trajectory command with a DSF
135    // auto-apply story so far).
136    dsf_table: Option<&DsfTable>,
137    // MBA-1359: deliberate POI offset at the zero range (meters) plus the distance the
138    // caller's zero solve used (Some iff a zero was solved). The vertical offset already
139    // rides inside `zero_angle_rad` (the zero solve returns it biased); the horizontal
140    // offset becomes an azimuth bias here, exactly as calculate_and_set_zero_angle applies
141    // it to its own solver. (0.0, 0.0, _) and (_, _, None) are exact no-ops.
142    zero_poi_vertical_m: f64,
143    zero_poi_horizontal_m: f64,
144    // MBA-1396: lateral sight-mount offset (meters). Physically displaces the initial
145    // lateral position, and (via windage_zero_bias_rad) joins the azimuth convergence
146    // below when a zero was solved.
147    sight_offset_lateral_m: f64,
148    zero_solve_distance_m: Option<f64>,
149) -> Result<Vec<trajectory_sampling::TrajectorySample>, Box<dyn Error>> {
150    let (mut inputs, wind, atmosphere) = build_trajectory_components(
151        velocity,
152        bc,
153        mass,
154        diameter,
155        drag_model,
156        sight_height,
157        temperature,
158        pressure,
159        humidity,
160        altitude,
161        wind_speed,
162        wind_direction,
163        max_range,
164        sample_interval,
165        bc_segments_data,
166        custom_drag_table,
167        zero_poi_vertical_m,
168        zero_poi_horizontal_m,
169        sight_offset_lateral_m,
170    );
171    inputs.muzzle_angle = zero_angle_rad;
172    if let Some(zero_distance_m) = zero_solve_distance_m {
173        inputs.azimuth_angle += inputs.windage_zero_bias_rad(zero_distance_m);
174    }
175
176    let mut solver = TrajectorySolver::new(inputs, wind, atmosphere);
177    solver.set_max_range(max_range);
178    solver.set_time_step(0.001);
179    let result = solver.solve()?;
180
181    let mut samples = result.sampled_points.unwrap_or_default();
182    // MBA-1357: drop-only correction, same convention as apply_dsf — TrajectorySample's
183    // `drop_m` uses the identical `LOS - actual` sign (see sample_trajectory's own doc
184    // comment in trajectory_sampling.rs), so scaling it directly by the DSF factor is
185    // the exact per-point transform apply_dsf performs on a full TrajectoryResult.
186    // Per-point Mach uses the SAME frozen station speed of sound divisor apply_dsf uses
187    // (velocity_mps / station_speed_of_sound_mps), not a per-altitude recompute.
188    if let Some(table) = dsf_table {
189        let station_sos = result.station_speed_of_sound_mps;
190        for s in samples.iter_mut() {
191            let mach = if station_sos > 0.0 {
192                s.velocity_mps / station_sos
193            } else {
194                0.0
195            };
196            s.drop_m *= table.factor_at(mach);
197        }
198    }
199
200    Ok(samples)
201}
202
203/// Everything one sampled hold curve needs, already in METRIC (MBA-1361/MBA-1362).
204///
205/// Flat and small on purpose: it is built once at a CLI boundary and handed to
206/// [`HoldCurve::solve`], which owns the zero solve so every consumer zeroes identically.
207#[derive(Debug, Clone)]
208pub struct HoldCurveLoad {
209    pub velocity_mps: f64,
210    pub bc: f64,
211    pub mass_kg: f64,
212    pub diameter_m: f64,
213    pub drag_model: DragModel,
214    pub sight_height_m: f64,
215    pub zero_distance_m: f64,
216    pub temperature_c: f64,
217    pub pressure_hpa: f64,
218    pub humidity: f64,
219    pub altitude_m: f64,
220    pub wind_speed_mps: f64,
221    pub wind_direction_deg: f64,
222}
223
224/// One point read off a [`HoldCurve`], in the ANGULAR units a hold is expressed in.
225#[derive(Debug, Clone, Copy, PartialEq)]
226pub struct HoldPoint {
227    pub range_m: f64,
228    /// Milliradians below the line of sight (positive = the shooter holds over).
229    pub drop_mil: f64,
230    /// Milliradians of lateral deflection, positive = the bullet goes RIGHT.
231    pub wind_mil: f64,
232    pub velocity_mps: f64,
233    pub energy_j: f64,
234    pub time_s: f64,
235}
236
237/// A solved, sampled drop-vs-range curve expressed in ANGULAR units (MBA-1361/MBA-1362).
238///
239/// One solve, sampled finely, then read by interpolation — the same shape `come-ups` uses,
240/// but keyed on angle rather than linear drop because that is what a reticle mark is.
241///
242/// **This is THE drop-vs-range helper**, forward ([`Self::at_range`]) and inverse
243/// ([`Self::range_for_angular_drop_mil`]), and all four consumers go through it:
244/// `reticle hold --range` (MBA-1361) plus `mark-to-range`, `bdc-match` and `optimal-zero`
245/// (MBA-1362). Sharing it is the point — three separately-written root finds would be
246/// three chances for "the drop at 500 yards" to mean three different things. The future
247/// constant-drop range-card ticket is mark-to-range with uniformly spaced marks and should
248/// reuse it too rather than growing a fourth.
249pub struct HoldCurve {
250    samples: Vec<trajectory_sampling::TrajectorySample>,
251}
252
253/// Result of inverting the hold curve for one mark subtension (MBA-1362).
254///
255/// The two failure arms are REPORTED, never silently dropped: a mark the load cannot reach
256/// is information a shooter needs (it is the reticle's usable range limit), and dropping it
257/// would quietly shorten the answer table.
258#[derive(Debug, Clone, PartialEq)]
259pub enum MarkToRangeOutcome {
260    /// The mark's subtension is matched at this range.
261    Reached(Box<HoldPoint>),
262    /// The subtension corresponds to no range past the far zero. Angular drop is exactly
263    /// zero AT the far zero and only grows past it, so a mark at or ABOVE the optical
264    /// center (a non-positive subtension) is never matched — those are under-holds for
265    /// ranges inside the zero, where drop-vs-range is not monotone and "the range for this
266    /// mark" is not well defined.
267    InsideZero { far_zero_range_m: f64 },
268    /// Angular drop never grows to the subtension within the searched trajectory.
269    BeyondSearch {
270        max_range_m: f64,
271        max_drop_mil: f64,
272    },
273}
274
275impl HoldCurve {
276    /// Sample interval used by every hold curve, meters (~1 yard).
277    ///
278    /// Fine enough that linear interpolation between neighbours is well below the
279    /// resolution any reticle can be read to, and coarse enough that a 1500 m curve is a
280    /// few thousand points.
281    pub const SAMPLE_INTERVAL_M: f64 = 0.9144;
282
283    /// Solve once and sample out to `max_range_m`.
284    pub fn solve(load: &HoldCurveLoad, max_range_m: f64) -> Result<Self, Box<dyn Error>> {
285        if !max_range_m.is_finite() || max_range_m <= 0.0 {
286            return Err("hold curve max range must be finite and greater than zero".into());
287        }
288        let zero_inputs = BallisticInputs {
289            bc_value: load.bc,
290            bc_type: load.drag_model,
291            bullet_mass: load.mass_kg,
292            muzzle_velocity: load.velocity_mps,
293            bullet_diameter: load.diameter_m,
294            bullet_length: fallback_bullet_length_m(load.diameter_m, load.mass_kg),
295            sight_height: load.sight_height_m,
296            use_rk4: true,
297            ..Default::default()
298        };
299        let atmosphere = AtmosphericConditions {
300            temperature: load.temperature_c,
301            pressure: load.pressure_hpa,
302            humidity: load.humidity,
303            altitude: load.altitude_m,
304        };
305        let zero_angle = crate::calculate_zero_angle_with_conditions(
306            zero_inputs,
307            load.zero_distance_m,
308            load.sight_height_m,
309            WindConditions::default(),
310            atmosphere,
311        )?;
312
313        let samples = run_sampled_trajectory(
314            load.velocity_mps,
315            load.bc,
316            load.mass_kg,
317            load.diameter_m,
318            load.drag_model,
319            load.sight_height_m,
320            load.temperature_c,
321            load.pressure_hpa,
322            load.humidity,
323            load.altitude_m,
324            load.wind_speed_mps,
325            load.wind_direction_deg,
326            max_range_m,
327            Self::SAMPLE_INTERVAL_M,
328            zero_angle,
329            None,
330            None,
331            None,
332            0.0,
333            0.0,
334            0.0,
335            Some(load.zero_distance_m),
336        )?;
337        if samples.len() < 2 {
338            return Err(
339                "the trajectory produced too few sampled points to read a hold from".into(),
340            );
341        }
342        Ok(Self { samples })
343    }
344
345    /// The furthest range this curve reaches, meters.
346    pub fn max_sampled_range_m(&self) -> f64 {
347        self.samples.last().map_or(0.0, |s| s.distance_m)
348    }
349
350    /// This curve's own sample ranges, in order, meters.
351    ///
352    /// Exact multiples of [`Self::SAMPLE_INTERVAL_M`] (`i as f64 * SAMPLE_INTERVAL_M` for
353    /// `i = 0..N`), the same arithmetic sequence a caller would otherwise have to reproduce by
354    /// hand to reason about where this curve was actually verified -- an additive accessor so
355    /// no consumer needs read access to the private `samples` field just to answer "which
356    /// ranges did this curve solve at."
357    pub fn sample_ranges_m(&self) -> Vec<f64> {
358        self.samples.iter().map(|s| s.distance_m).collect()
359    }
360
361    /// Linearly interpolate the angular hold at `range_m`.
362    ///
363    /// `None` when the range is outside the sampled span or non-positive (an angular drop
364    /// is undefined at the muzzle — it divides by the range).
365    pub fn at_range(&self, range_m: f64) -> Option<HoldPoint> {
366        if !range_m.is_finite() || range_m <= 0.0 {
367            return None;
368        }
369        let samples = &self.samples;
370        let Bracket::Inside { lo, t } =
371            bracket_param(samples.len(), |i| samples[i].distance_m, range_m)
372        else {
373            return None;
374        };
375        let hi = lo + 1;
376        let lerp = |a: f64, b: f64| a + (b - a) * t;
377        let drop_m = lerp(samples[lo].drop_m, samples[hi].drop_m);
378        let drift_m = lerp(samples[lo].wind_drift_m, samples[hi].wind_drift_m);
379        Some(HoldPoint {
380            range_m,
381            // Milliradian small-angle definition: 1 mil subtends 1/1000 of the range.
382            drop_mil: drop_m / range_m * 1000.0,
383            wind_mil: drift_m / range_m * 1000.0,
384            velocity_mps: lerp(samples[lo].velocity_mps, samples[hi].velocity_mps),
385            energy_j: lerp(samples[lo].energy_j, samples[hi].energy_j),
386            time_s: lerp(samples[lo].time_s, samples[hi].time_s),
387        })
388    }
389
390    /// The downrange distance of the FAR zero crossing, meters — the point past which
391    /// angular drop grows monotonically with range.
392    ///
393    /// Angular drop is not monotone over the whole flight: it starts large and positive at
394    /// the muzzle (the bullet is a sight height below the line of sight, divided by a tiny
395    /// range), falls through zero at the near zero, goes negative while the bullet rides
396    /// above the line of sight, and returns through zero at the far zero. Only past that
397    /// second crossing is the inverse below single-valued, so the search domain starts
398    /// there rather than at the muzzle.
399    pub fn far_zero_range_m(&self) -> f64 {
400        let mut far = self.samples.first().map_or(0.0, |s| s.distance_m);
401        for sample in &self.samples {
402            if sample.distance_m > 0.0 && sample.drop_m <= 0.0 {
403                far = sample.distance_m;
404            }
405        }
406        far
407    }
408
409    /// Bisection cap for the inverse below. The curve is monotone on the searched interval,
410    /// so bisection halves the bracket every step; 80 iterations takes any realistic
411    /// bracket far below the tolerance and exists only as a runaway guard.
412    const INVERSE_MAX_ITERATIONS: u32 = 80;
413
414    /// Bracket width (meters) at which the inverse stops regardless of residual — a
415    /// hundredth of a millimeter, four orders below anything a range card resolves.
416    const INVERSE_TOLERANCE_M: f64 = 1.0e-5;
417
418    /// Invert the curve: the range at which the angular drop equals `target_mil`.
419    ///
420    /// Bisection over the interpolated curve on `[far zero, furthest sample]`, where drop
421    /// is monotone increasing in range. Both out-of-domain cases come back as their own
422    /// outcome rather than as a clamped range.
423    pub fn range_for_angular_drop_mil(&self, target_mil: f64) -> MarkToRangeOutcome {
424        let far_zero_range_m = self.far_zero_range_m();
425        let max_range_m = self.max_sampled_range_m();
426        let drop_at = |range_m: f64| self.at_range(range_m).map(|p| p.drop_mil);
427        let max_drop_mil = drop_at(max_range_m).unwrap_or(f64::NEG_INFINITY);
428
429        if !target_mil.is_finite() || target_mil <= 0.0 {
430            return MarkToRangeOutcome::InsideZero { far_zero_range_m };
431        }
432        if target_mil > max_drop_mil {
433            return MarkToRangeOutcome::BeyondSearch {
434                max_range_m,
435                max_drop_mil,
436            };
437        }
438        // Start the bracket just past the far zero: exactly at it the drop is 0, and any
439        // positive target is therefore bracketed.
440        let mut lo = far_zero_range_m.max(Self::SAMPLE_INTERVAL_M);
441        if drop_at(lo).is_none_or(|drop| drop >= target_mil) {
442            return MarkToRangeOutcome::InsideZero { far_zero_range_m };
443        }
444        let mut hi = max_range_m;
445        for _ in 0..Self::INVERSE_MAX_ITERATIONS {
446            if hi - lo <= Self::INVERSE_TOLERANCE_M {
447                break;
448            }
449            let mid = 0.5 * (lo + hi);
450            match drop_at(mid) {
451                Some(drop) if drop < target_mil => lo = mid,
452                Some(_) => hi = mid,
453                None => break,
454            }
455        }
456        match self.at_range(0.5 * (lo + hi)) {
457            Some(point) => MarkToRangeOutcome::Reached(Box::new(point)),
458            None => MarkToRangeOutcome::BeyondSearch {
459                max_range_m,
460                max_drop_mil,
461            },
462        }
463    }
464}
465
466#[cfg(test)]
467mod tests {
468    use super::*;
469
470    /// A representative metric load (a .308-class 168 gr match bullet at a 100 m zero),
471    /// solved with a nonzero crosswind so both `drop_mil` and `wind_mil` are exercised.
472    fn oracle_test_load() -> HoldCurveLoad {
473        HoldCurveLoad {
474            velocity_mps: 800.0,
475            bc: 0.223,
476            mass_kg: 0.0109,
477            diameter_m: 0.00782,
478            drag_model: DragModel::G7,
479            sight_height_m: 0.045,
480            zero_distance_m: 100.0,
481            temperature_c: 15.0,
482            pressure_hpa: 1013.25,
483            humidity: 50.0,
484            altitude_m: 0.0,
485            wind_speed_mps: 3.0,
486            wind_direction_deg: 90.0,
487        }
488    }
489
490    /// Reproduces exactly what [`HoldCurve::solve`] does internally, as a SEPARATE, from-
491    /// scratch call -- it never reads any `HoldCurve`'s private `samples` field. This gives
492    /// `at_range` an independent direct-solve oracle to be checked against, rather than a
493    /// self-consistency check of `HoldCurve` against its own stored state.
494    fn independent_direct_solve(
495        load: &HoldCurveLoad,
496        max_range_m: f64,
497    ) -> Vec<trajectory_sampling::TrajectorySample> {
498        let zero_inputs = BallisticInputs {
499            bc_value: load.bc,
500            bc_type: load.drag_model,
501            bullet_mass: load.mass_kg,
502            muzzle_velocity: load.velocity_mps,
503            bullet_diameter: load.diameter_m,
504            bullet_length: fallback_bullet_length_m(load.diameter_m, load.mass_kg),
505            sight_height: load.sight_height_m,
506            use_rk4: true,
507            ..Default::default()
508        };
509        let atmosphere = AtmosphericConditions {
510            temperature: load.temperature_c,
511            pressure: load.pressure_hpa,
512            humidity: load.humidity,
513            altitude: load.altitude_m,
514        };
515        let zero_angle = crate::calculate_zero_angle_with_conditions(
516            zero_inputs,
517            load.zero_distance_m,
518            load.sight_height_m,
519            WindConditions::default(),
520            atmosphere,
521        )
522        .expect("zero solve should succeed for a realistic load");
523
524        run_sampled_trajectory(
525            load.velocity_mps,
526            load.bc,
527            load.mass_kg,
528            load.diameter_m,
529            load.drag_model,
530            load.sight_height_m,
531            load.temperature_c,
532            load.pressure_hpa,
533            load.humidity,
534            load.altitude_m,
535            load.wind_speed_mps,
536            load.wind_direction_deg,
537            max_range_m,
538            HoldCurve::SAMPLE_INTERVAL_M,
539            zero_angle,
540            None,
541            None,
542            None,
543            0.0,
544            0.0,
545            0.0,
546            Some(load.zero_distance_m),
547        )
548        .expect("sampled trajectory should succeed for a realistic load")
549    }
550
551    #[test]
552    fn at_range_matches_an_independent_direct_solves_sampled_observation() {
553        let load = oracle_test_load();
554        let max_range_m = 1500.0;
555
556        let hold_curve = HoldCurve::solve(&load, max_range_m).expect("hold curve should solve");
557        let oracle_samples = independent_direct_solve(&load, max_range_m);
558
559        // A probe range read off the oracle's own grid rather than a hand-picked constant --
560        // it only needs to land exactly on a sample node so `at_range`'s bracket search
561        // resolves with t == 1.0 (the exact-endpoint-hit convention Task 7 disclosed: an exact
562        // key hit lands on the upper endpoint of its bracket, not the lower one) and its own
563        // linear interpolation is not itself a source of disagreement with the oracle.
564        let probe = &oracle_samples[oracle_samples.len() / 2];
565        let probe_range_m = probe.distance_m;
566        assert!(probe_range_m > 0.0 && probe_range_m.is_finite());
567
568        let point = hold_curve
569            .at_range(probe_range_m)
570            .expect("probe range must be inside the sampled span");
571
572        let expected_drop_mil = probe.drop_m / probe_range_m * 1000.0;
573        let expected_wind_mil = probe.wind_drift_m / probe_range_m * 1000.0;
574
575        assert!((point.range_m - probe_range_m).abs() < 1e-9);
576        assert!((point.drop_mil - expected_drop_mil).abs() < 1e-9);
577        assert!((point.wind_mil - expected_wind_mil).abs() < 1e-9);
578        assert!((point.velocity_mps - probe.velocity_mps).abs() < 1e-9);
579        assert!((point.energy_j - probe.energy_j).abs() < 1e-9);
580        assert!((point.time_s - probe.time_s).abs() < 1e-9);
581    }
582
583    #[test]
584    fn at_range_outside_the_sampled_span_returns_none() {
585        let load = oracle_test_load();
586        let max_range_m = 1500.0;
587        let hold_curve = HoldCurve::solve(&load, max_range_m).expect("hold curve should solve");
588
589        let beyond = hold_curve.max_sampled_range_m() + 10_000.0;
590        assert_eq!(hold_curve.at_range(beyond), None);
591    }
592
593    /// `sample_ranges_m` must reproduce the exact arithmetic sequence `HoldCurve::solve` built
594    /// internally: index 0 at the muzzle (`0.0`), every later index an exact multiple of
595    /// `SAMPLE_INTERVAL_M`, and the last entry equal to `max_sampled_range_m()`.
596    #[test]
597    fn sample_ranges_m_is_the_exact_multiples_of_the_sample_interval() {
598        let load = oracle_test_load();
599        let hold_curve = HoldCurve::solve(&load, 1500.0).expect("hold curve should solve");
600
601        let ranges = hold_curve.sample_ranges_m();
602        assert!(!ranges.is_empty());
603        assert_eq!(ranges[0], 0.0);
604        assert_eq!(*ranges.last().unwrap(), hold_curve.max_sampled_range_m());
605        for (i, &r) in ranges.iter().enumerate() {
606            let expected = i as f64 * HoldCurve::SAMPLE_INTERVAL_M;
607            assert!(
608                (r - expected).abs() < 1e-9,
609                "index {i}: got {r}, expected {expected} ({} * SAMPLE_INTERVAL_M)",
610                i
611            );
612        }
613    }
614}