Skip to main content

ballistics_engine/
trajectory_sampling.rs

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