1use nalgebra::Vector3;
2use std::collections::HashSet;
3
4#[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#[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#[derive(Debug, Clone)]
36pub struct TrajectoryData {
37 pub times: Vec<f64>,
38 pub positions: Vec<Vector3<f64>>, pub velocities: Vec<Vector3<f64>>, pub transonic_distances: Vec<f64>, }
42
43#[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 pub sight_height_m: f64,
53}
54
55pub 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 let max_dist = outputs.target_distance_horiz_m;
72 if max_dist < 1e-9 {
73 return Vec::new();
74 }
75
76 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 let speeds: Vec<f64> = trajectory_data
83 .velocities
84 .iter()
85 .map(|v| v.norm())
86 .collect();
87
88 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) .collect();
95
96 let mut samples = Vec::with_capacity(distances.len());
98
99 for &distance in &distances {
100 let y_interp = interpolate(&downrange_vals, &y_vals, distance); let wind_drift = interpolate(&downrange_vals, &lateral_vals, distance); let velocity = interpolate(&downrange_vals, &speeds, distance); let time = interpolate(&downrange_vals, &trajectory_data.times, distance); let energy = 0.5 * mass_kg * velocity * velocity;
107
108 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; 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(), });
135 }
136
137 add_trajectory_flags(&mut samples, &trajectory_data.transonic_distances, max_dist);
139
140 samples
141}
142
143fn 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 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 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
187fn 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 detect_zero_crossings(samples, tolerance);
197
198 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 if samples.len() > 2 {
209 let target_distance_m = target_distance_input_m;
211
212 let first_drop = samples[0].drop_m;
215 let mut min_drop = first_drop;
216 let mut apex_idx: Option<usize> = None;
217
218 for i in 1..samples.len() {
220 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
237fn 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 for i in 0..(drops.len() - 1) {
247 let current = drops[i];
248 let next = drops[i + 1];
249
250 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 for (i, &drop) in drops.iter().enumerate() {
261 if drop.abs() <= tolerance {
262 samples[i].flags.push(TrajectoryFlag::ZeroCrossing);
263 }
264 }
265
266 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
280fn find_closest_sample_index(samples: &[TrajectorySample], target_distance: f64) -> Option<usize> {
282 if samples.is_empty() {
283 return None;
284 }
285
286 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 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 if left_dist <= right_dist {
310 best_idx = left - 1;
311 }
312 }
313
314 Some(best_idx)
315}
316
317pub 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#[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 assert_eq!(interpolate(&x_vals, &y_vals, -1.0), 0.0); assert_eq!(interpolate(&x_vals, &y_vals, 4.0), 30.0); }
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, 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, 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, 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 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 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), Vector3::new(100.0, 10.0, 1.0), Vector3::new(200.0, 5.0, 2.0), ],
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, };
466
467 let samples = sample_trajectory(&trajectory_data, &outputs, 50.0, 0.1);
468
469 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 assert!(samples[1].velocity_mps > 90.0 && samples[1].velocity_mps < 100.0);
479
480 assert!(samples[2].flags.contains(&TrajectoryFlag::Apex)); assert!(samples[3].flags.contains(&TrajectoryFlag::MachTransition)); }
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}