1use crate::cli_api::{TrajectoryPoint, TrajectoryResult};
8use crate::trajectory_sampling::MAX_TRAJECTORY_SAMPLES;
9use thiserror::Error;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
13pub enum TrajectoryTermination {
14 MaxRange,
15 GroundThreshold,
16 TimeLimit,
17 VelocityFloor,
18}
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
22pub enum TrajectoryObservationFlag {
23 Transonic,
25 Subsonic,
27 Terminal,
29 GroundThreshold,
31}
32
33#[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 pub drop_m: f64,
42 pub windage_m: f64,
44 pub mach: f64,
45 pub flags: Vec<TrajectoryObservationFlag>,
46}
47
48#[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 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 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 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 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 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 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 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 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 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}