Skip to main content

ballistics_engine/
trajectory_sampling.rs

1use crate::cli_api::BallisticsError;
2use nalgebra::Vector3;
3use std::collections::HashSet;
4use std::fmt;
5
6/// Hard ceiling for observations produced by one regular trajectory-sampling request.
7///
8/// This is initially aligned with [`crate::MAX_TRAJECTORY_POINTS`]. The limit is checked before
9/// the sampler allocates its interpolation buffers or output vector.
10pub const MAX_TRAJECTORY_SAMPLES: usize = 250_000;
11
12/// Compute the candidate distance-grid size without performing an unchecked float-to-integer
13/// conversion or addition.
14///
15/// The count intentionally matches the historical grid generator: positive intervals below
16/// 0.1 m are clamped to 0.1 m, and the grid includes distance zero plus enough candidates to
17/// reach the requested range. The historical endpoint-tolerance filter can remove the final
18/// candidate; this function mirrors that decision so every grid with exactly the public limit is
19/// accepted.
20pub(crate) fn projected_sample_count(
21    max_dist: f64,
22    step_m: f64,
23) -> Result<usize, BallisticsError> {
24    if !max_dist.is_finite() || !step_m.is_finite() {
25        return Err(BallisticsError::from(
26            "trajectory sampling range and interval must be finite",
27        ));
28    }
29
30    if step_m <= 0.0 || max_dist < 1e-9 {
31        return Ok(0);
32    }
33
34    let step_size = step_m.max(0.1);
35    let intervals = (max_dist / step_size).ceil();
36    // The historical generator has `intervals + 1` candidates, and its filter can discard at
37    // most the final candidate because `step_size >= 0.1`. If there are more than MAX intervals,
38    // even discarding that candidate cannot bring the retained grid within the limit.
39    if !intervals.is_finite() || intervals > MAX_TRAJECTORY_SAMPLES as f64 {
40        return Err(BallisticsError::from(format!(
41            "trajectory sample limit of {MAX_TRAJECTORY_SAMPLES} exceeded"
42        )));
43    }
44
45    let intervals = intervals as usize;
46    let candidate_count = intervals.checked_add(1).ok_or_else(|| {
47        BallisticsError::from(format!(
48            "trajectory sample limit of {MAX_TRAJECTORY_SAMPLES} exceeded"
49        ))
50    })?;
51    let final_candidate_m = intervals as f64 * step_size;
52    let retained_count = if final_candidate_m > max_dist + 0.1 {
53        candidate_count - 1
54    } else {
55        candidate_count
56    };
57
58    if retained_count > MAX_TRAJECTORY_SAMPLES {
59        Err(BallisticsError::from(format!(
60            "trajectory sample limit of {MAX_TRAJECTORY_SAMPLES} exceeded"
61        )))
62    } else {
63        Ok(retained_count)
64    }
65}
66
67/// Trajectory flags for notable events
68#[derive(Debug, Clone, PartialEq, Eq, Hash)]
69pub enum TrajectoryFlag {
70    ZeroCrossing,
71    MachTransition,
72    Apex,
73}
74
75impl fmt::Display for TrajectoryFlag {
76    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
77        formatter.write_str(match self {
78            TrajectoryFlag::ZeroCrossing => "zero_crossing",
79            TrajectoryFlag::MachTransition => "mach_transition",
80            TrajectoryFlag::Apex => "apex",
81        })
82    }
83}
84
85impl TrajectoryFlag {
86    /// Return the stable wire-style name used by existing callers.
87    ///
88    /// Keep the inherent method alongside [`fmt::Display`] so fully qualified calls to the
89    /// historical API continue to compile.
90    #[allow(clippy::inherent_to_string_shadow_display)] // Preserve the established public method.
91    pub fn to_string(&self) -> String {
92        match self {
93            TrajectoryFlag::ZeroCrossing => "zero_crossing".to_owned(),
94            TrajectoryFlag::MachTransition => "mach_transition".to_owned(),
95            TrajectoryFlag::Apex => "apex".to_owned(),
96        }
97    }
98}
99
100/// Single trajectory sample point
101#[derive(Debug, Clone)]
102pub struct TrajectorySample {
103    pub distance_m: f64,
104    pub drop_m: f64,
105    pub wind_drift_m: f64,
106    pub velocity_mps: f64,
107    pub energy_j: f64,
108    pub time_s: f64,
109    pub flags: Vec<TrajectoryFlag>,
110}
111
112/// Trajectory solution data for sampling
113#[derive(Debug, Clone)]
114pub struct TrajectoryData {
115    pub times: Vec<f64>,
116    pub positions: Vec<Vector3<f64>>,  // [x, y, z] positions
117    pub velocities: Vec<Vector3<f64>>, // [vx, vy, vz] velocities
118    pub transonic_distances: Vec<f64>, // Distances where mach transitions occur
119}
120
121/// Output data for trajectory sampling
122#[derive(Debug, Clone)]
123pub struct TrajectoryOutputs {
124    pub target_distance_horiz_m: f64,
125    pub target_vertical_height_m: f64,
126    pub time_of_flight_s: f64,
127    pub max_ord_dist_horiz_m: f64,
128    /// Height of sight above bore (meters). Used for LOS calculation.
129    /// For a flat shot, the LOS is horizontal at y = sight_height_m.
130    pub sight_height_m: f64,
131}
132
133/// Sample trajectory at regular distance intervals with vectorized operations.
134///
135/// # Errors
136///
137/// Returns [`BallisticsError`] when the requested range or interval is non-finite, or when the
138/// retained distance grid would exceed [`MAX_TRAJECTORY_SAMPLES`].
139pub fn sample_trajectory(
140    trajectory_data: &TrajectoryData,
141    outputs: &TrajectoryOutputs,
142    step_m: f64,
143    mass_kg: f64,
144) -> Result<Vec<TrajectorySample>, BallisticsError> {
145    // Use the input target distance as the limit for sampling
146    let max_dist = outputs.target_distance_horiz_m;
147    let num_steps = projected_sample_count(max_dist, step_m)?;
148    if num_steps == 0 {
149        return Ok(Vec::new());
150    }
151    let step_size = step_m.max(0.1);
152
153    // Extract trajectory arrays for vectorized operations (McCoy: X=downrange, Z=lateral)
154    let downrange_vals: Vec<f64> = trajectory_data.positions.iter().map(|p| p.x).collect();
155    let y_vals: Vec<f64> = trajectory_data.positions.iter().map(|p| p.y).collect();
156    let lateral_vals: Vec<f64> = trajectory_data.positions.iter().map(|p| p.z).collect();
157
158    // Calculate speed at each integration knot.
159    let speeds: Vec<f64> = trajectory_data
160        .velocities
161        .iter()
162        .map(|v| v.norm())
163        .collect();
164
165    // Generate sampling distances. `num_steps` was checked before any sampler allocation.
166    let distances: Vec<f64> = (0..num_steps)
167        .map(|i| i as f64 * step_size)
168        .filter(|&d| d <= max_dist + 0.1) // Stop exactly at target (with tiny tolerance for rounding)
169        .collect();
170
171    // Vectorized interpolation for all trajectory data
172    let mut samples = Vec::with_capacity(distances.len());
173
174    for &distance in &distances {
175        // Interpolate using X (downrange) as the independent variable
176        // McCoy coordinate system: x=downrange, y=vertical, z=lateral (wind drift)
177        let y_interp = interpolate(&downrange_vals, &y_vals, distance); // vertical at downrange distance
178        let wind_drift = interpolate(&downrange_vals, &lateral_vals, distance); // lateral drift at downrange distance
179        let velocity = interpolate(&downrange_vals, &speeds, distance); // velocity at downrange distance
180        let time = interpolate(&downrange_vals, &trajectory_data.times, distance); // time at downrange distance
181        let energy = 0.5 * mass_kg * velocity * velocity;
182
183        // Calculate line-of-sight y-coordinate and drop
184        // The LOS is a straight line from the SIGHT to the target
185        // The sight is at y = sight_height_m above the bore (which starts at y = 0)
186        // For a flat shot: LOS is horizontal at y = sight_height_m
187        // For elevated/depressed shots: LOS slopes from sight_height_m to target_vertical_height_m
188        //
189        // Drop convention:
190        // - Positive drop means bullet is below LOS (has dropped)
191        // - Negative drop means bullet is above LOS (has risen)
192        // Therefore: drop = LOS - actual (not actual - LOS)
193        //
194        // LOS interpolation: starts at sight_height_m (z=0), ends at target_vertical_height_m (z=max_dist)
195        // Note: For a properly zeroed flat shot, target_vertical_height_m should equal sight_height_m
196        // (bullet ends at LOS at target distance for a point-blank shot)
197        let los_y = outputs.sight_height_m
198            + (outputs.target_vertical_height_m - outputs.sight_height_m) * distance / max_dist;
199        let drop = los_y - y_interp; // LOS - actual: positive when bullet is below LOS
200
201        samples.push(TrajectorySample {
202            distance_m: distance,
203            drop_m: drop,
204            wind_drift_m: wind_drift,
205            velocity_mps: velocity,
206            energy_j: energy,
207            time_s: time,
208            flags: Vec::new(), // Flags will be added later
209        });
210    }
211
212    // Add flags using vectorized detection
213    add_trajectory_flags(&mut samples, &trajectory_data.transonic_distances, max_dist);
214
215    Ok(samples)
216}
217
218/// Linear interpolation function optimized for trajectory data
219fn interpolate(x_vals: &[f64], y_vals: &[f64], x: f64) -> f64 {
220    if x_vals.is_empty() || y_vals.is_empty() {
221        return 0.0;
222    }
223
224    if x_vals.len() != y_vals.len() {
225        return 0.0;
226    }
227
228    if x <= x_vals[0] {
229        return y_vals[0];
230    }
231
232    if x >= x_vals[x_vals.len() - 1] {
233        return y_vals[y_vals.len() - 1];
234    }
235
236    // Binary search for the correct interval
237    let mut left = 0;
238    let mut right = x_vals.len() - 1;
239
240    while right - left > 1 {
241        let mid = (left + right) / 2;
242        if x_vals[mid] <= x {
243            left = mid;
244        } else {
245            right = mid;
246        }
247    }
248
249    // Linear interpolation
250    let x1 = x_vals[left];
251    let x2 = x_vals[right];
252    let y1 = y_vals[left];
253    let y2 = y_vals[right];
254
255    if (x2 - x1).abs() < f64::EPSILON {
256        return y1;
257    }
258
259    y1 + (y2 - y1) * (x - x1) / (x2 - x1)
260}
261
262/// Add trajectory flags using vectorized detection algorithms
263fn add_trajectory_flags(
264    samples: &mut [TrajectorySample],
265    transonic_distances: &[f64],
266    target_distance_input_m: f64,
267) {
268    let tolerance = 1e-6;
269
270    // 1. Zero crossings - vectorized detection
271    detect_zero_crossings(samples, tolerance);
272
273    // 2. Mach transitions
274    for &transonic_dist in transonic_distances {
275        if let Some(idx) = find_closest_sample_index(samples, transonic_dist) {
276            samples[idx].flags.push(TrajectoryFlag::MachTransition);
277        }
278    }
279
280    // 3. Apex - find the point with maximum height between muzzle and target
281    // Since drop is positive when bullet is below LOS and negative when above,
282    // the apex is where drop is minimum (most negative)
283    if samples.len() > 2 {
284        // Use the target distance passed as parameter
285        let target_distance_m = target_distance_input_m;
286
287        // Find the index of maximum height (minimum drop, most negative) within target distance.
288        // Only mark an interior apex if it is actually above the muzzle/first sample.
289        let first_drop = samples[0].drop_m;
290        let mut min_drop = first_drop;
291        let mut apex_idx: Option<usize> = None;
292
293        // Search from index 1, but stop at target distance
294        for (i, sample) in samples.iter().enumerate().skip(1) {
295            // Only consider points up to target distance
296            if sample.distance_m > target_distance_m {
297                break;
298            }
299
300            if sample.drop_m < min_drop {
301                min_drop = sample.drop_m;
302                apex_idx = Some(i);
303            }
304        }
305
306        if let Some(idx) = apex_idx {
307            samples[idx].flags.push(TrajectoryFlag::Apex);
308        }
309    }
310}
311
312/// Detect zero crossings in trajectory drop values using vectorized operations
313fn detect_zero_crossings(samples: &mut [TrajectorySample], tolerance: f64) {
314    if samples.len() < 2 {
315        return;
316    }
317
318    let drops: Vec<f64> = samples.iter().map(|s| s.drop_m).collect();
319
320    // Find crossing indices where drop changes sign
321    for i in 0..(drops.len() - 1) {
322        let current = drops[i];
323        let next = drops[i + 1];
324
325        // Check for sign change crossings
326        let crosses_zero = (current < -tolerance && next >= -tolerance)
327            || (current > tolerance && next <= tolerance);
328
329        if crosses_zero {
330            samples[i + 1].flags.push(TrajectoryFlag::ZeroCrossing);
331        }
332    }
333
334    // Find points very close to zero
335    for (i, &drop) in drops.iter().enumerate() {
336        if drop.abs() <= tolerance {
337            samples[i].flags.push(TrajectoryFlag::ZeroCrossing);
338        }
339    }
340
341    // Remove duplicate zero crossing flags
342    for sample in samples.iter_mut() {
343        let mut unique_flags = Vec::new();
344        let mut seen = HashSet::new();
345
346        for flag in &sample.flags {
347            if seen.insert(flag.clone()) {
348                unique_flags.push(flag.clone());
349            }
350        }
351        sample.flags = unique_flags;
352    }
353}
354
355/// Find the closest sample index to a given distance
356fn find_closest_sample_index(samples: &[TrajectorySample], target_distance: f64) -> Option<usize> {
357    if samples.is_empty() {
358        return None;
359    }
360
361    // Binary search for the closest distance
362    let distances: Vec<f64> = samples.iter().map(|s| s.distance_m).collect();
363
364    let mut left = 0;
365    let mut right = distances.len();
366
367    while left < right {
368        let mid = (left + right) / 2;
369        if distances[mid] < target_distance {
370            left = mid + 1;
371        } else {
372            right = mid;
373        }
374    }
375
376    // Find the closest point (could be left-1 or left)
377    let mut best_idx = left.min(distances.len() - 1);
378
379    if left > 0 {
380        let left_dist = (distances[left - 1] - target_distance).abs();
381        let right_dist = (distances[best_idx] - target_distance).abs();
382
383        // Prefer earlier index in case of tie
384        if left_dist <= right_dist {
385            best_idx = left - 1;
386        }
387    }
388
389    Some(best_idx)
390}
391
392/// Convert trajectory samples to Python-compatible format
393pub fn trajectory_samples_to_dicts(samples: &[TrajectorySample]) -> Vec<TrajectoryDict> {
394    samples
395        .iter()
396        .map(|sample| TrajectoryDict {
397            distance_m: sample.distance_m,
398            drop_m: sample.drop_m,
399            wind_drift_m: sample.wind_drift_m,
400            velocity_mps: sample.velocity_mps,
401            energy_j: sample.energy_j,
402            time_s: sample.time_s,
403            flags: sample.flags.iter().map(|f| f.to_string()).collect(),
404        })
405        .collect()
406}
407
408/// Python-compatible trajectory sample structure
409#[derive(Debug, Clone)]
410pub struct TrajectoryDict {
411    pub distance_m: f64,
412    pub drop_m: f64,
413    pub wind_drift_m: f64,
414    pub velocity_mps: f64,
415    pub energy_j: f64,
416    pub time_s: f64,
417    pub flags: Vec<String>,
418}
419
420#[cfg(test)]
421mod tests {
422    use super::*;
423
424    fn linear_fixture(max_dist: f64) -> (TrajectoryData, TrajectoryOutputs) {
425        (
426            TrajectoryData {
427                times: vec![0.0, 1.0],
428                positions: vec![
429                    Vector3::new(0.0, -1.0, 0.0),
430                    Vector3::new(max_dist, -1.0, 0.0),
431                ],
432                velocities: vec![
433                    Vector3::new(800.0, 0.0, 0.0),
434                    Vector3::new(700.0, 0.0, 0.0),
435                ],
436                transonic_distances: vec![],
437            },
438            TrajectoryOutputs {
439                target_distance_horiz_m: max_dist,
440                target_vertical_height_m: 0.0,
441                time_of_flight_s: 1.0,
442                max_ord_dist_horiz_m: 0.0,
443                sight_height_m: 0.0,
444            },
445        )
446    }
447
448    #[test]
449    fn mba1299_projected_sample_count_checks_exact_limit_and_overflow() {
450        assert_eq!(MAX_TRAJECTORY_SAMPLES, crate::MAX_TRAJECTORY_POINTS);
451        assert_eq!(
452            projected_sample_count((MAX_TRAJECTORY_SAMPLES - 1) as f64, 1.0)
453                .expect("the exact sample cap should be accepted"),
454            MAX_TRAJECTORY_SAMPLES
455        );
456        assert_eq!(
457            projected_sample_count(MAX_TRAJECTORY_SAMPLES as f64 - 0.5, 1.0)
458                .expect("a filtered final candidate must not reject an exact-cap grid"),
459            MAX_TRAJECTORY_SAMPLES
460        );
461        assert_eq!(
462            projected_sample_count(0.2, 0.01)
463                .expect("the historical 0.1 meter interval floor should remain valid"),
464            3
465        );
466
467        for (range, interval) in [
468            (MAX_TRAJECTORY_SAMPLES as f64, 1.0),
469            (f64::MAX, 0.1),
470        ] {
471            let error = projected_sample_count(range, interval)
472                .expect_err("a grid above the sample cap must fail");
473            assert!(
474                error
475                    .to_string()
476                    .contains("trajectory sample limit of 250000 exceeded"),
477                "unexpected sampling limit error: {error}"
478            );
479        }
480    }
481
482    #[test]
483    fn mba1299_public_sampler_accepts_the_exact_cap() {
484        for max_dist in [
485            (MAX_TRAJECTORY_SAMPLES - 1) as f64,
486            MAX_TRAJECTORY_SAMPLES as f64 - 0.5,
487        ] {
488            let (trajectory_data, outputs) = linear_fixture(max_dist);
489            let samples = sample_trajectory(&trajectory_data, &outputs, 1.0, 0.01)
490                .expect("an exact-cap sample grid should succeed");
491
492            assert_eq!(samples.len(), MAX_TRAJECTORY_SAMPLES);
493            assert_eq!(samples.first().expect("muzzle sample").distance_m, 0.0);
494            assert_eq!(
495                samples.last().expect("terminal sample").distance_m,
496                (MAX_TRAJECTORY_SAMPLES - 1) as f64
497            );
498        }
499    }
500
501    #[test]
502    fn mba1299_public_sampler_rejects_oversized_grids_before_allocation() {
503        for max_dist in [MAX_TRAJECTORY_SAMPLES as f64, f64::MAX] {
504            let (trajectory_data, outputs) = linear_fixture(max_dist);
505            let error = sample_trajectory(&trajectory_data, &outputs, 1.0, 0.01)
506                .expect_err("an oversized public sampling request must fail");
507            assert!(
508                error
509                    .to_string()
510                    .contains("trajectory sample limit of 250000 exceeded"),
511                "unexpected sampling limit error: {error}"
512            );
513        }
514    }
515
516    #[test]
517    fn test_interpolate() {
518        let x_vals = vec![0.0, 1.0, 2.0, 3.0];
519        let y_vals = vec![0.0, 10.0, 20.0, 30.0];
520
521        assert_eq!(interpolate(&x_vals, &y_vals, 0.5), 5.0);
522        assert_eq!(interpolate(&x_vals, &y_vals, 1.5), 15.0);
523        assert_eq!(interpolate(&x_vals, &y_vals, 2.5), 25.0);
524
525        // Test boundary conditions
526        assert_eq!(interpolate(&x_vals, &y_vals, -1.0), 0.0); // Below range
527        assert_eq!(interpolate(&x_vals, &y_vals, 4.0), 30.0); // Above range
528    }
529
530    #[test]
531    fn test_find_closest_sample_index() {
532        let samples = vec![
533            TrajectorySample {
534                distance_m: 0.0,
535                drop_m: 0.0,
536                wind_drift_m: 0.0,
537                velocity_mps: 100.0,
538                energy_j: 1000.0,
539                time_s: 0.0,
540                flags: Vec::new(),
541            },
542            TrajectorySample {
543                distance_m: 10.0,
544                drop_m: -1.0,
545                wind_drift_m: 0.1,
546                velocity_mps: 95.0,
547                energy_j: 950.0,
548                time_s: 0.1,
549                flags: Vec::new(),
550            },
551            TrajectorySample {
552                distance_m: 20.0,
553                drop_m: -4.0,
554                wind_drift_m: 0.2,
555                velocity_mps: 90.0,
556                energy_j: 900.0,
557                time_s: 0.2,
558                flags: Vec::new(),
559            },
560        ];
561
562        assert_eq!(find_closest_sample_index(&samples, 5.0), Some(0));
563        assert_eq!(find_closest_sample_index(&samples, 12.0), Some(1));
564        assert_eq!(find_closest_sample_index(&samples, 18.0), Some(2));
565    }
566
567    #[test]
568    fn test_detect_zero_crossings() {
569        let mut samples = vec![
570            TrajectorySample {
571                distance_m: 0.0,
572                drop_m: 1.0, // Positive
573                wind_drift_m: 0.0,
574                velocity_mps: 100.0,
575                energy_j: 1000.0,
576                time_s: 0.0,
577                flags: Vec::new(),
578            },
579            TrajectorySample {
580                distance_m: 10.0,
581                drop_m: -0.5, // Negative - crossing here
582                wind_drift_m: 0.1,
583                velocity_mps: 95.0,
584                energy_j: 950.0,
585                time_s: 0.1,
586                flags: Vec::new(),
587            },
588            TrajectorySample {
589                distance_m: 20.0,
590                drop_m: -2.0, // Still negative
591                wind_drift_m: 0.2,
592                velocity_mps: 90.0,
593                energy_j: 900.0,
594                time_s: 0.2,
595                flags: Vec::new(),
596            },
597        ];
598
599        detect_zero_crossings(&mut samples, 1e-6);
600
601        // Should have a zero crossing flag at index 1
602        assert!(!samples[0].flags.contains(&TrajectoryFlag::ZeroCrossing));
603        assert!(samples[1].flags.contains(&TrajectoryFlag::ZeroCrossing));
604        assert!(!samples[2].flags.contains(&TrajectoryFlag::ZeroCrossing));
605    }
606
607    #[test]
608    fn test_sample_trajectory_basic() {
609        // Create simple test trajectory data
610        // McCoy coordinate system: x=downrange, y=vertical, z=lateral (wind drift)
611        let trajectory_data = TrajectoryData {
612            times: vec![0.0, 1.0, 2.0],
613            positions: vec![
614                Vector3::new(0.0, 0.0, 0.0), // x=0 (start), y=0 (vertical), z=0 (no drift)
615                Vector3::new(100.0, 10.0, 1.0), // x=100 (mid downrange), y=10 (apex height), z=1 (drift)
616                Vector3::new(200.0, 5.0, 2.0), // x=200 (end downrange), y=5 (below apex), z=2 (drift)
617            ],
618            velocities: vec![
619                Vector3::new(1.0, 10.0, 100.0),
620                Vector3::new(1.0, 5.0, 95.0),
621                Vector3::new(1.0, 0.0, 90.0),
622            ],
623            transonic_distances: vec![150.0],
624        };
625
626        let outputs = TrajectoryOutputs {
627            target_distance_horiz_m: 200.0,
628            target_vertical_height_m: 0.0,
629            time_of_flight_s: 2.0,
630            max_ord_dist_horiz_m: 100.0,
631            sight_height_m: 0.0, // For test: assume bore-referenced coordinates
632        };
633
634        let samples = sample_trajectory(&trajectory_data, &outputs, 50.0, 0.1)
635            .expect("normal sampling should succeed");
636
637        // Should have samples at 0, 50, 100, 150, 200 meters
638        assert_eq!(samples.len(), 5);
639        assert_eq!(samples[0].distance_m, 0.0);
640        assert_eq!(samples[1].distance_m, 50.0);
641        assert_eq!(samples[2].distance_m, 100.0);
642        assert_eq!(samples[3].distance_m, 150.0);
643        assert_eq!(samples[4].distance_m, 200.0);
644
645        // Check that interpolation is working
646        assert!(samples[1].velocity_mps > 90.0 && samples[1].velocity_mps < 100.0);
647
648        // Check flags
649        assert!(samples[2].flags.contains(&TrajectoryFlag::Apex)); // At apex distance
650        assert!(samples[3].flags.contains(&TrajectoryFlag::MachTransition)); // At transonic distance
651    }
652
653    #[test]
654    fn sampled_energy_is_derived_from_interpolated_speed() {
655        let mass_kg = 0.01;
656        let trajectory_data = TrajectoryData {
657            times: vec![0.0, 1.0],
658            positions: vec![Vector3::zeros(), Vector3::new(100.0, 0.0, 0.0)],
659            velocities: vec![Vector3::new(800.0, 0.0, 0.0), Vector3::new(700.0, 0.0, 0.0)],
660            transonic_distances: vec![],
661        };
662        let outputs = TrajectoryOutputs {
663            target_distance_horiz_m: 100.0,
664            target_vertical_height_m: 0.0,
665            time_of_flight_s: 1.0,
666            max_ord_dist_horiz_m: 0.0,
667            sight_height_m: 0.0,
668        };
669
670        let samples = sample_trajectory(&trajectory_data, &outputs, 50.0, mass_kg)
671            .expect("normal sampling should succeed");
672        assert_eq!(samples.len(), 3);
673        assert_eq!(samples[1].velocity_mps.to_bits(), 750.0_f64.to_bits());
674        assert_eq!(samples[1].energy_j.to_bits(), 2812.5_f64.to_bits());
675        for sample in samples {
676            let expected_energy = 0.5 * mass_kg * sample.velocity_mps * sample.velocity_mps;
677            assert_eq!(sample.energy_j.to_bits(), expected_energy.to_bits());
678        }
679    }
680}