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
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        }
502    }
503
504    fn synthetic_result() -> TrajectoryResult {
505        result(
506            vec![
507                point(0.0, 0.0, 0.5, -0.4, 680.0),
508                point(2.0, 100.0, 1.5, 0.4, 340.0),
509            ],
510            TrajectoryTermination::MaxRange,
511        )
512    }
513
514    #[test]
515    fn interpolates_full_state_with_documented_drop_and_windage_signs() {
516        let trajectory = synthetic_result();
517
518        let first = trajectory
519            .observation_at_range_checked(25.0)
520            .expect("in-range observation");
521        assert_relative_eq!(first.time_s, 0.5);
522        assert_relative_eq!(first.speed_mps, 595.0);
523        assert_relative_eq!(first.energy_j, 3540.25);
524        assert_relative_eq!(first.drop_m, 0.25);
525        assert_relative_eq!(first.windage_m, -0.2);
526        assert_relative_eq!(first.mach, 1.75);
527
528        let second = trajectory
529            .observation_at_range_checked(75.0)
530            .expect("in-range observation");
531        assert_relative_eq!(second.drop_m, -0.25);
532        assert_relative_eq!(second.windage_m, 0.2);
533    }
534
535    #[test]
536    fn preserves_exact_endpoints_and_marks_only_the_terminal_endpoint() {
537        let trajectory = synthetic_result();
538
539        let muzzle = trajectory
540            .observation_at_range_checked(0.0)
541            .expect("muzzle endpoint");
542        assert_eq!(muzzle.time_s, 0.0);
543        assert_eq!(muzzle.speed_mps, 680.0);
544        assert!(!muzzle.flags.contains(&TrajectoryObservationFlag::Terminal));
545
546        let terminal = trajectory
547            .observation_at_range_checked(100.0)
548            .expect("terminal endpoint");
549        assert_eq!(terminal.time_s, 2.0);
550        assert_eq!(terminal.speed_mps, 340.0);
551        assert_eq!(terminal.drop_m, -0.5);
552        assert_eq!(terminal.windage_m, 0.4);
553        assert!(terminal
554            .flags
555            .contains(&TrajectoryObservationFlag::Transonic));
556        assert!(terminal
557            .flags
558            .contains(&TrajectoryObservationFlag::Terminal));
559    }
560
561    #[test]
562    fn rejects_out_of_range_and_non_finite_queries_instead_of_clamping() {
563        let trajectory = synthetic_result();
564
565        for distance_m in [-0.001, 100.001] {
566            assert!(matches!(
567                trajectory.observation_at_range_checked(distance_m),
568                Err(TrajectoryObservationError::OutOfRange { .. })
569            ));
570        }
571        for distance_m in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
572            assert!(matches!(
573                trajectory.observation_at_range_checked(distance_m),
574                Err(TrajectoryObservationError::NonFiniteQuery { .. })
575            ));
576        }
577
578        // The established convenience API retains its historical clamping behavior.
579        assert_eq!(
580            trajectory.position_at_range(101.0),
581            Some(Vector3::new(100.0, 1.5, 0.4))
582        );
583    }
584
585    #[test]
586    fn regular_grid_appends_an_off_grid_terminal_and_deduplicates_an_on_grid_terminal() {
587        let off_grid = result(
588            vec![
589                point(0.0, 0.0, 1.0, 0.0, 500.0),
590                point(1.0, 95.0, 0.5, 0.0, 400.0),
591            ],
592            TrajectoryTermination::GroundThreshold,
593        );
594        let samples = off_grid
595            .sample_observations(30.0, 10)
596            .expect("off-grid samples");
597        assert_eq!(
598            samples
599                .iter()
600                .map(|sample| sample.distance_m)
601                .collect::<Vec<_>>(),
602            vec![0.0, 30.0, 60.0, 90.0, 95.0]
603        );
604        let terminal = samples.last().expect("terminal sample");
605        assert!(terminal
606            .flags
607            .contains(&TrajectoryObservationFlag::Terminal));
608        assert!(terminal
609            .flags
610            .contains(&TrajectoryObservationFlag::GroundThreshold));
611
612        let on_grid = result(
613            vec![
614                point(0.0, 0.0, 1.0, 0.0, 500.0),
615                point(1.0, 90.0, 0.5, 0.0, 400.0),
616            ],
617            TrajectoryTermination::MaxRange,
618        );
619        let samples = on_grid
620            .sample_observations(30.0, 10)
621            .expect("on-grid samples");
622        assert_eq!(
623            samples
624                .iter()
625                .map(|sample| sample.distance_m)
626                .collect::<Vec<_>>(),
627            vec![0.0, 30.0, 60.0, 90.0]
628        );
629
630        let fractional = result(
631            vec![
632                point(0.0, 0.0, 1.0, 0.0, 500.0),
633                point(1.0, 0.15, 0.5, 0.0, 400.0),
634            ],
635            TrajectoryTermination::MaxRange,
636        );
637        assert_eq!(
638            fractional
639                .sample_observations(0.1, 10)
640                .expect("fractional samples")
641                .iter()
642                .map(|sample| sample.distance_m)
643                .collect::<Vec<_>>(),
644            vec![0.0, 0.1, 0.15]
645        );
646
647        let rounded_grid_point = 3.0_f64 * 0.3;
648        let just_past_grid = f64::from_bits(rounded_grid_point.to_bits() + 1);
649        let rounded_quotient = result(
650            vec![
651                point(0.0, 0.0, 1.0, 0.0, 500.0),
652                point(1.0, just_past_grid, 0.5, 0.0, 400.0),
653            ],
654            TrajectoryTermination::MaxRange,
655        );
656        assert_eq!(
657            rounded_quotient
658                .sample_observations(0.3, 5)
659                .expect("a rounded quotient retains the last regular grid point")
660                .iter()
661                .map(|sample| sample.distance_m.to_bits())
662                .collect::<Vec<_>>(),
663            [0.0, 0.3, 0.6, rounded_grid_point, just_past_grid]
664                .map(f64::to_bits)
665                .to_vec()
666        );
667    }
668
669    #[test]
670    fn rejects_non_finite_state_metadata_and_derived_values() {
671        let mut non_finite_state = synthetic_result();
672        non_finite_state.points[1].position.y = f64::NAN;
673        assert!(matches!(
674            non_finite_state.observation_at_range_checked(50.0),
675            Err(TrajectoryObservationError::NonFiniteState {
676                index: 1,
677                field: "position.y"
678            })
679        ));
680
681        let mut invalid_metadata = synthetic_result();
682        invalid_metadata.station_speed_of_sound_mps = 0.0;
683        assert!(matches!(
684            invalid_metadata.observation_at_range_checked(50.0),
685            Err(TrajectoryObservationError::InvalidMetadata {
686                field: "station_speed_of_sound_mps",
687                ..
688            })
689        ));
690
691        let mut overflowing_energy = synthetic_result();
692        overflowing_energy.points[0].velocity_magnitude = f64::MAX;
693        overflowing_energy.points[0].kinetic_energy = f64::MAX;
694        assert!(matches!(
695            overflowing_energy.observation_at_range_checked(0.0),
696            Err(TrajectoryObservationError::NonFiniteObservation { field: "energy_j" })
697        ));
698
699        let overflowing_bracket = result(
700            vec![
701                point(0.0, -f64::MAX, 1.0, 0.0, 500.0),
702                point(1.0, f64::MAX, 0.5, 0.0, 400.0),
703            ],
704            TrajectoryTermination::MaxRange,
705        );
706        assert!(matches!(
707            overflowing_bracket.observation_at_range_checked(0.0),
708            Err(TrajectoryObservationError::NonFiniteObservation {
709                field: "interpolation_span_m"
710            })
711        ));
712    }
713
714    #[test]
715    fn enforces_caller_and_engine_sample_caps_before_allocation() {
716        let small = result(
717            vec![
718                point(0.0, 0.0, 1.0, 0.0, 500.0),
719                point(1.0, 4.0, 0.5, 0.0, 400.0),
720            ],
721            TrajectoryTermination::MaxRange,
722        );
723        assert_eq!(
724            small
725                .sample_observations(1.0, 5)
726                .expect("exact caller limit")
727                .len(),
728            5
729        );
730        assert!(matches!(
731            small.sample_observations(1.0, 4),
732            Err(TrajectoryObservationError::SampleLimitExceeded {
733                requested: 5,
734                limit: 4
735            })
736        ));
737
738        let at_engine_limit = result(
739            vec![
740                point(0.0, 0.0, 1.0, 0.0, 500.0),
741                point(1.0, (MAX_TRAJECTORY_SAMPLES - 1) as f64, 0.5, 0.0, 400.0),
742            ],
743            TrajectoryTermination::MaxRange,
744        );
745        assert_eq!(
746            projected_observation_count(&at_engine_limit, 1.0, MAX_TRAJECTORY_SAMPLES),
747            Ok(MAX_TRAJECTORY_SAMPLES)
748        );
749
750        let above_engine_limit = result(
751            vec![
752                point(0.0, 0.0, 1.0, 0.0, 500.0),
753                point(1.0, MAX_TRAJECTORY_SAMPLES as f64, 0.5, 0.0, 400.0),
754            ],
755            TrajectoryTermination::MaxRange,
756        );
757        assert!(matches!(
758            projected_observation_count(
759                &above_engine_limit,
760                1.0,
761                MAX_TRAJECTORY_SAMPLES
762            ),
763            Err(TrajectoryObservationError::SampleLimitExceeded {
764                requested,
765                limit: MAX_TRAJECTORY_SAMPLES
766            }) if requested == MAX_TRAJECTORY_SAMPLES + 1
767        ));
768    }
769
770    #[test]
771    fn rejects_huge_or_unrepresentable_grids_in_bounded_work() {
772        let first_distance = 1.0e300_f64;
773        let terminal_distance = f64::from_bits(first_distance.to_bits() + 1);
774        let span = terminal_distance - first_distance;
775        let trajectory = result(
776            vec![
777                point(0.0, first_distance, 1.0, 0.0, 500.0),
778                point(1.0, terminal_distance, 0.5, 0.0, 400.0),
779            ],
780            TrajectoryTermination::MaxRange,
781        );
782
783        assert!(matches!(
784            trajectory.sample_observations(span / 1.0e15, MAX_TRAJECTORY_SAMPLES),
785            Err(TrajectoryObservationError::SampleLimitExceeded { .. })
786        ));
787        assert!(matches!(
788            trajectory.sample_observations(span / 10.0, 20),
789            Err(TrajectoryObservationError::UnrepresentableGrid { index: 1, .. })
790        ));
791    }
792
793    #[test]
794    fn interval_ratio_underflow_still_includes_both_endpoints() {
795        let terminal_distance = f64::from_bits(1);
796        let trajectory = result(
797            vec![
798                point(0.0, 0.0, 1.0, 0.0, 500.0),
799                point(1.0, terminal_distance, 0.5, 0.0, 400.0),
800            ],
801            TrajectoryTermination::MaxRange,
802        );
803
804        let observations = trajectory
805            .sample_observations(f64::MAX, 2)
806            .expect("a positive span retains its first and terminal observations");
807        assert_eq!(observations.len(), 2);
808        assert_eq!(observations[0].distance_m.to_bits(), 0.0_f64.to_bits());
809        assert_eq!(
810            observations[1].distance_m.to_bits(),
811            terminal_distance.to_bits()
812        );
813    }
814
815    #[test]
816    fn rejects_duplicate_or_reversing_distances() {
817        for terminal_x in [0.0, -1.0] {
818            let trajectory = result(
819                vec![
820                    point(0.0, 0.0, 1.0, 0.0, 500.0),
821                    point(1.0, terminal_x, 0.5, 0.0, 400.0),
822                ],
823                TrajectoryTermination::MaxRange,
824            );
825            assert!(matches!(
826                trajectory.observation_at_range_checked(0.0),
827                Err(TrajectoryObservationError::NonMonotonicTrajectory { .. })
828            ));
829        }
830    }
831}