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