Skip to main content

ballistics_engine/
trajectory_sampling.rs

1use nalgebra::Vector3;
2use std::collections::HashSet;
3
4/// Trajectory flags for notable events
5#[derive(Debug, Clone, PartialEq, Eq, Hash)]
6pub enum TrajectoryFlag {
7    ZeroCrossing,
8    MachTransition,
9    Apex,
10}
11
12impl TrajectoryFlag {
13    pub fn to_string(&self) -> String {
14        match self {
15            TrajectoryFlag::ZeroCrossing => "zero_crossing".to_string(),
16            TrajectoryFlag::MachTransition => "mach_transition".to_string(),
17            TrajectoryFlag::Apex => "apex".to_string(),
18        }
19    }
20}
21
22/// Single trajectory sample point
23#[derive(Debug, Clone)]
24pub struct TrajectorySample {
25    pub distance_m: f64,
26    pub drop_m: f64,
27    pub wind_drift_m: f64,
28    pub velocity_mps: f64,
29    pub energy_j: f64,
30    pub time_s: f64,
31    pub flags: Vec<TrajectoryFlag>,
32}
33
34/// Trajectory solution data for sampling
35#[derive(Debug, Clone)]
36pub struct TrajectoryData {
37    pub times: Vec<f64>,
38    pub positions: Vec<Vector3<f64>>,  // [x, y, z] positions
39    pub velocities: Vec<Vector3<f64>>, // [vx, vy, vz] velocities
40    pub transonic_distances: Vec<f64>, // Distances where mach transitions occur
41}
42
43/// Output data for trajectory sampling
44#[derive(Debug, Clone)]
45pub struct TrajectoryOutputs {
46    pub target_distance_horiz_m: f64,
47    pub target_vertical_height_m: f64,
48    pub time_of_flight_s: f64,
49    pub max_ord_dist_horiz_m: f64,
50    /// Height of sight above bore (meters). Used for LOS calculation.
51    /// For a flat shot, the LOS is horizontal at y = sight_height_m.
52    pub sight_height_m: f64,
53}
54
55/// Sample trajectory at regular distance intervals with vectorized operations
56pub fn sample_trajectory(
57    trajectory_data: &TrajectoryData,
58    outputs: &TrajectoryOutputs,
59    step_m: f64,
60    mass_kg: f64,
61) -> Vec<TrajectorySample> {
62    let step_size = if step_m <= 0.0 {
63        return Vec::new();
64    } else if step_m < 0.1 {
65        0.1
66    } else {
67        step_m
68    };
69
70    // Use the input target distance as the limit for sampling
71    let max_dist = outputs.target_distance_horiz_m;
72    if max_dist < 1e-9 {
73        return Vec::new();
74    }
75
76    // Extract trajectory arrays for vectorized operations (McCoy: X=downrange, Z=lateral)
77    let downrange_vals: Vec<f64> = trajectory_data.positions.iter().map(|p| p.x).collect();
78    let y_vals: Vec<f64> = trajectory_data.positions.iter().map(|p| p.y).collect();
79    let lateral_vals: Vec<f64> = trajectory_data.positions.iter().map(|p| p.z).collect();
80
81    // Calculate speed at each integration knot.
82    let speeds: Vec<f64> = trajectory_data
83        .velocities
84        .iter()
85        .map(|v| v.norm())
86        .collect();
87
88    // Generate sampling distances
89    // Calculate number of steps to reach target without exceeding it
90    let num_steps = (max_dist / step_size).ceil() as usize + 1;
91    let distances: Vec<f64> = (0..num_steps)
92        .map(|i| i as f64 * step_size)
93        .filter(|&d| d <= max_dist + 0.1) // Stop exactly at target (with tiny tolerance for rounding)
94        .collect();
95
96    // Vectorized interpolation for all trajectory data
97    let mut samples = Vec::with_capacity(distances.len());
98
99    for &distance in &distances {
100        // Interpolate using X (downrange) as the independent variable
101        // McCoy coordinate system: x=downrange, y=vertical, z=lateral (wind drift)
102        let y_interp = interpolate(&downrange_vals, &y_vals, distance); // vertical at downrange distance
103        let wind_drift = interpolate(&downrange_vals, &lateral_vals, distance); // lateral drift at downrange distance
104        let velocity = interpolate(&downrange_vals, &speeds, distance); // velocity at downrange distance
105        let time = interpolate(&downrange_vals, &trajectory_data.times, distance); // time at downrange distance
106        let energy = 0.5 * mass_kg * velocity * velocity;
107
108        // Calculate line-of-sight y-coordinate and drop
109        // The LOS is a straight line from the SIGHT to the target
110        // The sight is at y = sight_height_m above the bore (which starts at y = 0)
111        // For a flat shot: LOS is horizontal at y = sight_height_m
112        // For elevated/depressed shots: LOS slopes from sight_height_m to target_vertical_height_m
113        //
114        // Drop convention:
115        // - Positive drop means bullet is below LOS (has dropped)
116        // - Negative drop means bullet is above LOS (has risen)
117        // Therefore: drop = LOS - actual (not actual - LOS)
118        //
119        // LOS interpolation: starts at sight_height_m (z=0), ends at target_vertical_height_m (z=max_dist)
120        // Note: For a properly zeroed flat shot, target_vertical_height_m should equal sight_height_m
121        // (bullet ends at LOS at target distance for a point-blank shot)
122        let los_y = outputs.sight_height_m
123            + (outputs.target_vertical_height_m - outputs.sight_height_m) * distance / max_dist;
124        let drop = los_y - y_interp; // LOS - actual: positive when bullet is below LOS
125
126        samples.push(TrajectorySample {
127            distance_m: distance,
128            drop_m: drop,
129            wind_drift_m: wind_drift,
130            velocity_mps: velocity,
131            energy_j: energy,
132            time_s: time,
133            flags: Vec::new(), // Flags will be added later
134        });
135    }
136
137    // Add flags using vectorized detection
138    add_trajectory_flags(&mut samples, &trajectory_data.transonic_distances, max_dist);
139
140    samples
141}
142
143/// Linear interpolation function optimized for trajectory data
144fn interpolate(x_vals: &[f64], y_vals: &[f64], x: f64) -> f64 {
145    if x_vals.is_empty() || y_vals.is_empty() {
146        return 0.0;
147    }
148
149    if x_vals.len() != y_vals.len() {
150        return 0.0;
151    }
152
153    if x <= x_vals[0] {
154        return y_vals[0];
155    }
156
157    if x >= x_vals[x_vals.len() - 1] {
158        return y_vals[y_vals.len() - 1];
159    }
160
161    // Binary search for the correct interval
162    let mut left = 0;
163    let mut right = x_vals.len() - 1;
164
165    while right - left > 1 {
166        let mid = (left + right) / 2;
167        if x_vals[mid] <= x {
168            left = mid;
169        } else {
170            right = mid;
171        }
172    }
173
174    // Linear interpolation
175    let x1 = x_vals[left];
176    let x2 = x_vals[right];
177    let y1 = y_vals[left];
178    let y2 = y_vals[right];
179
180    if (x2 - x1).abs() < f64::EPSILON {
181        return y1;
182    }
183
184    y1 + (y2 - y1) * (x - x1) / (x2 - x1)
185}
186
187/// Add trajectory flags using vectorized detection algorithms
188fn add_trajectory_flags(
189    samples: &mut [TrajectorySample],
190    transonic_distances: &[f64],
191    target_distance_input_m: f64,
192) {
193    let tolerance = 1e-6;
194
195    // 1. Zero crossings - vectorized detection
196    detect_zero_crossings(samples, tolerance);
197
198    // 2. Mach transitions
199    for &transonic_dist in transonic_distances {
200        if let Some(idx) = find_closest_sample_index(samples, transonic_dist) {
201            samples[idx].flags.push(TrajectoryFlag::MachTransition);
202        }
203    }
204
205    // 3. Apex - find the point with maximum height between muzzle and target
206    // Since drop is positive when bullet is below LOS and negative when above,
207    // the apex is where drop is minimum (most negative)
208    if samples.len() > 2 {
209        // Use the target distance passed as parameter
210        let target_distance_m = target_distance_input_m;
211
212        // Find the index of maximum height (minimum drop, most negative) within target distance.
213        // Only mark an interior apex if it is actually above the muzzle/first sample.
214        let first_drop = samples[0].drop_m;
215        let mut min_drop = first_drop;
216        let mut apex_idx: Option<usize> = None;
217
218        // Search from index 1, but stop at target distance
219        for i in 1..samples.len() {
220            // Only consider points up to target distance
221            if samples[i].distance_m > target_distance_m {
222                break;
223            }
224
225            if samples[i].drop_m < min_drop {
226                min_drop = samples[i].drop_m;
227                apex_idx = Some(i);
228            }
229        }
230
231        if let Some(idx) = apex_idx {
232            samples[idx].flags.push(TrajectoryFlag::Apex);
233        }
234    }
235}
236
237/// Detect zero crossings in trajectory drop values using vectorized operations
238fn detect_zero_crossings(samples: &mut [TrajectorySample], tolerance: f64) {
239    if samples.len() < 2 {
240        return;
241    }
242
243    let drops: Vec<f64> = samples.iter().map(|s| s.drop_m).collect();
244
245    // Find crossing indices where drop changes sign
246    for i in 0..(drops.len() - 1) {
247        let current = drops[i];
248        let next = drops[i + 1];
249
250        // Check for sign change crossings
251        let crosses_zero = (current < -tolerance && next >= -tolerance)
252            || (current > tolerance && next <= tolerance);
253
254        if crosses_zero {
255            samples[i + 1].flags.push(TrajectoryFlag::ZeroCrossing);
256        }
257    }
258
259    // Find points very close to zero
260    for (i, &drop) in drops.iter().enumerate() {
261        if drop.abs() <= tolerance {
262            samples[i].flags.push(TrajectoryFlag::ZeroCrossing);
263        }
264    }
265
266    // Remove duplicate zero crossing flags
267    for sample in samples.iter_mut() {
268        let mut unique_flags = Vec::new();
269        let mut seen = HashSet::new();
270
271        for flag in &sample.flags {
272            if seen.insert(flag.clone()) {
273                unique_flags.push(flag.clone());
274            }
275        }
276        sample.flags = unique_flags;
277    }
278}
279
280/// Find the closest sample index to a given distance
281fn find_closest_sample_index(samples: &[TrajectorySample], target_distance: f64) -> Option<usize> {
282    if samples.is_empty() {
283        return None;
284    }
285
286    // Binary search for the closest distance
287    let distances: Vec<f64> = samples.iter().map(|s| s.distance_m).collect();
288
289    let mut left = 0;
290    let mut right = distances.len();
291
292    while left < right {
293        let mid = (left + right) / 2;
294        if distances[mid] < target_distance {
295            left = mid + 1;
296        } else {
297            right = mid;
298        }
299    }
300
301    // Find the closest point (could be left-1 or left)
302    let mut best_idx = left.min(distances.len() - 1);
303
304    if left > 0 {
305        let left_dist = (distances[left - 1] - target_distance).abs();
306        let right_dist = (distances[best_idx] - target_distance).abs();
307
308        // Prefer earlier index in case of tie
309        if left_dist <= right_dist {
310            best_idx = left - 1;
311        }
312    }
313
314    Some(best_idx)
315}
316
317/// Convert trajectory samples to Python-compatible format
318pub fn trajectory_samples_to_dicts(samples: &[TrajectorySample]) -> Vec<TrajectoryDict> {
319    samples
320        .iter()
321        .map(|sample| TrajectoryDict {
322            distance_m: sample.distance_m,
323            drop_m: sample.drop_m,
324            wind_drift_m: sample.wind_drift_m,
325            velocity_mps: sample.velocity_mps,
326            energy_j: sample.energy_j,
327            time_s: sample.time_s,
328            flags: sample.flags.iter().map(|f| f.to_string()).collect(),
329        })
330        .collect()
331}
332
333/// Python-compatible trajectory sample structure
334#[derive(Debug, Clone)]
335pub struct TrajectoryDict {
336    pub distance_m: f64,
337    pub drop_m: f64,
338    pub wind_drift_m: f64,
339    pub velocity_mps: f64,
340    pub energy_j: f64,
341    pub time_s: f64,
342    pub flags: Vec<String>,
343}
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348
349    #[test]
350    fn test_interpolate() {
351        let x_vals = vec![0.0, 1.0, 2.0, 3.0];
352        let y_vals = vec![0.0, 10.0, 20.0, 30.0];
353
354        assert_eq!(interpolate(&x_vals, &y_vals, 0.5), 5.0);
355        assert_eq!(interpolate(&x_vals, &y_vals, 1.5), 15.0);
356        assert_eq!(interpolate(&x_vals, &y_vals, 2.5), 25.0);
357
358        // Test boundary conditions
359        assert_eq!(interpolate(&x_vals, &y_vals, -1.0), 0.0); // Below range
360        assert_eq!(interpolate(&x_vals, &y_vals, 4.0), 30.0); // Above range
361    }
362
363    #[test]
364    fn test_find_closest_sample_index() {
365        let samples = vec![
366            TrajectorySample {
367                distance_m: 0.0,
368                drop_m: 0.0,
369                wind_drift_m: 0.0,
370                velocity_mps: 100.0,
371                energy_j: 1000.0,
372                time_s: 0.0,
373                flags: Vec::new(),
374            },
375            TrajectorySample {
376                distance_m: 10.0,
377                drop_m: -1.0,
378                wind_drift_m: 0.1,
379                velocity_mps: 95.0,
380                energy_j: 950.0,
381                time_s: 0.1,
382                flags: Vec::new(),
383            },
384            TrajectorySample {
385                distance_m: 20.0,
386                drop_m: -4.0,
387                wind_drift_m: 0.2,
388                velocity_mps: 90.0,
389                energy_j: 900.0,
390                time_s: 0.2,
391                flags: Vec::new(),
392            },
393        ];
394
395        assert_eq!(find_closest_sample_index(&samples, 5.0), Some(0));
396        assert_eq!(find_closest_sample_index(&samples, 12.0), Some(1));
397        assert_eq!(find_closest_sample_index(&samples, 18.0), Some(2));
398    }
399
400    #[test]
401    fn test_detect_zero_crossings() {
402        let mut samples = vec![
403            TrajectorySample {
404                distance_m: 0.0,
405                drop_m: 1.0, // Positive
406                wind_drift_m: 0.0,
407                velocity_mps: 100.0,
408                energy_j: 1000.0,
409                time_s: 0.0,
410                flags: Vec::new(),
411            },
412            TrajectorySample {
413                distance_m: 10.0,
414                drop_m: -0.5, // Negative - crossing here
415                wind_drift_m: 0.1,
416                velocity_mps: 95.0,
417                energy_j: 950.0,
418                time_s: 0.1,
419                flags: Vec::new(),
420            },
421            TrajectorySample {
422                distance_m: 20.0,
423                drop_m: -2.0, // Still negative
424                wind_drift_m: 0.2,
425                velocity_mps: 90.0,
426                energy_j: 900.0,
427                time_s: 0.2,
428                flags: Vec::new(),
429            },
430        ];
431
432        detect_zero_crossings(&mut samples, 1e-6);
433
434        // Should have a zero crossing flag at index 1
435        assert!(!samples[0].flags.contains(&TrajectoryFlag::ZeroCrossing));
436        assert!(samples[1].flags.contains(&TrajectoryFlag::ZeroCrossing));
437        assert!(!samples[2].flags.contains(&TrajectoryFlag::ZeroCrossing));
438    }
439
440    #[test]
441    fn test_sample_trajectory_basic() {
442        // Create simple test trajectory data
443        // McCoy coordinate system: x=downrange, y=vertical, z=lateral (wind drift)
444        let trajectory_data = TrajectoryData {
445            times: vec![0.0, 1.0, 2.0],
446            positions: vec![
447                Vector3::new(0.0, 0.0, 0.0), // x=0 (start), y=0 (vertical), z=0 (no drift)
448                Vector3::new(100.0, 10.0, 1.0), // x=100 (mid downrange), y=10 (apex height), z=1 (drift)
449                Vector3::new(200.0, 5.0, 2.0), // x=200 (end downrange), y=5 (below apex), z=2 (drift)
450            ],
451            velocities: vec![
452                Vector3::new(1.0, 10.0, 100.0),
453                Vector3::new(1.0, 5.0, 95.0),
454                Vector3::new(1.0, 0.0, 90.0),
455            ],
456            transonic_distances: vec![150.0],
457        };
458
459        let outputs = TrajectoryOutputs {
460            target_distance_horiz_m: 200.0,
461            target_vertical_height_m: 0.0,
462            time_of_flight_s: 2.0,
463            max_ord_dist_horiz_m: 100.0,
464            sight_height_m: 0.0, // For test: assume bore-referenced coordinates
465        };
466
467        let samples = sample_trajectory(&trajectory_data, &outputs, 50.0, 0.1);
468
469        // Should have samples at 0, 50, 100, 150, 200 meters
470        assert_eq!(samples.len(), 5);
471        assert_eq!(samples[0].distance_m, 0.0);
472        assert_eq!(samples[1].distance_m, 50.0);
473        assert_eq!(samples[2].distance_m, 100.0);
474        assert_eq!(samples[3].distance_m, 150.0);
475        assert_eq!(samples[4].distance_m, 200.0);
476
477        // Check that interpolation is working
478        assert!(samples[1].velocity_mps > 90.0 && samples[1].velocity_mps < 100.0);
479
480        // Check flags
481        assert!(samples[2].flags.contains(&TrajectoryFlag::Apex)); // At apex distance
482        assert!(samples[3].flags.contains(&TrajectoryFlag::MachTransition)); // At transonic distance
483    }
484
485    #[test]
486    fn sampled_energy_is_derived_from_interpolated_speed() {
487        let mass_kg = 0.01;
488        let trajectory_data = TrajectoryData {
489            times: vec![0.0, 1.0],
490            positions: vec![Vector3::zeros(), Vector3::new(100.0, 0.0, 0.0)],
491            velocities: vec![Vector3::new(800.0, 0.0, 0.0), Vector3::new(700.0, 0.0, 0.0)],
492            transonic_distances: vec![],
493        };
494        let outputs = TrajectoryOutputs {
495            target_distance_horiz_m: 100.0,
496            target_vertical_height_m: 0.0,
497            time_of_flight_s: 1.0,
498            max_ord_dist_horiz_m: 0.0,
499            sight_height_m: 0.0,
500        };
501
502        let samples = sample_trajectory(&trajectory_data, &outputs, 50.0, mass_kg);
503        assert_eq!(samples.len(), 3);
504        assert_eq!(samples[1].velocity_mps.to_bits(), 750.0_f64.to_bits());
505        assert_eq!(samples[1].energy_j.to_bits(), 2812.5_f64.to_bits());
506        for sample in samples {
507            let expected_energy = 0.5 * mass_kg * sample.velocity_mps * sample.velocity_mps;
508            assert_eq!(sample.energy_j.to_bits(), expected_energy.to_bits());
509        }
510    }
511}