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