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(
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 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#[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 #[allow(clippy::inherent_to_string_shadow_display)] 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#[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#[derive(Debug, Clone)]
115pub struct TrajectoryData {
116 pub times: Vec<f64>,
117 pub positions: Vec<Vector3<f64>>, pub velocities: Vec<Vector3<f64>>, pub transonic_distances: Vec<f64>,
123 pub mach_1_2_distance_m: Option<f64>,
126 pub mach_1_0_distance_m: Option<f64>,
129 pub mach_0_9_distance_m: Option<f64>,
133}
134
135#[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 pub sight_height_m: f64,
145}
146
147pub fn sample_trajectory(
154 trajectory_data: &TrajectoryData,
155 outputs: &TrajectoryOutputs,
156 step_m: f64,
157 mass_kg: f64,
158) -> Result<Vec<TrajectorySample>, BallisticsError> {
159 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 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 let speeds: Vec<f64> = trajectory_data
174 .velocities
175 .iter()
176 .map(|v| v.norm())
177 .collect();
178
179 let distances: Vec<f64> = (0..num_steps)
181 .map(|i| i as f64 * step_size)
182 .filter(|&d| d <= max_dist + 0.1) .collect();
184
185 let mut samples = Vec::with_capacity(distances.len());
187
188 for &distance in &distances {
189 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;
196
197 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; 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(), });
224 }
225
226 add_trajectory_flags(&mut samples, &trajectory_data.transonic_distances, max_dist);
228
229 Ok(samples)
230}
231
232fn 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 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
252fn 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 detect_zero_crossings(samples, tolerance);
262
263 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 if samples.len() > 2 {
274 let target_distance_m = target_distance_input_m;
276
277 let first_drop = samples[0].drop_m;
280 let mut min_drop = first_drop;
281 let mut apex_idx: Option<usize> = None;
282
283 for (i, sample) in samples.iter().enumerate().skip(1) {
285 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
302fn 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 for i in 0..(drops.len() - 1) {
312 let current = drops[i];
313 let next = drops[i + 1];
314
315 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 for (i, &drop) in drops.iter().enumerate() {
326 if drop.abs() <= tolerance {
327 samples[i].flags.push(TrajectoryFlag::ZeroCrossing);
328 }
329 }
330
331 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
345fn find_closest_sample_index(samples: &[TrajectorySample], target_distance: f64) -> Option<usize> {
347 if samples.is_empty() {
348 return None;
349 }
350
351 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 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 if left_dist <= right_dist {
375 best_idx = left - 1;
376 }
377 }
378
379 Some(best_idx)
380}
381
382pub 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#[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 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); }
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, 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, 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, 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 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 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), Vector3::new(100.0, 10.0, 1.0), Vector3::new(200.0, 5.0, 2.0), ],
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, };
638
639 let samples = sample_trajectory(&trajectory_data, &outputs, 50.0, 0.1)
640 .expect("normal sampling should succeed");
641
642 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 assert!(samples[1].velocity_mps > 90.0 && samples[1].velocity_mps < 100.0);
652
653 assert!(samples[2].flags.contains(&TrajectoryFlag::Apex)); assert!(samples[3].flags.contains(&TrajectoryFlag::MachTransition)); }
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}