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}