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