1use crate::cli_api::BallisticsError;
2use crate::trajectory_observation::{bracket_param, Bracket};
3use nalgebra::Vector3;
4use std::collections::HashSet;
5use std::fmt;
6
7pub const MAX_TRAJECTORY_SAMPLES: usize = 250_000;
12
13pub(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 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#[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 #[allow(clippy::inherent_to_string_shadow_display)] 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#[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#[derive(Debug, Clone)]
112pub struct TrajectoryData {
113 pub times: Vec<f64>,
114 pub positions: Vec<Vector3<f64>>, pub velocities: Vec<Vector3<f64>>, pub transonic_distances: Vec<f64>,
120 pub mach_1_2_distance_m: Option<f64>,
123 pub mach_1_0_distance_m: Option<f64>,
126 pub mach_0_9_distance_m: Option<f64>,
130}
131
132#[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 pub sight_height_m: f64,
142}
143
144pub fn sample_trajectory(
151 trajectory_data: &TrajectoryData,
152 outputs: &TrajectoryOutputs,
153 step_m: f64,
154 mass_kg: f64,
155) -> Result<Vec<TrajectorySample>, BallisticsError> {
156 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 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 let speeds: Vec<f64> = trajectory_data
171 .velocities
172 .iter()
173 .map(|v| v.norm())
174 .collect();
175
176 let distances: Vec<f64> = (0..num_steps)
178 .map(|i| i as f64 * step_size)
179 .filter(|&d| d <= max_dist + 0.1) .collect();
181
182 let mut samples = Vec::with_capacity(distances.len());
184
185 for &distance in &distances {
186 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;
193
194 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; 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(), });
221 }
222
223 add_trajectory_flags(&mut samples, &trajectory_data.transonic_distances, max_dist);
225
226 Ok(samples)
227}
228
229fn 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 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
249fn 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 detect_zero_crossings(samples, tolerance);
259
260 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 if samples.len() > 2 {
271 let target_distance_m = target_distance_input_m;
273
274 let first_drop = samples[0].drop_m;
277 let mut min_drop = first_drop;
278 let mut apex_idx: Option<usize> = None;
279
280 for (i, sample) in samples.iter().enumerate().skip(1) {
282 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
299fn 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 for i in 0..(drops.len() - 1) {
309 let current = drops[i];
310 let next = drops[i + 1];
311
312 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 for (i, &drop) in drops.iter().enumerate() {
323 if drop.abs() <= tolerance {
324 samples[i].flags.push(TrajectoryFlag::ZeroCrossing);
325 }
326 }
327
328 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
342fn find_closest_sample_index(samples: &[TrajectorySample], target_distance: f64) -> Option<usize> {
344 if samples.is_empty() {
345 return None;
346 }
347
348 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 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 if left_dist <= right_dist {
372 best_idx = left - 1;
373 }
374 }
375
376 Some(best_idx)
377}
378
379pub 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#[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 assert_eq!(interpolate(&x_vals, &y_vals, -1.0), 0.0); assert_eq!(interpolate(&x_vals, &y_vals, 4.0), 30.0); assert_eq!(interpolate(&x_vals, &y_vals, 0.0), 0.0); assert_eq!(interpolate(&x_vals, &y_vals, 2.0), 20.0); assert_eq!(interpolate(&x_vals, &y_vals, 3.0), 30.0); }
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, 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, 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, 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 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 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), Vector3::new(100.0, 10.0, 1.0), Vector3::new(200.0, 5.0, 2.0), ],
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, };
629
630 let samples = sample_trajectory(&trajectory_data, &outputs, 50.0, 0.1)
631 .expect("normal sampling should succeed");
632
633 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 assert!(samples[1].velocity_mps > 90.0 && samples[1].velocity_mps < 100.0);
643
644 assert!(samples[2].flags.contains(&TrajectoryFlag::Apex)); assert!(samples[3].flags.contains(&TrajectoryFlag::MachTransition)); }
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}