1use crate::cli_api::{TrajectoryPoint, TrajectoryResult};
14use crate::trajectory_sampling::MAX_TRAJECTORY_SAMPLES;
15use thiserror::Error;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
19pub enum TrajectoryTermination {
20 MaxRange,
21 GroundThreshold,
22 TimeLimit,
23 VelocityFloor,
24}
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
28pub enum TrajectoryObservationFlag {
29 Transonic,
31 Subsonic,
33 Terminal,
35 GroundThreshold,
37}
38
39#[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 pub drop_m: f64,
48 pub windage_m: f64,
50 pub mach: f64,
51 pub flags: Vec<TrajectoryObservationFlag>,
52}
53
54#[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 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 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 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 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 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 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 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 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#[derive(Debug, Clone, Copy, PartialEq)]
488pub(crate) enum Bracket {
489 Inside { lo: usize, t: f64 },
493 Below,
495 Above,
497 Degenerate,
499}
500
501pub(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 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 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 #[test]
924 fn bracket_param_is_degenerate_below_two_keys() {
925 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 }
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 } );
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 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 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 #[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}