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    /// Downward Mach 1.2-then-1.0 crossing distances (m), in that order when present. Used to
119    /// flag sampled points with a Mach-transition marker. Historical shape — do not append
120    /// the 0.9 crossing here; it is carried only by `mach_0_9_distance_m` below (MBA-1405).
121    pub transonic_distances: Vec<f64>,
122    /// Downrange distance (m) of the downward Mach 1.2 crossing, mirroring
123    /// [`crate::cli_api::TrajectoryResult::mach_1_2_distance_m`]. `None` if never crossed.
124    pub mach_1_2_distance_m: Option<f64>,
125    /// Downrange distance (m) of the downward Mach 1.0 crossing, mirroring
126    /// [`crate::cli_api::TrajectoryResult::mach_1_0_distance_m`]. `None` if never crossed.
127    pub mach_1_0_distance_m: Option<f64>,
128    /// Downrange distance (m) of the downward Mach 0.9 crossing, mirroring
129    /// [`crate::cli_api::TrajectoryResult::mach_0_9_distance_m`]. `None` if never crossed.
130    /// NOT included in `transonic_distances` (see its docs).
131    pub mach_0_9_distance_m: Option<f64>,
132}
133
134/// Output data for trajectory sampling
135#[derive(Debug, Clone)]
136pub struct TrajectoryOutputs {
137    pub target_distance_horiz_m: f64,
138    pub target_vertical_height_m: f64,
139    pub time_of_flight_s: f64,
140    pub max_ord_dist_horiz_m: f64,
141    /// Height of sight above bore (meters). Used for LOS calculation.
142    /// For a flat shot, the LOS is horizontal at y = sight_height_m.
143    pub sight_height_m: f64,
144}
145
146/// Sample trajectory at regular distance intervals with vectorized operations.
147///
148/// # Errors
149///
150/// Returns [`BallisticsError`] when the requested range or interval is non-finite, or when the
151/// retained distance grid would exceed [`MAX_TRAJECTORY_SAMPLES`].
152pub fn sample_trajectory(
153    trajectory_data: &TrajectoryData,
154    outputs: &TrajectoryOutputs,
155    step_m: f64,
156    mass_kg: f64,
157) -> Result<Vec<TrajectorySample>, BallisticsError> {
158    // Use the input target distance as the limit for sampling
159    let max_dist = outputs.target_distance_horiz_m;
160    let num_steps = projected_sample_count(max_dist, step_m)?;
161    if num_steps == 0 {
162        return Ok(Vec::new());
163    }
164    let step_size = step_m.max(0.1);
165
166    // Extract trajectory arrays for vectorized operations (McCoy: X=downrange, Z=lateral)
167    let downrange_vals: Vec<f64> = trajectory_data.positions.iter().map(|p| p.x).collect();
168    let y_vals: Vec<f64> = trajectory_data.positions.iter().map(|p| p.y).collect();
169    let lateral_vals: Vec<f64> = trajectory_data.positions.iter().map(|p| p.z).collect();
170
171    // Calculate speed at each integration knot.
172    let speeds: Vec<f64> = trajectory_data
173        .velocities
174        .iter()
175        .map(|v| v.norm())
176        .collect();
177
178    // Generate sampling distances. `num_steps` was checked before any sampler allocation.
179    let distances: Vec<f64> = (0..num_steps)
180        .map(|i| i as f64 * step_size)
181        .filter(|&d| d <= max_dist + 0.1) // Stop exactly at target (with tiny tolerance for rounding)
182        .collect();
183
184    // Vectorized interpolation for all trajectory data
185    let mut samples = Vec::with_capacity(distances.len());
186
187    for &distance in &distances {
188        // Interpolate using X (downrange) as the independent variable
189        // McCoy coordinate system: x=downrange, y=vertical, z=lateral (wind drift)
190        let y_interp = interpolate(&downrange_vals, &y_vals, distance); // vertical at downrange distance
191        let wind_drift = interpolate(&downrange_vals, &lateral_vals, distance); // lateral drift at downrange distance
192        let velocity = interpolate(&downrange_vals, &speeds, distance); // velocity at downrange distance
193        let time = interpolate(&downrange_vals, &trajectory_data.times, distance); // time at downrange distance
194        let energy = 0.5 * mass_kg * velocity * velocity;
195
196        // Calculate line-of-sight y-coordinate and drop
197        // The LOS is a straight line from the SIGHT to the target
198        // The sight is at y = sight_height_m above the bore (which starts at y = 0)
199        // For a flat shot: LOS is horizontal at y = sight_height_m
200        // For elevated/depressed shots: LOS slopes from sight_height_m to target_vertical_height_m
201        //
202        // Drop convention:
203        // - Positive drop means bullet is below LOS (has dropped)
204        // - Negative drop means bullet is above LOS (has risen)
205        // Therefore: drop = LOS - actual (not actual - LOS)
206        //
207        // LOS interpolation: starts at sight_height_m (z=0), ends at target_vertical_height_m (z=max_dist)
208        // Note: For a properly zeroed flat shot, target_vertical_height_m should equal sight_height_m
209        // (bullet ends at LOS at target distance for a point-blank shot)
210        let los_y = outputs.sight_height_m
211            + (outputs.target_vertical_height_m - outputs.sight_height_m) * distance / max_dist;
212        let drop = los_y - y_interp; // LOS - actual: positive when bullet is below LOS
213
214        samples.push(TrajectorySample {
215            distance_m: distance,
216            drop_m: drop,
217            wind_drift_m: wind_drift,
218            velocity_mps: velocity,
219            energy_j: energy,
220            time_s: time,
221            flags: Vec::new(), // Flags will be added later
222        });
223    }
224
225    // Add flags using vectorized detection
226    add_trajectory_flags(&mut samples, &trajectory_data.transonic_distances, max_dist);
227
228    Ok(samples)
229}
230
231/// Linear interpolation function optimized for trajectory data
232fn interpolate(x_vals: &[f64], y_vals: &[f64], x: f64) -> f64 {
233    if x_vals.is_empty() || y_vals.is_empty() {
234        return 0.0;
235    }
236
237    if x_vals.len() != y_vals.len() {
238        return 0.0;
239    }
240
241    if x <= x_vals[0] {
242        return y_vals[0];
243    }
244
245    if x >= x_vals[x_vals.len() - 1] {
246        return y_vals[y_vals.len() - 1];
247    }
248
249    // Binary search for the correct interval
250    let mut left = 0;
251    let mut right = x_vals.len() - 1;
252
253    while right - left > 1 {
254        let mid = (left + right) / 2;
255        if x_vals[mid] <= x {
256            left = mid;
257        } else {
258            right = mid;
259        }
260    }
261
262    // Linear interpolation
263    let x1 = x_vals[left];
264    let x2 = x_vals[right];
265    let y1 = y_vals[left];
266    let y2 = y_vals[right];
267
268    if (x2 - x1).abs() < f64::EPSILON {
269        return y1;
270    }
271
272    y1 + (y2 - y1) * (x - x1) / (x2 - x1)
273}
274
275/// Add trajectory flags using vectorized detection algorithms
276fn add_trajectory_flags(
277    samples: &mut [TrajectorySample],
278    transonic_distances: &[f64],
279    target_distance_input_m: f64,
280) {
281    let tolerance = 1e-6;
282
283    // 1. Zero crossings - vectorized detection
284    detect_zero_crossings(samples, tolerance);
285
286    // 2. Mach transitions
287    for &transonic_dist in transonic_distances {
288        if let Some(idx) = find_closest_sample_index(samples, transonic_dist) {
289            samples[idx].flags.push(TrajectoryFlag::MachTransition);
290        }
291    }
292
293    // 3. Apex - find the point with maximum height between muzzle and target
294    // Since drop is positive when bullet is below LOS and negative when above,
295    // the apex is where drop is minimum (most negative)
296    if samples.len() > 2 {
297        // Use the target distance passed as parameter
298        let target_distance_m = target_distance_input_m;
299
300        // Find the index of maximum height (minimum drop, most negative) within target distance.
301        // Only mark an interior apex if it is actually above the muzzle/first sample.
302        let first_drop = samples[0].drop_m;
303        let mut min_drop = first_drop;
304        let mut apex_idx: Option<usize> = None;
305
306        // Search from index 1, but stop at target distance
307        for (i, sample) in samples.iter().enumerate().skip(1) {
308            // Only consider points up to target distance
309            if sample.distance_m > target_distance_m {
310                break;
311            }
312
313            if sample.drop_m < min_drop {
314                min_drop = sample.drop_m;
315                apex_idx = Some(i);
316            }
317        }
318
319        if let Some(idx) = apex_idx {
320            samples[idx].flags.push(TrajectoryFlag::Apex);
321        }
322    }
323}
324
325/// Detect zero crossings in trajectory drop values using vectorized operations
326fn detect_zero_crossings(samples: &mut [TrajectorySample], tolerance: f64) {
327    if samples.len() < 2 {
328        return;
329    }
330
331    let drops: Vec<f64> = samples.iter().map(|s| s.drop_m).collect();
332
333    // Find crossing indices where drop changes sign
334    for i in 0..(drops.len() - 1) {
335        let current = drops[i];
336        let next = drops[i + 1];
337
338        // Check for sign change crossings
339        let crosses_zero = (current < -tolerance && next >= -tolerance)
340            || (current > tolerance && next <= tolerance);
341
342        if crosses_zero {
343            samples[i + 1].flags.push(TrajectoryFlag::ZeroCrossing);
344        }
345    }
346
347    // Find points very close to zero
348    for (i, &drop) in drops.iter().enumerate() {
349        if drop.abs() <= tolerance {
350            samples[i].flags.push(TrajectoryFlag::ZeroCrossing);
351        }
352    }
353
354    // Remove duplicate zero crossing flags
355    for sample in samples.iter_mut() {
356        let mut unique_flags = Vec::new();
357        let mut seen = HashSet::new();
358
359        for flag in &sample.flags {
360            if seen.insert(flag.clone()) {
361                unique_flags.push(flag.clone());
362            }
363        }
364        sample.flags = unique_flags;
365    }
366}
367
368/// Find the closest sample index to a given distance
369fn find_closest_sample_index(samples: &[TrajectorySample], target_distance: f64) -> Option<usize> {
370    if samples.is_empty() {
371        return None;
372    }
373
374    // Binary search for the closest distance
375    let distances: Vec<f64> = samples.iter().map(|s| s.distance_m).collect();
376
377    let mut left = 0;
378    let mut right = distances.len();
379
380    while left < right {
381        let mid = (left + right) / 2;
382        if distances[mid] < target_distance {
383            left = mid + 1;
384        } else {
385            right = mid;
386        }
387    }
388
389    // Find the closest point (could be left-1 or left)
390    let mut best_idx = left.min(distances.len() - 1);
391
392    if left > 0 {
393        let left_dist = (distances[left - 1] - target_distance).abs();
394        let right_dist = (distances[best_idx] - target_distance).abs();
395
396        // Prefer earlier index in case of tie
397        if left_dist <= right_dist {
398            best_idx = left - 1;
399        }
400    }
401
402    Some(best_idx)
403}
404
405/// Convert trajectory samples to Python-compatible format
406pub fn trajectory_samples_to_dicts(samples: &[TrajectorySample]) -> Vec<TrajectoryDict> {
407    samples
408        .iter()
409        .map(|sample| TrajectoryDict {
410            distance_m: sample.distance_m,
411            drop_m: sample.drop_m,
412            wind_drift_m: sample.wind_drift_m,
413            velocity_mps: sample.velocity_mps,
414            energy_j: sample.energy_j,
415            time_s: sample.time_s,
416            flags: sample.flags.iter().map(|f| f.to_string()).collect(),
417        })
418        .collect()
419}
420
421/// Python-compatible trajectory sample structure
422#[derive(Debug, Clone)]
423pub struct TrajectoryDict {
424    pub distance_m: f64,
425    pub drop_m: f64,
426    pub wind_drift_m: f64,
427    pub velocity_mps: f64,
428    pub energy_j: f64,
429    pub time_s: f64,
430    pub flags: Vec<String>,
431}
432
433#[cfg(test)]
434mod tests {
435    use super::*;
436
437    fn linear_fixture(max_dist: f64) -> (TrajectoryData, TrajectoryOutputs) {
438        (
439            TrajectoryData {
440                times: vec![0.0, 1.0],
441                positions: vec![
442                    Vector3::new(0.0, -1.0, 0.0),
443                    Vector3::new(max_dist, -1.0, 0.0),
444                ],
445                velocities: vec![
446                    Vector3::new(800.0, 0.0, 0.0),
447                    Vector3::new(700.0, 0.0, 0.0),
448                ],
449                transonic_distances: vec![],
450                mach_1_2_distance_m: None,
451                mach_1_0_distance_m: None,
452                mach_0_9_distance_m: None,
453            },
454            TrajectoryOutputs {
455                target_distance_horiz_m: max_dist,
456                target_vertical_height_m: 0.0,
457                time_of_flight_s: 1.0,
458                max_ord_dist_horiz_m: 0.0,
459                sight_height_m: 0.0,
460            },
461        )
462    }
463
464    #[test]
465    fn mba1299_projected_sample_count_checks_exact_limit_and_overflow() {
466        assert_eq!(MAX_TRAJECTORY_SAMPLES, crate::MAX_TRAJECTORY_POINTS);
467        assert_eq!(
468            projected_sample_count((MAX_TRAJECTORY_SAMPLES - 1) as f64, 1.0)
469                .expect("the exact sample cap should be accepted"),
470            MAX_TRAJECTORY_SAMPLES
471        );
472        assert_eq!(
473            projected_sample_count(MAX_TRAJECTORY_SAMPLES as f64 - 0.5, 1.0)
474                .expect("a filtered final candidate must not reject an exact-cap grid"),
475            MAX_TRAJECTORY_SAMPLES
476        );
477        assert_eq!(
478            projected_sample_count(0.2, 0.01)
479                .expect("the historical 0.1 meter interval floor should remain valid"),
480            3
481        );
482
483        for (range, interval) in [
484            (MAX_TRAJECTORY_SAMPLES as f64, 1.0),
485            (f64::MAX, 0.1),
486        ] {
487            let error = projected_sample_count(range, interval)
488                .expect_err("a grid above the sample cap must fail");
489            assert!(
490                error
491                    .to_string()
492                    .contains("trajectory sample limit of 250000 exceeded"),
493                "unexpected sampling limit error: {error}"
494            );
495        }
496    }
497
498    #[test]
499    fn mba1299_public_sampler_accepts_the_exact_cap() {
500        for max_dist in [
501            (MAX_TRAJECTORY_SAMPLES - 1) as f64,
502            MAX_TRAJECTORY_SAMPLES as f64 - 0.5,
503        ] {
504            let (trajectory_data, outputs) = linear_fixture(max_dist);
505            let samples = sample_trajectory(&trajectory_data, &outputs, 1.0, 0.01)
506                .expect("an exact-cap sample grid should succeed");
507
508            assert_eq!(samples.len(), MAX_TRAJECTORY_SAMPLES);
509            assert_eq!(samples.first().expect("muzzle sample").distance_m, 0.0);
510            assert_eq!(
511                samples.last().expect("terminal sample").distance_m,
512                (MAX_TRAJECTORY_SAMPLES - 1) as f64
513            );
514        }
515    }
516
517    #[test]
518    fn mba1299_public_sampler_rejects_oversized_grids_before_allocation() {
519        for max_dist in [MAX_TRAJECTORY_SAMPLES as f64, f64::MAX] {
520            let (trajectory_data, outputs) = linear_fixture(max_dist);
521            let error = sample_trajectory(&trajectory_data, &outputs, 1.0, 0.01)
522                .expect_err("an oversized public sampling request must fail");
523            assert!(
524                error
525                    .to_string()
526                    .contains("trajectory sample limit of 250000 exceeded"),
527                "unexpected sampling limit error: {error}"
528            );
529        }
530    }
531
532    #[test]
533    fn test_interpolate() {
534        let x_vals = vec![0.0, 1.0, 2.0, 3.0];
535        let y_vals = vec![0.0, 10.0, 20.0, 30.0];
536
537        assert_eq!(interpolate(&x_vals, &y_vals, 0.5), 5.0);
538        assert_eq!(interpolate(&x_vals, &y_vals, 1.5), 15.0);
539        assert_eq!(interpolate(&x_vals, &y_vals, 2.5), 25.0);
540
541        // Test boundary conditions
542        assert_eq!(interpolate(&x_vals, &y_vals, -1.0), 0.0); // Below range
543        assert_eq!(interpolate(&x_vals, &y_vals, 4.0), 30.0); // Above range
544    }
545
546    #[test]
547    fn test_find_closest_sample_index() {
548        let samples = vec![
549            TrajectorySample {
550                distance_m: 0.0,
551                drop_m: 0.0,
552                wind_drift_m: 0.0,
553                velocity_mps: 100.0,
554                energy_j: 1000.0,
555                time_s: 0.0,
556                flags: Vec::new(),
557            },
558            TrajectorySample {
559                distance_m: 10.0,
560                drop_m: -1.0,
561                wind_drift_m: 0.1,
562                velocity_mps: 95.0,
563                energy_j: 950.0,
564                time_s: 0.1,
565                flags: Vec::new(),
566            },
567            TrajectorySample {
568                distance_m: 20.0,
569                drop_m: -4.0,
570                wind_drift_m: 0.2,
571                velocity_mps: 90.0,
572                energy_j: 900.0,
573                time_s: 0.2,
574                flags: Vec::new(),
575            },
576        ];
577
578        assert_eq!(find_closest_sample_index(&samples, 5.0), Some(0));
579        assert_eq!(find_closest_sample_index(&samples, 12.0), Some(1));
580        assert_eq!(find_closest_sample_index(&samples, 18.0), Some(2));
581    }
582
583    #[test]
584    fn test_detect_zero_crossings() {
585        let mut samples = vec![
586            TrajectorySample {
587                distance_m: 0.0,
588                drop_m: 1.0, // Positive
589                wind_drift_m: 0.0,
590                velocity_mps: 100.0,
591                energy_j: 1000.0,
592                time_s: 0.0,
593                flags: Vec::new(),
594            },
595            TrajectorySample {
596                distance_m: 10.0,
597                drop_m: -0.5, // Negative - crossing here
598                wind_drift_m: 0.1,
599                velocity_mps: 95.0,
600                energy_j: 950.0,
601                time_s: 0.1,
602                flags: Vec::new(),
603            },
604            TrajectorySample {
605                distance_m: 20.0,
606                drop_m: -2.0, // Still negative
607                wind_drift_m: 0.2,
608                velocity_mps: 90.0,
609                energy_j: 900.0,
610                time_s: 0.2,
611                flags: Vec::new(),
612            },
613        ];
614
615        detect_zero_crossings(&mut samples, 1e-6);
616
617        // Should have a zero crossing flag at index 1
618        assert!(!samples[0].flags.contains(&TrajectoryFlag::ZeroCrossing));
619        assert!(samples[1].flags.contains(&TrajectoryFlag::ZeroCrossing));
620        assert!(!samples[2].flags.contains(&TrajectoryFlag::ZeroCrossing));
621    }
622
623    #[test]
624    fn test_sample_trajectory_basic() {
625        // Create simple test trajectory data
626        // McCoy coordinate system: x=downrange, y=vertical, z=lateral (wind drift)
627        let trajectory_data = TrajectoryData {
628            times: vec![0.0, 1.0, 2.0],
629            positions: vec![
630                Vector3::new(0.0, 0.0, 0.0), // x=0 (start), y=0 (vertical), z=0 (no drift)
631                Vector3::new(100.0, 10.0, 1.0), // x=100 (mid downrange), y=10 (apex height), z=1 (drift)
632                Vector3::new(200.0, 5.0, 2.0), // x=200 (end downrange), y=5 (below apex), z=2 (drift)
633            ],
634            velocities: vec![
635                Vector3::new(1.0, 10.0, 100.0),
636                Vector3::new(1.0, 5.0, 95.0),
637                Vector3::new(1.0, 0.0, 90.0),
638            ],
639            transonic_distances: vec![150.0],
640            mach_1_2_distance_m: None,
641            mach_1_0_distance_m: Some(150.0),
642            mach_0_9_distance_m: None,
643        };
644
645        let outputs = TrajectoryOutputs {
646            target_distance_horiz_m: 200.0,
647            target_vertical_height_m: 0.0,
648            time_of_flight_s: 2.0,
649            max_ord_dist_horiz_m: 100.0,
650            sight_height_m: 0.0, // For test: assume bore-referenced coordinates
651        };
652
653        let samples = sample_trajectory(&trajectory_data, &outputs, 50.0, 0.1)
654            .expect("normal sampling should succeed");
655
656        // Should have samples at 0, 50, 100, 150, 200 meters
657        assert_eq!(samples.len(), 5);
658        assert_eq!(samples[0].distance_m, 0.0);
659        assert_eq!(samples[1].distance_m, 50.0);
660        assert_eq!(samples[2].distance_m, 100.0);
661        assert_eq!(samples[3].distance_m, 150.0);
662        assert_eq!(samples[4].distance_m, 200.0);
663
664        // Check that interpolation is working
665        assert!(samples[1].velocity_mps > 90.0 && samples[1].velocity_mps < 100.0);
666
667        // Check flags
668        assert!(samples[2].flags.contains(&TrajectoryFlag::Apex)); // At apex distance
669        assert!(samples[3].flags.contains(&TrajectoryFlag::MachTransition)); // At transonic distance
670    }
671
672    #[test]
673    fn sampled_energy_is_derived_from_interpolated_speed() {
674        let mass_kg = 0.01;
675        let trajectory_data = TrajectoryData {
676            times: vec![0.0, 1.0],
677            positions: vec![Vector3::zeros(), Vector3::new(100.0, 0.0, 0.0)],
678            velocities: vec![Vector3::new(800.0, 0.0, 0.0), Vector3::new(700.0, 0.0, 0.0)],
679            transonic_distances: vec![],
680            mach_1_2_distance_m: None,
681            mach_1_0_distance_m: None,
682            mach_0_9_distance_m: None,
683        };
684        let outputs = TrajectoryOutputs {
685            target_distance_horiz_m: 100.0,
686            target_vertical_height_m: 0.0,
687            time_of_flight_s: 1.0,
688            max_ord_dist_horiz_m: 0.0,
689            sight_height_m: 0.0,
690        };
691
692        let samples = sample_trajectory(&trajectory_data, &outputs, 50.0, mass_kg)
693            .expect("normal sampling should succeed");
694        assert_eq!(samples.len(), 3);
695        assert_eq!(samples[1].velocity_mps.to_bits(), 750.0_f64.to_bits());
696        assert_eq!(samples[1].energy_j.to_bits(), 2812.5_f64.to_bits());
697        for sample in samples {
698            let expected_energy = 0.5 * mass_kg * sample.velocity_mps * sample.velocity_mps;
699            assert_eq!(sample.energy_j.to_bits(), expected_energy.to_bits());
700        }
701    }
702}