Skip to main content

ballistics_engine/
trajectory_observation.rs

1//! Checked, full-state observations over completed trajectories.
2//!
3//! The legacy [`crate::TrajectoryResult::position_at_range`] API intentionally clamps queries
4//! beyond the computed trajectory.  The APIs in this module are for protocol and laboratory
5//! consumers that need explicit range errors, finite values, and an exact terminal sample.
6//!
7//! This module also owns `bracket_param`, the shared bracket-and-lerp search behind every
8//! interpolation site in the crate: hold curves, wind-scenario corridors, reticle holds,
9//! trajectory sampling, and the CLI's equivalent-horizontal-range solver. Each call site keeps
10//! its own out-of-range policy (`None`, clamp-to-end, or a structured error) and its own field
11//! interpolation; this centralizes only the search and the degenerate-interval rule.
12
13use crate::cli_api::{TrajectoryPoint, TrajectoryResult};
14use crate::trajectory_sampling::MAX_TRAJECTORY_SAMPLES;
15use thiserror::Error;
16
17/// Why trajectory integration stopped.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
19pub enum TrajectoryTermination {
20    MaxRange,
21    GroundThreshold,
22    TimeLimit,
23    VelocityFloor,
24}
25
26/// Stable annotations attached to a checked trajectory observation.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
28pub enum TrajectoryObservationFlag {
29    /// Mach is in the conventional inclusive transonic band, `0.8..=1.2`.
30    Transonic,
31    /// Mach is below `1.0`; this intentionally overlaps `Transonic` from `0.8` to `1.0`.
32    Subsonic,
33    /// This is the exact final observation returned by the solver.
34    Terminal,
35    /// The terminal observation is an impact at the configured ground threshold.
36    GroundThreshold,
37}
38
39/// A finite, sight-referenced observation of the complete trajectory state.
40#[derive(Debug, Clone, PartialEq)]
41pub struct TrajectoryObservation {
42    pub distance_m: f64,
43    pub time_s: f64,
44    pub speed_mps: f64,
45    pub energy_j: f64,
46    /// Positive values are below the line of sight.
47    pub drop_m: f64,
48    /// Positive values are right of the line of sight from the shooter's perspective.
49    pub windage_m: f64,
50    pub mach: f64,
51    pub flags: Vec<TrajectoryObservationFlag>,
52}
53
54/// Failure to produce a checked trajectory observation.
55#[derive(Debug, Clone, PartialEq, Error)]
56pub enum TrajectoryObservationError {
57    #[error("trajectory contains no points")]
58    EmptyTrajectory,
59
60    #[error("observation range must be finite (got {distance_m})")]
61    NonFiniteQuery { distance_m: f64 },
62
63    #[error("sample interval must be finite and greater than zero (got {interval_m})")]
64    InvalidInterval { interval_m: f64 },
65
66    #[error(
67        "requested range {requested_m} m is outside the computed trajectory [{minimum_m}, {maximum_m}] m"
68    )]
69    OutOfRange {
70        requested_m: f64,
71        minimum_m: f64,
72        maximum_m: f64,
73    },
74
75    #[error(
76        "trajectory distance is not strictly increasing at point {index}: {previous_distance_m} m then {distance_m} m"
77    )]
78    NonMonotonicTrajectory {
79        index: usize,
80        previous_distance_m: f64,
81        distance_m: f64,
82    },
83
84    #[error("trajectory point {index} contains a non-finite {field}")]
85    NonFiniteState { index: usize, field: &'static str },
86
87    #[error("trajectory point {index} contains an invalid {field} value ({value})")]
88    InvalidState {
89        index: usize,
90        field: &'static str,
91        value: f64,
92    },
93
94    #[error("trajectory metadata field {field} has an invalid value ({value})")]
95    InvalidMetadata { field: &'static str, value: f64 },
96
97    #[error("computed observation field {field} is not finite")]
98    NonFiniteObservation { field: &'static str },
99
100    #[error("trajectory observation limit of {limit} exceeded (would produce {requested})")]
101    SampleLimitExceeded { requested: usize, limit: usize },
102
103    #[error("could not reserve storage for {requested} trajectory observations")]
104    AllocationFailed { requested: usize },
105
106    #[error(
107        "sample interval {interval_m} m cannot produce a strictly increasing distance at grid index {index} ({previous_distance_m} m then {distance_m} m)"
108    )]
109    UnrepresentableGrid {
110        interval_m: f64,
111        index: usize,
112        previous_distance_m: f64,
113        distance_m: f64,
114    },
115}
116
117impl TrajectoryResult {
118    /// Return a checked full-state observation at an in-range downrange distance.
119    ///
120    /// Unlike [`Self::position_at_range`], this method never clamps an out-of-range request to
121    /// the final point.
122    pub fn observation_at_range_checked(
123        &self,
124        distance_m: f64,
125    ) -> Result<TrajectoryObservation, TrajectoryObservationError> {
126        validate_trajectory(self)?;
127        observation_at_range_validated(self, distance_m)
128    }
129
130    /// Sample checked observations on a regular distance grid and include the exact terminal
131    /// point once.
132    ///
133    /// Regular grid points are strictly before the actual reached range.  If the terminal is
134    /// off-grid it is appended; if it is on-grid it appears only as the final terminal sample.
135    /// `max_samples` lets protocol layers enforce a limit smaller than the engine-wide hard cap.
136    pub fn sample_observations(
137        &self,
138        interval_m: f64,
139        max_samples: usize,
140    ) -> Result<Vec<TrajectoryObservation>, TrajectoryObservationError> {
141        validate_trajectory(self)?;
142
143        let effective_limit = max_samples.min(MAX_TRAJECTORY_SAMPLES);
144        let count = projected_observation_count(self, interval_m, effective_limit)?;
145        let mut observations = Vec::new();
146        observations
147            .try_reserve_exact(count)
148            .map_err(|_| TrajectoryObservationError::AllocationFailed { requested: count })?;
149
150        let first_distance = self.points[0].position.x;
151        let terminal_distance = self.points[self.points.len() - 1].position.x;
152        let regular_count = count.saturating_sub(1);
153
154        let mut previous_distance_m = None;
155        for index in 0..regular_count {
156            let distance_m = first_distance + index as f64 * interval_m;
157            if let Some(previous_distance_m) = previous_distance_m {
158                if distance_m <= previous_distance_m {
159                    return Err(TrajectoryObservationError::UnrepresentableGrid {
160                        interval_m,
161                        index,
162                        previous_distance_m,
163                        distance_m,
164                    });
165                }
166            }
167            // The count projection removes a rounded terminal grid point. Refuse any remaining
168            // collision instead of silently thinning the caller's requested grid.
169            if distance_m >= terminal_distance {
170                return Err(TrajectoryObservationError::UnrepresentableGrid {
171                    interval_m,
172                    index,
173                    previous_distance_m: previous_distance_m.unwrap_or(first_distance),
174                    distance_m,
175                });
176            }
177            observations.push(observation_at_range_validated(self, distance_m)?);
178            previous_distance_m = Some(distance_m);
179        }
180
181        // The exact stored endpoint is authoritative.  A rounded regular-grid calculation is
182        // never allowed to replace it or create a repeated terminal observation.
183        if observations
184            .last()
185            .is_some_and(|observation| observation.distance_m == terminal_distance)
186        {
187            if let Some(last) = observations.last_mut() {
188                *last = observation_at_range_validated(self, terminal_distance)?;
189            }
190        } else {
191            observations.push(observation_at_range_validated(self, terminal_distance)?);
192        }
193
194        Ok(observations)
195    }
196}
197
198fn validate_trajectory(result: &TrajectoryResult) -> Result<(), TrajectoryObservationError> {
199    if result.points.is_empty() {
200        return Err(TrajectoryObservationError::EmptyTrajectory);
201    }
202
203    validate_metadata("projectile_mass_kg", result.projectile_mass_kg, |value| {
204        value > 0.0
205    })?;
206    validate_metadata(
207        "line_of_sight_height_m",
208        result.line_of_sight_height_m,
209        |_| true,
210    )?;
211    validate_metadata(
212        "station_speed_of_sound_mps",
213        result.station_speed_of_sound_mps,
214        |value| value > 0.0,
215    )?;
216
217    for (index, point) in result.points.iter().enumerate() {
218        validate_point(index, point)?;
219        if index > 0 {
220            let previous_distance_m = result.points[index - 1].position.x;
221            if point.position.x <= previous_distance_m {
222                return Err(TrajectoryObservationError::NonMonotonicTrajectory {
223                    index,
224                    previous_distance_m,
225                    distance_m: point.position.x,
226                });
227            }
228        }
229    }
230
231    Ok(())
232}
233
234fn validate_metadata(
235    field: &'static str,
236    value: f64,
237    predicate: impl FnOnce(f64) -> bool,
238) -> Result<(), TrajectoryObservationError> {
239    if value.is_finite() && predicate(value) {
240        Ok(())
241    } else {
242        Err(TrajectoryObservationError::InvalidMetadata { field, value })
243    }
244}
245
246fn validate_point(index: usize, point: &TrajectoryPoint) -> Result<(), TrajectoryObservationError> {
247    for (field, value) in [
248        ("time", point.time),
249        ("position.x", point.position.x),
250        ("position.y", point.position.y),
251        ("position.z", point.position.z),
252        ("velocity_magnitude", point.velocity_magnitude),
253        ("kinetic_energy", point.kinetic_energy),
254    ] {
255        if !value.is_finite() {
256            return Err(TrajectoryObservationError::NonFiniteState { index, field });
257        }
258    }
259
260    for (field, value) in [
261        ("time", point.time),
262        ("velocity_magnitude", point.velocity_magnitude),
263        ("kinetic_energy", point.kinetic_energy),
264    ] {
265        if value < 0.0 {
266            return Err(TrajectoryObservationError::InvalidState {
267                index,
268                field,
269                value,
270            });
271        }
272    }
273
274    Ok(())
275}
276
277fn observation_at_range_validated(
278    result: &TrajectoryResult,
279    distance_m: f64,
280) -> Result<TrajectoryObservation, TrajectoryObservationError> {
281    if !distance_m.is_finite() {
282        return Err(TrajectoryObservationError::NonFiniteQuery { distance_m });
283    }
284
285    let minimum_m = result.points[0].position.x;
286    let maximum_m = result.points[result.points.len() - 1].position.x;
287    if distance_m < minimum_m || distance_m > maximum_m {
288        return Err(TrajectoryObservationError::OutOfRange {
289            requested_m: distance_m,
290            minimum_m,
291            maximum_m,
292        });
293    }
294
295    let upper_index = result
296        .points
297        .partition_point(|point| point.position.x < distance_m);
298
299    let (time_s, vertical_m, windage_m, speed_mps) = if upper_index < result.points.len()
300        && result.points[upper_index].position.x == distance_m
301    {
302        let point = &result.points[upper_index];
303        (
304            point.time,
305            point.position.y,
306            point.position.z,
307            point.velocity_magnitude,
308        )
309    } else {
310        // In-range non-exact observations necessarily have a point on each side.
311        let lower = &result.points[upper_index - 1];
312        let upper = &result.points[upper_index];
313        let bracket_span_m = upper.position.x - lower.position.x;
314        require_finite_observation("interpolation_span_m", bracket_span_m)?;
315        let bracket_offset_m = distance_m - lower.position.x;
316        require_finite_observation("interpolation_offset_m", bracket_offset_m)?;
317        let alpha = bracket_offset_m / bracket_span_m;
318        require_finite_observation("interpolation_fraction", alpha)?;
319        (
320            checked_lerp(lower.time, upper.time, alpha, "time_s")?,
321            checked_lerp(lower.position.y, upper.position.y, alpha, "drop_m")?,
322            checked_lerp(lower.position.z, upper.position.z, alpha, "windage_m")?,
323            checked_lerp(
324                lower.velocity_magnitude,
325                upper.velocity_magnitude,
326                alpha,
327                "speed_mps",
328            )?,
329        )
330    };
331
332    let energy_j = 0.5 * result.projectile_mass_kg * speed_mps * speed_mps;
333    require_finite_observation("energy_j", energy_j)?;
334    let drop_m = result.line_of_sight_height_m - vertical_m;
335    require_finite_observation("drop_m", drop_m)?;
336    let mach = speed_mps / result.station_speed_of_sound_mps;
337    require_finite_observation("mach", mach)?;
338
339    for (field, value) in [
340        ("distance_m", distance_m),
341        ("time_s", time_s),
342        ("speed_mps", speed_mps),
343        ("windage_m", windage_m),
344    ] {
345        require_finite_observation(field, value)?;
346    }
347
348    let terminal = distance_m == maximum_m;
349    let mut flags = Vec::with_capacity(4);
350    if (0.8..=1.2).contains(&mach) {
351        flags.push(TrajectoryObservationFlag::Transonic);
352    }
353    if mach < 1.0 {
354        flags.push(TrajectoryObservationFlag::Subsonic);
355    }
356    if terminal {
357        flags.push(TrajectoryObservationFlag::Terminal);
358        if result.termination == TrajectoryTermination::GroundThreshold {
359            flags.push(TrajectoryObservationFlag::GroundThreshold);
360        }
361    }
362
363    Ok(TrajectoryObservation {
364        distance_m,
365        time_s,
366        speed_mps,
367        energy_j,
368        drop_m,
369        windage_m,
370        mach,
371        flags,
372    })
373}
374
375fn checked_lerp(
376    lower: f64,
377    upper: f64,
378    alpha: f64,
379    field: &'static str,
380) -> Result<f64, TrajectoryObservationError> {
381    let value = lower + alpha * (upper - lower);
382    require_finite_observation(field, value)?;
383    Ok(value)
384}
385
386fn require_finite_observation(
387    field: &'static str,
388    value: f64,
389) -> Result<(), TrajectoryObservationError> {
390    if value.is_finite() {
391        Ok(())
392    } else {
393        Err(TrajectoryObservationError::NonFiniteObservation { field })
394    }
395}
396
397fn projected_observation_count(
398    result: &TrajectoryResult,
399    interval_m: f64,
400    limit: usize,
401) -> Result<usize, TrajectoryObservationError> {
402    if !interval_m.is_finite() || interval_m <= 0.0 {
403        return Err(TrajectoryObservationError::InvalidInterval { interval_m });
404    }
405
406    let first_distance = result.points[0].position.x;
407    let terminal_distance = result.points[result.points.len() - 1].position.x;
408    let span = terminal_distance - first_distance;
409    if !span.is_finite() {
410        return Err(TrajectoryObservationError::NonFiniteObservation {
411            field: "trajectory_span_m",
412        });
413    }
414
415    let regular_count_f64 = if span > 0.0 {
416        (span / interval_m).ceil().max(1.0)
417    } else {
418        0.0
419    };
420    if !regular_count_f64.is_finite() || regular_count_f64 > usize::MAX as f64 {
421        return Err(TrajectoryObservationError::SampleLimitExceeded {
422            requested: usize::MAX,
423            limit,
424        });
425    }
426
427    // Bound work before correcting a possible one-ULP quotient overshoot. In particular, never
428    // walk a huge projected count down one element at a time: a finite sub-ULP interval at a
429    // large nonzero starting distance can otherwise turn this check into an unbounded loop.
430    if regular_count_f64 > limit as f64 {
431        let requested = if regular_count_f64 >= usize::MAX as f64 {
432            usize::MAX
433        } else {
434            (regular_count_f64 as usize).saturating_add(1)
435        };
436        return Err(TrajectoryObservationError::SampleLimitExceeded { requested, limit });
437    }
438
439    let mut regular_count = regular_count_f64 as usize;
440    // `ceil(span / interval)` can round upward when a mathematically on-grid terminal is
441    // represented just above an integer quotient.  Count only generated points that are
442    // strictly before the authoritative endpoint.
443    if regular_count > 0 {
444        let last_regular = first_distance + (regular_count - 1) as f64 * interval_m;
445        if last_regular >= terminal_distance {
446            regular_count -= 1;
447        }
448    }
449    // Division can also round a quotient just above an integer down to that integer. Probe the
450    // next grid point once so that a representable point strictly before the terminal is not
451    // omitted. With the count already bounded by `limit` (and therefore exactly representable as
452    // f64), one correction in either direction covers the quotient's possible rounding error.
453    let next_regular = first_distance + regular_count as f64 * interval_m;
454    if next_regular < terminal_distance {
455        regular_count = regular_count.saturating_add(1);
456    }
457
458    let requested = regular_count.saturating_add(1);
459    if requested > limit {
460        Err(TrajectoryObservationError::SampleLimitExceeded { requested, limit })
461    } else {
462        Ok(requested)
463    }
464}
465
466/// Bracket `x` in a monotonically non-decreasing key sequence of `len` elements, addressed
467/// through `key_at(index)` rather than a concrete slice so every interpolation site can share
468/// one search over its own sample type -- a hold curve's samples, a raw `f64` array, or
469/// trajectory points.
470///
471/// Out-of-range POLICY is the caller's: the five call sites this centralizes deliberately
472/// differ (`None` vs. clamp-to-end vs. a structured error) and those differences are preserved
473/// AT the call sites, not here.
474///
475/// `pub(crate)`, not `pub`: every call site this centralizes is itself in-crate --
476/// `wind_scenarios`, `solve_v1`, `trajectory_sampling`, `cli_api`, and `hold_curve`. The CLI
477/// binary (`src/main.rs`) used to be a direct fifth call site; that call moved into
478/// `HoldCurve::at_range` in the library, so `src/main.rs` now reaches this only through that
479/// public method and has zero remaining references to `Bracket` or `bracket_param`. Nothing
480/// outside this crate needs either item, so both stay crate-private.
481///
482/// `x = NaN` is accepted, not rejected: it compares `false` against every key, so both the
483/// `Below`/`Above` bounds checks and the partition-point search fall through to index `0`,
484/// and NaN then propagates through the interpolation-fraction division -- the result is
485/// `Inside { lo: 0, t: NaN }`, never `Below`/`Above`/`Degenerate`. Callers that must reject
486/// non-finite input validate before calling; this function does not.
487#[derive(Debug, Clone, Copy, PartialEq)]
488pub(crate) enum Bracket {
489    /// `x` is within `[key_at(0), key_at(len - 1)]`. The bracket is `(lo, lo + 1)` and `t` is
490    /// the interpolation fraction in `[0, 1]`: `t == 0.0` reuses `key_at(lo)` exactly and
491    /// `t == 1.0` reuses `key_at(lo + 1)` exactly.
492    Inside { lo: usize, t: f64 },
493    /// `x` is below `key_at(0)`.
494    Below,
495    /// `x` is above `key_at(len - 1)`.
496    Above,
497    /// Fewer than two keys -- no interval exists to bracket.
498    Degenerate,
499}
500
501/// Find where `x` falls among `len` keys read through `key_at`. See [`Bracket`] for what each
502/// arm means and who is responsible for handling it.
503///
504/// The degenerate-interval rule: when the bracketed interval's width (`key_at(lo + 1) -
505/// key_at(lo)`) is non-finite or not strictly positive -- equal adjacent keys, or a locally
506/// non-monotonic or non-finite pair -- `t` is `0.0` and the lower point is reused rather than
507/// dividing by a zero or non-finite span.
508pub(crate) fn bracket_param(len: usize, key_at: impl Fn(usize) -> f64, x: f64) -> Bracket {
509    if len < 2 {
510        return Bracket::Degenerate;
511    }
512    let first = key_at(0);
513    let last = key_at(len - 1);
514    if x < first {
515        return Bracket::Below;
516    }
517    if x > last {
518        return Bracket::Above;
519    }
520
521    // `partition_point` over a virtual `len`-element sequence: the smallest index whose key is
522    // not less than `x`. Bounded to `len - 1` because `x <= last` was just established, so
523    // `key_at(len - 1)` always satisfies "not less than x" and the search cannot run off the end.
524    let mut lo_bound = 0usize;
525    let mut hi_bound = len;
526    while lo_bound < hi_bound {
527        let mid = lo_bound + (hi_bound - lo_bound) / 2;
528        if key_at(mid) < x {
529            lo_bound = mid + 1;
530        } else {
531            hi_bound = mid;
532        }
533    }
534    let index = lo_bound.min(len - 1);
535    let lo = index.saturating_sub(1);
536    let dx = key_at(lo + 1) - key_at(lo);
537    let t = if dx.is_finite() && dx > 0.0 {
538        (x - key_at(lo)) / dx
539    } else {
540        0.0
541    };
542    Bracket::Inside { lo, t }
543}
544
545#[cfg(test)]
546mod tests {
547    use super::*;
548    use approx::assert_relative_eq;
549    use nalgebra::Vector3;
550
551    fn point(time: f64, x: f64, y: f64, z: f64, speed: f64) -> TrajectoryPoint {
552        TrajectoryPoint {
553            time,
554            position: Vector3::new(x, y, z),
555            velocity_magnitude: speed,
556            kinetic_energy: 0.5 * 0.02 * speed * speed,
557            drag_coefficient: None,
558        }
559    }
560
561    fn result(
562        points: Vec<TrajectoryPoint>,
563        termination: TrajectoryTermination,
564    ) -> TrajectoryResult {
565        let terminal = points.last().expect("test trajectory must not be empty");
566        TrajectoryResult {
567            max_range: terminal.position.x,
568            max_height: points
569                .iter()
570                .map(|point| point.position.y)
571                .fold(f64::NEG_INFINITY, f64::max),
572            time_of_flight: terminal.time,
573            impact_velocity: terminal.velocity_magnitude,
574            impact_energy: terminal.kinetic_energy,
575            projectile_mass_kg: 0.02,
576            line_of_sight_height_m: 1.0,
577            station_speed_of_sound_mps: 340.0,
578            termination,
579            points,
580            sampled_points: None,
581            min_pitch_damping: None,
582            transonic_mach: None,
583            angular_state: None,
584            max_yaw_angle: None,
585            max_precession_angle: None,
586            aerodynamic_jump: None,
587            mach_1_2_distance_m: None,
588            mach_1_0_distance_m: None,
589            mach_0_9_distance_m: None,
590        }
591    }
592
593    fn synthetic_result() -> TrajectoryResult {
594        result(
595            vec![
596                point(0.0, 0.0, 0.5, -0.4, 680.0),
597                point(2.0, 100.0, 1.5, 0.4, 340.0),
598            ],
599            TrajectoryTermination::MaxRange,
600        )
601    }
602
603    #[test]
604    fn interpolates_full_state_with_documented_drop_and_windage_signs() {
605        let trajectory = synthetic_result();
606
607        let first = trajectory
608            .observation_at_range_checked(25.0)
609            .expect("in-range observation");
610        assert_relative_eq!(first.time_s, 0.5);
611        assert_relative_eq!(first.speed_mps, 595.0);
612        assert_relative_eq!(first.energy_j, 3540.25);
613        assert_relative_eq!(first.drop_m, 0.25);
614        assert_relative_eq!(first.windage_m, -0.2);
615        assert_relative_eq!(first.mach, 1.75);
616
617        let second = trajectory
618            .observation_at_range_checked(75.0)
619            .expect("in-range observation");
620        assert_relative_eq!(second.drop_m, -0.25);
621        assert_relative_eq!(second.windage_m, 0.2);
622    }
623
624    #[test]
625    fn preserves_exact_endpoints_and_marks_only_the_terminal_endpoint() {
626        let trajectory = synthetic_result();
627
628        let muzzle = trajectory
629            .observation_at_range_checked(0.0)
630            .expect("muzzle endpoint");
631        assert_eq!(muzzle.time_s, 0.0);
632        assert_eq!(muzzle.speed_mps, 680.0);
633        assert!(!muzzle.flags.contains(&TrajectoryObservationFlag::Terminal));
634
635        let terminal = trajectory
636            .observation_at_range_checked(100.0)
637            .expect("terminal endpoint");
638        assert_eq!(terminal.time_s, 2.0);
639        assert_eq!(terminal.speed_mps, 340.0);
640        assert_eq!(terminal.drop_m, -0.5);
641        assert_eq!(terminal.windage_m, 0.4);
642        assert!(terminal
643            .flags
644            .contains(&TrajectoryObservationFlag::Transonic));
645        assert!(terminal
646            .flags
647            .contains(&TrajectoryObservationFlag::Terminal));
648    }
649
650    #[test]
651    fn rejects_out_of_range_and_non_finite_queries_instead_of_clamping() {
652        let trajectory = synthetic_result();
653
654        for distance_m in [-0.001, 100.001] {
655            assert!(matches!(
656                trajectory.observation_at_range_checked(distance_m),
657                Err(TrajectoryObservationError::OutOfRange { .. })
658            ));
659        }
660        for distance_m in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
661            assert!(matches!(
662                trajectory.observation_at_range_checked(distance_m),
663                Err(TrajectoryObservationError::NonFiniteQuery { .. })
664            ));
665        }
666
667        // The established convenience API retains its historical clamping behavior.
668        assert_eq!(
669            trajectory.position_at_range(101.0),
670            Some(Vector3::new(100.0, 1.5, 0.4))
671        );
672    }
673
674    #[test]
675    fn regular_grid_appends_an_off_grid_terminal_and_deduplicates_an_on_grid_terminal() {
676        let off_grid = result(
677            vec![
678                point(0.0, 0.0, 1.0, 0.0, 500.0),
679                point(1.0, 95.0, 0.5, 0.0, 400.0),
680            ],
681            TrajectoryTermination::GroundThreshold,
682        );
683        let samples = off_grid
684            .sample_observations(30.0, 10)
685            .expect("off-grid samples");
686        assert_eq!(
687            samples
688                .iter()
689                .map(|sample| sample.distance_m)
690                .collect::<Vec<_>>(),
691            vec![0.0, 30.0, 60.0, 90.0, 95.0]
692        );
693        let terminal = samples.last().expect("terminal sample");
694        assert!(terminal
695            .flags
696            .contains(&TrajectoryObservationFlag::Terminal));
697        assert!(terminal
698            .flags
699            .contains(&TrajectoryObservationFlag::GroundThreshold));
700
701        let on_grid = result(
702            vec![
703                point(0.0, 0.0, 1.0, 0.0, 500.0),
704                point(1.0, 90.0, 0.5, 0.0, 400.0),
705            ],
706            TrajectoryTermination::MaxRange,
707        );
708        let samples = on_grid
709            .sample_observations(30.0, 10)
710            .expect("on-grid samples");
711        assert_eq!(
712            samples
713                .iter()
714                .map(|sample| sample.distance_m)
715                .collect::<Vec<_>>(),
716            vec![0.0, 30.0, 60.0, 90.0]
717        );
718
719        let fractional = result(
720            vec![
721                point(0.0, 0.0, 1.0, 0.0, 500.0),
722                point(1.0, 0.15, 0.5, 0.0, 400.0),
723            ],
724            TrajectoryTermination::MaxRange,
725        );
726        assert_eq!(
727            fractional
728                .sample_observations(0.1, 10)
729                .expect("fractional samples")
730                .iter()
731                .map(|sample| sample.distance_m)
732                .collect::<Vec<_>>(),
733            vec![0.0, 0.1, 0.15]
734        );
735
736        let rounded_grid_point = 3.0_f64 * 0.3;
737        let just_past_grid = f64::from_bits(rounded_grid_point.to_bits() + 1);
738        let rounded_quotient = result(
739            vec![
740                point(0.0, 0.0, 1.0, 0.0, 500.0),
741                point(1.0, just_past_grid, 0.5, 0.0, 400.0),
742            ],
743            TrajectoryTermination::MaxRange,
744        );
745        assert_eq!(
746            rounded_quotient
747                .sample_observations(0.3, 5)
748                .expect("a rounded quotient retains the last regular grid point")
749                .iter()
750                .map(|sample| sample.distance_m.to_bits())
751                .collect::<Vec<_>>(),
752            [0.0, 0.3, 0.6, rounded_grid_point, just_past_grid]
753                .map(f64::to_bits)
754                .to_vec()
755        );
756    }
757
758    #[test]
759    fn rejects_non_finite_state_metadata_and_derived_values() {
760        let mut non_finite_state = synthetic_result();
761        non_finite_state.points[1].position.y = f64::NAN;
762        assert!(matches!(
763            non_finite_state.observation_at_range_checked(50.0),
764            Err(TrajectoryObservationError::NonFiniteState {
765                index: 1,
766                field: "position.y"
767            })
768        ));
769
770        let mut invalid_metadata = synthetic_result();
771        invalid_metadata.station_speed_of_sound_mps = 0.0;
772        assert!(matches!(
773            invalid_metadata.observation_at_range_checked(50.0),
774            Err(TrajectoryObservationError::InvalidMetadata {
775                field: "station_speed_of_sound_mps",
776                ..
777            })
778        ));
779
780        let mut overflowing_energy = synthetic_result();
781        overflowing_energy.points[0].velocity_magnitude = f64::MAX;
782        overflowing_energy.points[0].kinetic_energy = f64::MAX;
783        assert!(matches!(
784            overflowing_energy.observation_at_range_checked(0.0),
785            Err(TrajectoryObservationError::NonFiniteObservation { field: "energy_j" })
786        ));
787
788        let overflowing_bracket = result(
789            vec![
790                point(0.0, -f64::MAX, 1.0, 0.0, 500.0),
791                point(1.0, f64::MAX, 0.5, 0.0, 400.0),
792            ],
793            TrajectoryTermination::MaxRange,
794        );
795        assert!(matches!(
796            overflowing_bracket.observation_at_range_checked(0.0),
797            Err(TrajectoryObservationError::NonFiniteObservation {
798                field: "interpolation_span_m"
799            })
800        ));
801    }
802
803    #[test]
804    fn enforces_caller_and_engine_sample_caps_before_allocation() {
805        let small = result(
806            vec![
807                point(0.0, 0.0, 1.0, 0.0, 500.0),
808                point(1.0, 4.0, 0.5, 0.0, 400.0),
809            ],
810            TrajectoryTermination::MaxRange,
811        );
812        assert_eq!(
813            small
814                .sample_observations(1.0, 5)
815                .expect("exact caller limit")
816                .len(),
817            5
818        );
819        assert!(matches!(
820            small.sample_observations(1.0, 4),
821            Err(TrajectoryObservationError::SampleLimitExceeded {
822                requested: 5,
823                limit: 4
824            })
825        ));
826
827        let at_engine_limit = result(
828            vec![
829                point(0.0, 0.0, 1.0, 0.0, 500.0),
830                point(1.0, (MAX_TRAJECTORY_SAMPLES - 1) as f64, 0.5, 0.0, 400.0),
831            ],
832            TrajectoryTermination::MaxRange,
833        );
834        assert_eq!(
835            projected_observation_count(&at_engine_limit, 1.0, MAX_TRAJECTORY_SAMPLES),
836            Ok(MAX_TRAJECTORY_SAMPLES)
837        );
838
839        let above_engine_limit = result(
840            vec![
841                point(0.0, 0.0, 1.0, 0.0, 500.0),
842                point(1.0, MAX_TRAJECTORY_SAMPLES as f64, 0.5, 0.0, 400.0),
843            ],
844            TrajectoryTermination::MaxRange,
845        );
846        assert!(matches!(
847            projected_observation_count(
848                &above_engine_limit,
849                1.0,
850                MAX_TRAJECTORY_SAMPLES
851            ),
852            Err(TrajectoryObservationError::SampleLimitExceeded {
853                requested,
854                limit: MAX_TRAJECTORY_SAMPLES
855            }) if requested == MAX_TRAJECTORY_SAMPLES + 1
856        ));
857    }
858
859    #[test]
860    fn rejects_huge_or_unrepresentable_grids_in_bounded_work() {
861        let first_distance = 1.0e300_f64;
862        let terminal_distance = f64::from_bits(first_distance.to_bits() + 1);
863        let span = terminal_distance - first_distance;
864        let trajectory = result(
865            vec![
866                point(0.0, first_distance, 1.0, 0.0, 500.0),
867                point(1.0, terminal_distance, 0.5, 0.0, 400.0),
868            ],
869            TrajectoryTermination::MaxRange,
870        );
871
872        assert!(matches!(
873            trajectory.sample_observations(span / 1.0e15, MAX_TRAJECTORY_SAMPLES),
874            Err(TrajectoryObservationError::SampleLimitExceeded { .. })
875        ));
876        assert!(matches!(
877            trajectory.sample_observations(span / 10.0, 20),
878            Err(TrajectoryObservationError::UnrepresentableGrid { index: 1, .. })
879        ));
880    }
881
882    #[test]
883    fn interval_ratio_underflow_still_includes_both_endpoints() {
884        let terminal_distance = f64::from_bits(1);
885        let trajectory = result(
886            vec![
887                point(0.0, 0.0, 1.0, 0.0, 500.0),
888                point(1.0, terminal_distance, 0.5, 0.0, 400.0),
889            ],
890            TrajectoryTermination::MaxRange,
891        );
892
893        let observations = trajectory
894            .sample_observations(f64::MAX, 2)
895            .expect("a positive span retains its first and terminal observations");
896        assert_eq!(observations.len(), 2);
897        assert_eq!(observations[0].distance_m.to_bits(), 0.0_f64.to_bits());
898        assert_eq!(
899            observations[1].distance_m.to_bits(),
900            terminal_distance.to_bits()
901        );
902    }
903
904    #[test]
905    fn rejects_duplicate_or_reversing_distances() {
906        for terminal_x in [0.0, -1.0] {
907            let trajectory = result(
908                vec![
909                    point(0.0, 0.0, 1.0, 0.0, 500.0),
910                    point(1.0, terminal_x, 0.5, 0.0, 400.0),
911                ],
912                TrajectoryTermination::MaxRange,
913            );
914            assert!(matches!(
915                trajectory.observation_at_range_checked(0.0),
916                Err(TrajectoryObservationError::NonMonotonicTrajectory { .. })
917            ));
918        }
919    }
920
921    // -- bracket_param -------------------------------------------------------------------
922
923    #[test]
924    fn bracket_param_is_degenerate_below_two_keys() {
925        // `key_at` must not even be called when len < 2 -- there is no interval to bracket.
926        let unreachable = |_: usize| -> f64 { panic!("key_at must not be called when len < 2") };
927        assert_eq!(bracket_param(0, unreachable, 5.0), Bracket::Degenerate);
928        assert_eq!(bracket_param(1, unreachable, 5.0), Bracket::Degenerate);
929        assert_eq!(bracket_param(1, unreachable, 0.0), Bracket::Degenerate);
930    }
931
932    #[test]
933    fn bracket_param_is_below_or_above_the_key_span() {
934        let keys = [10.0, 20.0, 30.0];
935        assert_eq!(bracket_param(3, |i| keys[i], 9.999), Bracket::Below);
936        assert_eq!(bracket_param(3, |i| keys[i], 30.001), Bracket::Above);
937        // The span endpoints themselves are Inside, not Below/Above -- see the
938        // exact-endpoint-hits test below.
939    }
940
941    #[test]
942    fn bracket_param_pins_exact_endpoint_hits() {
943        let keys = [0.0, 10.0, 20.0, 30.0];
944        assert_eq!(
945            bracket_param(4, |i| keys[i], 0.0),
946            Bracket::Inside { lo: 0, t: 0.0 }
947        );
948        assert_eq!(
949            bracket_param(4, |i| keys[i], 30.0),
950            Bracket::Inside { lo: 2, t: 1.0 } // lo == len - 2
951        );
952    }
953
954    #[test]
955    fn bracket_param_interpolates_an_interior_point() {
956        let keys = [0.0, 10.0, 20.0, 30.0];
957        assert_eq!(
958            bracket_param(4, |i| keys[i], 15.0),
959            Bracket::Inside { lo: 1, t: 0.5 }
960        );
961    }
962
963    #[test]
964    fn bracket_param_degenerate_dx_reuses_the_lower_point() {
965        // A non-finite key at the bracket forces t = 0.0 even though `x` is not itself equal
966        // to the lower key -- this isolates the degenerate-dx rule from the exact-endpoint case.
967        let nan_keys = [0.0, f64::NAN, 20.0];
968        assert_eq!(
969            bracket_param(3, |i| nan_keys[i], 10.0),
970            Bracket::Inside { lo: 0, t: 0.0 }
971        );
972        let inf_keys = [0.0, f64::INFINITY];
973        assert_eq!(
974            bracket_param(2, |i| inf_keys[i], 50.0),
975            Bracket::Inside { lo: 0, t: 0.0 }
976        );
977
978        // Equal adjacent keys (dx == 0.0 exactly): without the guard, t would be 0.0 / 0.0 ==
979        // NaN. A duplicate run can only be entered at its first occurrence -- the search always
980        // returns the leftmost index whose key is >= x, so this case necessarily coincides with
981        // x == first (unlike the two cases above, where x is strictly inside the span).
982        let equal_keys = [5.0, 5.0, 10.0];
983        assert_eq!(
984            bracket_param(3, |i| equal_keys[i], 5.0),
985            Bracket::Inside { lo: 0, t: 0.0 }
986        );
987    }
988
989    /// Documented on `bracket_param` itself: a NaN `x` is not rejected, it falls through to
990    /// `Inside { lo: 0, t: NaN }`. `Bracket` derives `PartialEq`, but `assert_eq!` against a
991    /// NaN-carrying value would always fail (`NaN != NaN`), so this pins it with an explicit
992    /// `is_nan()` check instead.
993    #[test]
994    fn bracket_param_of_nan_x_returns_inside_lo_zero_with_nan_t() {
995        let keys = [0.0, 10.0, 20.0];
996        match bracket_param(3, |i| keys[i], f64::NAN) {
997            Bracket::Inside { lo, t } => {
998                assert_eq!(lo, 0);
999                assert!(t.is_nan(), "expected t to be NaN, got {t}");
1000            }
1001            other => panic!("expected Inside {{ lo: 0, t: NaN }}, got {other:?}"),
1002        }
1003    }
1004}