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