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
7use crate::cli_api::{TrajectoryPoint, TrajectoryResult};
8use crate::trajectory_sampling::MAX_TRAJECTORY_SAMPLES;
9use thiserror::Error;
10
11/// Why trajectory integration stopped.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
13pub enum TrajectoryTermination {
14    MaxRange,
15    GroundThreshold,
16    TimeLimit,
17    VelocityFloor,
18}
19
20/// Stable annotations attached to a checked trajectory observation.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
22pub enum TrajectoryObservationFlag {
23    /// Mach is in the conventional inclusive transonic band, `0.8..=1.2`.
24    Transonic,
25    /// Mach is below `1.0`; this intentionally overlaps `Transonic` from `0.8` to `1.0`.
26    Subsonic,
27    /// This is the exact final observation returned by the solver.
28    Terminal,
29    /// The terminal observation is an impact at the configured ground threshold.
30    GroundThreshold,
31}
32
33/// A finite, sight-referenced observation of the complete trajectory state.
34#[derive(Debug, Clone, PartialEq)]
35pub struct TrajectoryObservation {
36    pub distance_m: f64,
37    pub time_s: f64,
38    pub speed_mps: f64,
39    pub energy_j: f64,
40    /// Positive values are below the line of sight.
41    pub drop_m: f64,
42    /// Positive values are right of the line of sight from the shooter's perspective.
43    pub windage_m: f64,
44    pub mach: f64,
45    pub flags: Vec<TrajectoryObservationFlag>,
46}
47
48/// Failure to produce a checked trajectory observation.
49#[derive(Debug, Clone, PartialEq, Error)]
50pub enum TrajectoryObservationError {
51    #[error("trajectory contains no points")]
52    EmptyTrajectory,
53
54    #[error("observation range must be finite (got {distance_m})")]
55    NonFiniteQuery { distance_m: f64 },
56
57    #[error("sample interval must be finite and greater than zero (got {interval_m})")]
58    InvalidInterval { interval_m: f64 },
59
60    #[error(
61        "requested range {requested_m} m is outside the computed trajectory [{minimum_m}, {maximum_m}] m"
62    )]
63    OutOfRange {
64        requested_m: f64,
65        minimum_m: f64,
66        maximum_m: f64,
67    },
68
69    #[error(
70        "trajectory distance is not strictly increasing at point {index}: {previous_distance_m} m then {distance_m} m"
71    )]
72    NonMonotonicTrajectory {
73        index: usize,
74        previous_distance_m: f64,
75        distance_m: f64,
76    },
77
78    #[error("trajectory point {index} contains a non-finite {field}")]
79    NonFiniteState { index: usize, field: &'static str },
80
81    #[error("trajectory point {index} contains an invalid {field} value ({value})")]
82    InvalidState {
83        index: usize,
84        field: &'static str,
85        value: f64,
86    },
87
88    #[error("trajectory metadata field {field} has an invalid value ({value})")]
89    InvalidMetadata { field: &'static str, value: f64 },
90
91    #[error("computed observation field {field} is not finite")]
92    NonFiniteObservation { field: &'static str },
93
94    #[error("trajectory observation limit of {limit} exceeded (would produce {requested})")]
95    SampleLimitExceeded { requested: usize, limit: usize },
96
97    #[error("could not reserve storage for {requested} trajectory observations")]
98    AllocationFailed { requested: usize },
99
100    #[error(
101        "sample interval {interval_m} m cannot produce a strictly increasing distance at grid index {index} ({previous_distance_m} m then {distance_m} m)"
102    )]
103    UnrepresentableGrid {
104        interval_m: f64,
105        index: usize,
106        previous_distance_m: f64,
107        distance_m: f64,
108    },
109}
110
111impl TrajectoryResult {
112    /// Return a checked full-state observation at an in-range downrange distance.
113    ///
114    /// Unlike [`Self::position_at_range`], this method never clamps an out-of-range request to
115    /// the final point.
116    pub fn observation_at_range_checked(
117        &self,
118        distance_m: f64,
119    ) -> Result<TrajectoryObservation, TrajectoryObservationError> {
120        validate_trajectory(self)?;
121        observation_at_range_validated(self, distance_m)
122    }
123
124    /// Sample checked observations on a regular distance grid and include the exact terminal
125    /// point once.
126    ///
127    /// Regular grid points are strictly before the actual reached range.  If the terminal is
128    /// off-grid it is appended; if it is on-grid it appears only as the final terminal sample.
129    /// `max_samples` lets protocol layers enforce a limit smaller than the engine-wide hard cap.
130    pub fn sample_observations(
131        &self,
132        interval_m: f64,
133        max_samples: usize,
134    ) -> Result<Vec<TrajectoryObservation>, TrajectoryObservationError> {
135        validate_trajectory(self)?;
136
137        let effective_limit = max_samples.min(MAX_TRAJECTORY_SAMPLES);
138        let count = projected_observation_count(self, interval_m, effective_limit)?;
139        let mut observations = Vec::new();
140        observations
141            .try_reserve_exact(count)
142            .map_err(|_| TrajectoryObservationError::AllocationFailed { requested: count })?;
143
144        let first_distance = self.points[0].position.x;
145        let terminal_distance = self.points[self.points.len() - 1].position.x;
146        let regular_count = count.saturating_sub(1);
147
148        let mut previous_distance_m = None;
149        for index in 0..regular_count {
150            let distance_m = first_distance + index as f64 * interval_m;
151            if let Some(previous_distance_m) = previous_distance_m {
152                if distance_m <= previous_distance_m {
153                    return Err(TrajectoryObservationError::UnrepresentableGrid {
154                        interval_m,
155                        index,
156                        previous_distance_m,
157                        distance_m,
158                    });
159                }
160            }
161            // The count projection removes a rounded terminal grid point. Refuse any remaining
162            // collision instead of silently thinning the caller's requested grid.
163            if distance_m >= terminal_distance {
164                return Err(TrajectoryObservationError::UnrepresentableGrid {
165                    interval_m,
166                    index,
167                    previous_distance_m: previous_distance_m.unwrap_or(first_distance),
168                    distance_m,
169                });
170            }
171            observations.push(observation_at_range_validated(self, distance_m)?);
172            previous_distance_m = Some(distance_m);
173        }
174
175        // The exact stored endpoint is authoritative.  A rounded regular-grid calculation is
176        // never allowed to replace it or create a repeated terminal observation.
177        if observations
178            .last()
179            .is_some_and(|observation| observation.distance_m == terminal_distance)
180        {
181            if let Some(last) = observations.last_mut() {
182                *last = observation_at_range_validated(self, terminal_distance)?;
183            }
184        } else {
185            observations.push(observation_at_range_validated(self, terminal_distance)?);
186        }
187
188        Ok(observations)
189    }
190}
191
192fn validate_trajectory(result: &TrajectoryResult) -> Result<(), TrajectoryObservationError> {
193    if result.points.is_empty() {
194        return Err(TrajectoryObservationError::EmptyTrajectory);
195    }
196
197    validate_metadata("projectile_mass_kg", result.projectile_mass_kg, |value| {
198        value > 0.0
199    })?;
200    validate_metadata(
201        "line_of_sight_height_m",
202        result.line_of_sight_height_m,
203        |_| true,
204    )?;
205    validate_metadata(
206        "station_speed_of_sound_mps",
207        result.station_speed_of_sound_mps,
208        |value| value > 0.0,
209    )?;
210
211    for (index, point) in result.points.iter().enumerate() {
212        validate_point(index, point)?;
213        if index > 0 {
214            let previous_distance_m = result.points[index - 1].position.x;
215            if point.position.x <= previous_distance_m {
216                return Err(TrajectoryObservationError::NonMonotonicTrajectory {
217                    index,
218                    previous_distance_m,
219                    distance_m: point.position.x,
220                });
221            }
222        }
223    }
224
225    Ok(())
226}
227
228fn validate_metadata(
229    field: &'static str,
230    value: f64,
231    predicate: impl FnOnce(f64) -> bool,
232) -> Result<(), TrajectoryObservationError> {
233    if value.is_finite() && predicate(value) {
234        Ok(())
235    } else {
236        Err(TrajectoryObservationError::InvalidMetadata { field, value })
237    }
238}
239
240fn validate_point(index: usize, point: &TrajectoryPoint) -> Result<(), TrajectoryObservationError> {
241    for (field, value) in [
242        ("time", point.time),
243        ("position.x", point.position.x),
244        ("position.y", point.position.y),
245        ("position.z", point.position.z),
246        ("velocity_magnitude", point.velocity_magnitude),
247        ("kinetic_energy", point.kinetic_energy),
248    ] {
249        if !value.is_finite() {
250            return Err(TrajectoryObservationError::NonFiniteState { index, field });
251        }
252    }
253
254    for (field, value) in [
255        ("time", point.time),
256        ("velocity_magnitude", point.velocity_magnitude),
257        ("kinetic_energy", point.kinetic_energy),
258    ] {
259        if value < 0.0 {
260            return Err(TrajectoryObservationError::InvalidState {
261                index,
262                field,
263                value,
264            });
265        }
266    }
267
268    Ok(())
269}
270
271fn observation_at_range_validated(
272    result: &TrajectoryResult,
273    distance_m: f64,
274) -> Result<TrajectoryObservation, TrajectoryObservationError> {
275    if !distance_m.is_finite() {
276        return Err(TrajectoryObservationError::NonFiniteQuery { distance_m });
277    }
278
279    let minimum_m = result.points[0].position.x;
280    let maximum_m = result.points[result.points.len() - 1].position.x;
281    if distance_m < minimum_m || distance_m > maximum_m {
282        return Err(TrajectoryObservationError::OutOfRange {
283            requested_m: distance_m,
284            minimum_m,
285            maximum_m,
286        });
287    }
288
289    let upper_index = result
290        .points
291        .partition_point(|point| point.position.x < distance_m);
292
293    let (time_s, vertical_m, windage_m, speed_mps) = if upper_index < result.points.len()
294        && result.points[upper_index].position.x == distance_m
295    {
296        let point = &result.points[upper_index];
297        (
298            point.time,
299            point.position.y,
300            point.position.z,
301            point.velocity_magnitude,
302        )
303    } else {
304        // In-range non-exact observations necessarily have a point on each side.
305        let lower = &result.points[upper_index - 1];
306        let upper = &result.points[upper_index];
307        let bracket_span_m = upper.position.x - lower.position.x;
308        require_finite_observation("interpolation_span_m", bracket_span_m)?;
309        let bracket_offset_m = distance_m - lower.position.x;
310        require_finite_observation("interpolation_offset_m", bracket_offset_m)?;
311        let alpha = bracket_offset_m / bracket_span_m;
312        require_finite_observation("interpolation_fraction", alpha)?;
313        (
314            checked_lerp(lower.time, upper.time, alpha, "time_s")?,
315            checked_lerp(lower.position.y, upper.position.y, alpha, "drop_m")?,
316            checked_lerp(lower.position.z, upper.position.z, alpha, "windage_m")?,
317            checked_lerp(
318                lower.velocity_magnitude,
319                upper.velocity_magnitude,
320                alpha,
321                "speed_mps",
322            )?,
323        )
324    };
325
326    let energy_j = 0.5 * result.projectile_mass_kg * speed_mps * speed_mps;
327    require_finite_observation("energy_j", energy_j)?;
328    let drop_m = result.line_of_sight_height_m - vertical_m;
329    require_finite_observation("drop_m", drop_m)?;
330    let mach = speed_mps / result.station_speed_of_sound_mps;
331    require_finite_observation("mach", mach)?;
332
333    for (field, value) in [
334        ("distance_m", distance_m),
335        ("time_s", time_s),
336        ("speed_mps", speed_mps),
337        ("windage_m", windage_m),
338    ] {
339        require_finite_observation(field, value)?;
340    }
341
342    let terminal = distance_m == maximum_m;
343    let mut flags = Vec::with_capacity(4);
344    if (0.8..=1.2).contains(&mach) {
345        flags.push(TrajectoryObservationFlag::Transonic);
346    }
347    if mach < 1.0 {
348        flags.push(TrajectoryObservationFlag::Subsonic);
349    }
350    if terminal {
351        flags.push(TrajectoryObservationFlag::Terminal);
352        if result.termination == TrajectoryTermination::GroundThreshold {
353            flags.push(TrajectoryObservationFlag::GroundThreshold);
354        }
355    }
356
357    Ok(TrajectoryObservation {
358        distance_m,
359        time_s,
360        speed_mps,
361        energy_j,
362        drop_m,
363        windage_m,
364        mach,
365        flags,
366    })
367}
368
369fn checked_lerp(
370    lower: f64,
371    upper: f64,
372    alpha: f64,
373    field: &'static str,
374) -> Result<f64, TrajectoryObservationError> {
375    let value = lower + alpha * (upper - lower);
376    require_finite_observation(field, value)?;
377    Ok(value)
378}
379
380fn require_finite_observation(
381    field: &'static str,
382    value: f64,
383) -> Result<(), TrajectoryObservationError> {
384    if value.is_finite() {
385        Ok(())
386    } else {
387        Err(TrajectoryObservationError::NonFiniteObservation { field })
388    }
389}
390
391fn projected_observation_count(
392    result: &TrajectoryResult,
393    interval_m: f64,
394    limit: usize,
395) -> Result<usize, TrajectoryObservationError> {
396    if !interval_m.is_finite() || interval_m <= 0.0 {
397        return Err(TrajectoryObservationError::InvalidInterval { interval_m });
398    }
399
400    let first_distance = result.points[0].position.x;
401    let terminal_distance = result.points[result.points.len() - 1].position.x;
402    let span = terminal_distance - first_distance;
403    if !span.is_finite() {
404        return Err(TrajectoryObservationError::NonFiniteObservation {
405            field: "trajectory_span_m",
406        });
407    }
408
409    let regular_count_f64 = if span > 0.0 {
410        (span / interval_m).ceil().max(1.0)
411    } else {
412        0.0
413    };
414    if !regular_count_f64.is_finite() || regular_count_f64 > usize::MAX as f64 {
415        return Err(TrajectoryObservationError::SampleLimitExceeded {
416            requested: usize::MAX,
417            limit,
418        });
419    }
420
421    // Bound work before correcting a possible one-ULP quotient overshoot. In particular, never
422    // walk a huge projected count down one element at a time: a finite sub-ULP interval at a
423    // large nonzero starting distance can otherwise turn this check into an unbounded loop.
424    if regular_count_f64 > limit as f64 {
425        let requested = if regular_count_f64 >= usize::MAX as f64 {
426            usize::MAX
427        } else {
428            (regular_count_f64 as usize).saturating_add(1)
429        };
430        return Err(TrajectoryObservationError::SampleLimitExceeded { requested, limit });
431    }
432
433    let mut regular_count = regular_count_f64 as usize;
434    // `ceil(span / interval)` can round upward when a mathematically on-grid terminal is
435    // represented just above an integer quotient.  Count only generated points that are
436    // strictly before the authoritative endpoint.
437    if regular_count > 0 {
438        let last_regular = first_distance + (regular_count - 1) as f64 * interval_m;
439        if last_regular >= terminal_distance {
440            regular_count -= 1;
441        }
442    }
443    // Division can also round a quotient just above an integer down to that integer. Probe the
444    // next grid point once so that a representable point strictly before the terminal is not
445    // omitted. With the count already bounded by `limit` (and therefore exactly representable as
446    // f64), one correction in either direction covers the quotient's possible rounding error.
447    let next_regular = first_distance + regular_count as f64 * interval_m;
448    if next_regular < terminal_distance {
449        regular_count = regular_count.saturating_add(1);
450    }
451
452    let requested = regular_count.saturating_add(1);
453    if requested > limit {
454        Err(TrajectoryObservationError::SampleLimitExceeded { requested, limit })
455    } else {
456        Ok(requested)
457    }
458}
459
460#[cfg(test)]
461mod tests {
462    use super::*;
463    use approx::assert_relative_eq;
464    use nalgebra::Vector3;
465
466    fn point(time: f64, x: f64, y: f64, z: f64, speed: f64) -> TrajectoryPoint {
467        TrajectoryPoint {
468            time,
469            position: Vector3::new(x, y, z),
470            velocity_magnitude: speed,
471            kinetic_energy: 0.5 * 0.02 * speed * speed,
472        }
473    }
474
475    fn result(
476        points: Vec<TrajectoryPoint>,
477        termination: TrajectoryTermination,
478    ) -> TrajectoryResult {
479        let terminal = points.last().expect("test trajectory must not be empty");
480        TrajectoryResult {
481            max_range: terminal.position.x,
482            max_height: points
483                .iter()
484                .map(|point| point.position.y)
485                .fold(f64::NEG_INFINITY, f64::max),
486            time_of_flight: terminal.time,
487            impact_velocity: terminal.velocity_magnitude,
488            impact_energy: terminal.kinetic_energy,
489            projectile_mass_kg: 0.02,
490            line_of_sight_height_m: 1.0,
491            station_speed_of_sound_mps: 340.0,
492            termination,
493            points,
494            sampled_points: None,
495            min_pitch_damping: None,
496            transonic_mach: None,
497            angular_state: None,
498            max_yaw_angle: None,
499            max_precession_angle: None,
500            aerodynamic_jump: None,
501            mach_1_2_distance_m: None,
502            mach_1_0_distance_m: None,
503            mach_0_9_distance_m: None,
504        }
505    }
506
507    fn synthetic_result() -> TrajectoryResult {
508        result(
509            vec![
510                point(0.0, 0.0, 0.5, -0.4, 680.0),
511                point(2.0, 100.0, 1.5, 0.4, 340.0),
512            ],
513            TrajectoryTermination::MaxRange,
514        )
515    }
516
517    #[test]
518    fn interpolates_full_state_with_documented_drop_and_windage_signs() {
519        let trajectory = synthetic_result();
520
521        let first = trajectory
522            .observation_at_range_checked(25.0)
523            .expect("in-range observation");
524        assert_relative_eq!(first.time_s, 0.5);
525        assert_relative_eq!(first.speed_mps, 595.0);
526        assert_relative_eq!(first.energy_j, 3540.25);
527        assert_relative_eq!(first.drop_m, 0.25);
528        assert_relative_eq!(first.windage_m, -0.2);
529        assert_relative_eq!(first.mach, 1.75);
530
531        let second = trajectory
532            .observation_at_range_checked(75.0)
533            .expect("in-range observation");
534        assert_relative_eq!(second.drop_m, -0.25);
535        assert_relative_eq!(second.windage_m, 0.2);
536    }
537
538    #[test]
539    fn preserves_exact_endpoints_and_marks_only_the_terminal_endpoint() {
540        let trajectory = synthetic_result();
541
542        let muzzle = trajectory
543            .observation_at_range_checked(0.0)
544            .expect("muzzle endpoint");
545        assert_eq!(muzzle.time_s, 0.0);
546        assert_eq!(muzzle.speed_mps, 680.0);
547        assert!(!muzzle.flags.contains(&TrajectoryObservationFlag::Terminal));
548
549        let terminal = trajectory
550            .observation_at_range_checked(100.0)
551            .expect("terminal endpoint");
552        assert_eq!(terminal.time_s, 2.0);
553        assert_eq!(terminal.speed_mps, 340.0);
554        assert_eq!(terminal.drop_m, -0.5);
555        assert_eq!(terminal.windage_m, 0.4);
556        assert!(terminal
557            .flags
558            .contains(&TrajectoryObservationFlag::Transonic));
559        assert!(terminal
560            .flags
561            .contains(&TrajectoryObservationFlag::Terminal));
562    }
563
564    #[test]
565    fn rejects_out_of_range_and_non_finite_queries_instead_of_clamping() {
566        let trajectory = synthetic_result();
567
568        for distance_m in [-0.001, 100.001] {
569            assert!(matches!(
570                trajectory.observation_at_range_checked(distance_m),
571                Err(TrajectoryObservationError::OutOfRange { .. })
572            ));
573        }
574        for distance_m in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
575            assert!(matches!(
576                trajectory.observation_at_range_checked(distance_m),
577                Err(TrajectoryObservationError::NonFiniteQuery { .. })
578            ));
579        }
580
581        // The established convenience API retains its historical clamping behavior.
582        assert_eq!(
583            trajectory.position_at_range(101.0),
584            Some(Vector3::new(100.0, 1.5, 0.4))
585        );
586    }
587
588    #[test]
589    fn regular_grid_appends_an_off_grid_terminal_and_deduplicates_an_on_grid_terminal() {
590        let off_grid = result(
591            vec![
592                point(0.0, 0.0, 1.0, 0.0, 500.0),
593                point(1.0, 95.0, 0.5, 0.0, 400.0),
594            ],
595            TrajectoryTermination::GroundThreshold,
596        );
597        let samples = off_grid
598            .sample_observations(30.0, 10)
599            .expect("off-grid samples");
600        assert_eq!(
601            samples
602                .iter()
603                .map(|sample| sample.distance_m)
604                .collect::<Vec<_>>(),
605            vec![0.0, 30.0, 60.0, 90.0, 95.0]
606        );
607        let terminal = samples.last().expect("terminal sample");
608        assert!(terminal
609            .flags
610            .contains(&TrajectoryObservationFlag::Terminal));
611        assert!(terminal
612            .flags
613            .contains(&TrajectoryObservationFlag::GroundThreshold));
614
615        let on_grid = result(
616            vec![
617                point(0.0, 0.0, 1.0, 0.0, 500.0),
618                point(1.0, 90.0, 0.5, 0.0, 400.0),
619            ],
620            TrajectoryTermination::MaxRange,
621        );
622        let samples = on_grid
623            .sample_observations(30.0, 10)
624            .expect("on-grid samples");
625        assert_eq!(
626            samples
627                .iter()
628                .map(|sample| sample.distance_m)
629                .collect::<Vec<_>>(),
630            vec![0.0, 30.0, 60.0, 90.0]
631        );
632
633        let fractional = result(
634            vec![
635                point(0.0, 0.0, 1.0, 0.0, 500.0),
636                point(1.0, 0.15, 0.5, 0.0, 400.0),
637            ],
638            TrajectoryTermination::MaxRange,
639        );
640        assert_eq!(
641            fractional
642                .sample_observations(0.1, 10)
643                .expect("fractional samples")
644                .iter()
645                .map(|sample| sample.distance_m)
646                .collect::<Vec<_>>(),
647            vec![0.0, 0.1, 0.15]
648        );
649
650        let rounded_grid_point = 3.0_f64 * 0.3;
651        let just_past_grid = f64::from_bits(rounded_grid_point.to_bits() + 1);
652        let rounded_quotient = result(
653            vec![
654                point(0.0, 0.0, 1.0, 0.0, 500.0),
655                point(1.0, just_past_grid, 0.5, 0.0, 400.0),
656            ],
657            TrajectoryTermination::MaxRange,
658        );
659        assert_eq!(
660            rounded_quotient
661                .sample_observations(0.3, 5)
662                .expect("a rounded quotient retains the last regular grid point")
663                .iter()
664                .map(|sample| sample.distance_m.to_bits())
665                .collect::<Vec<_>>(),
666            [0.0, 0.3, 0.6, rounded_grid_point, just_past_grid]
667                .map(f64::to_bits)
668                .to_vec()
669        );
670    }
671
672    #[test]
673    fn rejects_non_finite_state_metadata_and_derived_values() {
674        let mut non_finite_state = synthetic_result();
675        non_finite_state.points[1].position.y = f64::NAN;
676        assert!(matches!(
677            non_finite_state.observation_at_range_checked(50.0),
678            Err(TrajectoryObservationError::NonFiniteState {
679                index: 1,
680                field: "position.y"
681            })
682        ));
683
684        let mut invalid_metadata = synthetic_result();
685        invalid_metadata.station_speed_of_sound_mps = 0.0;
686        assert!(matches!(
687            invalid_metadata.observation_at_range_checked(50.0),
688            Err(TrajectoryObservationError::InvalidMetadata {
689                field: "station_speed_of_sound_mps",
690                ..
691            })
692        ));
693
694        let mut overflowing_energy = synthetic_result();
695        overflowing_energy.points[0].velocity_magnitude = f64::MAX;
696        overflowing_energy.points[0].kinetic_energy = f64::MAX;
697        assert!(matches!(
698            overflowing_energy.observation_at_range_checked(0.0),
699            Err(TrajectoryObservationError::NonFiniteObservation { field: "energy_j" })
700        ));
701
702        let overflowing_bracket = result(
703            vec![
704                point(0.0, -f64::MAX, 1.0, 0.0, 500.0),
705                point(1.0, f64::MAX, 0.5, 0.0, 400.0),
706            ],
707            TrajectoryTermination::MaxRange,
708        );
709        assert!(matches!(
710            overflowing_bracket.observation_at_range_checked(0.0),
711            Err(TrajectoryObservationError::NonFiniteObservation {
712                field: "interpolation_span_m"
713            })
714        ));
715    }
716
717    #[test]
718    fn enforces_caller_and_engine_sample_caps_before_allocation() {
719        let small = result(
720            vec![
721                point(0.0, 0.0, 1.0, 0.0, 500.0),
722                point(1.0, 4.0, 0.5, 0.0, 400.0),
723            ],
724            TrajectoryTermination::MaxRange,
725        );
726        assert_eq!(
727            small
728                .sample_observations(1.0, 5)
729                .expect("exact caller limit")
730                .len(),
731            5
732        );
733        assert!(matches!(
734            small.sample_observations(1.0, 4),
735            Err(TrajectoryObservationError::SampleLimitExceeded {
736                requested: 5,
737                limit: 4
738            })
739        ));
740
741        let at_engine_limit = result(
742            vec![
743                point(0.0, 0.0, 1.0, 0.0, 500.0),
744                point(1.0, (MAX_TRAJECTORY_SAMPLES - 1) as f64, 0.5, 0.0, 400.0),
745            ],
746            TrajectoryTermination::MaxRange,
747        );
748        assert_eq!(
749            projected_observation_count(&at_engine_limit, 1.0, MAX_TRAJECTORY_SAMPLES),
750            Ok(MAX_TRAJECTORY_SAMPLES)
751        );
752
753        let above_engine_limit = result(
754            vec![
755                point(0.0, 0.0, 1.0, 0.0, 500.0),
756                point(1.0, MAX_TRAJECTORY_SAMPLES as f64, 0.5, 0.0, 400.0),
757            ],
758            TrajectoryTermination::MaxRange,
759        );
760        assert!(matches!(
761            projected_observation_count(
762                &above_engine_limit,
763                1.0,
764                MAX_TRAJECTORY_SAMPLES
765            ),
766            Err(TrajectoryObservationError::SampleLimitExceeded {
767                requested,
768                limit: MAX_TRAJECTORY_SAMPLES
769            }) if requested == MAX_TRAJECTORY_SAMPLES + 1
770        ));
771    }
772
773    #[test]
774    fn rejects_huge_or_unrepresentable_grids_in_bounded_work() {
775        let first_distance = 1.0e300_f64;
776        let terminal_distance = f64::from_bits(first_distance.to_bits() + 1);
777        let span = terminal_distance - first_distance;
778        let trajectory = result(
779            vec![
780                point(0.0, first_distance, 1.0, 0.0, 500.0),
781                point(1.0, terminal_distance, 0.5, 0.0, 400.0),
782            ],
783            TrajectoryTermination::MaxRange,
784        );
785
786        assert!(matches!(
787            trajectory.sample_observations(span / 1.0e15, MAX_TRAJECTORY_SAMPLES),
788            Err(TrajectoryObservationError::SampleLimitExceeded { .. })
789        ));
790        assert!(matches!(
791            trajectory.sample_observations(span / 10.0, 20),
792            Err(TrajectoryObservationError::UnrepresentableGrid { index: 1, .. })
793        ));
794    }
795
796    #[test]
797    fn interval_ratio_underflow_still_includes_both_endpoints() {
798        let terminal_distance = f64::from_bits(1);
799        let trajectory = result(
800            vec![
801                point(0.0, 0.0, 1.0, 0.0, 500.0),
802                point(1.0, terminal_distance, 0.5, 0.0, 400.0),
803            ],
804            TrajectoryTermination::MaxRange,
805        );
806
807        let observations = trajectory
808            .sample_observations(f64::MAX, 2)
809            .expect("a positive span retains its first and terminal observations");
810        assert_eq!(observations.len(), 2);
811        assert_eq!(observations[0].distance_m.to_bits(), 0.0_f64.to_bits());
812        assert_eq!(
813            observations[1].distance_m.to_bits(),
814            terminal_distance.to_bits()
815        );
816    }
817
818    #[test]
819    fn rejects_duplicate_or_reversing_distances() {
820        for terminal_x in [0.0, -1.0] {
821            let trajectory = result(
822                vec![
823                    point(0.0, 0.0, 1.0, 0.0, 500.0),
824                    point(1.0, terminal_x, 0.5, 0.0, 400.0),
825                ],
826                TrajectoryTermination::MaxRange,
827            );
828            assert!(matches!(
829                trajectory.observation_at_range_checked(0.0),
830                Err(TrajectoryObservationError::NonMonotonicTrajectory { .. })
831            ));
832        }
833    }
834}