1use crate::cli_api::BallisticsError;
2use nalgebra::Vector3;
3use std::collections::HashSet;
4use std::fmt;
5
6pub const MAX_TRAJECTORY_SAMPLES: usize = 250_000;
11
12pub(crate) fn projected_sample_count(
21 max_dist: f64,
22 step_m: f64,
23) -> Result<usize, BallisticsError> {
24 if !max_dist.is_finite() || !step_m.is_finite() {
25 return Err(BallisticsError::from(
26 "trajectory sampling range and interval must be finite",
27 ));
28 }
29
30 if step_m <= 0.0 || max_dist < 1e-9 {
31 return Ok(0);
32 }
33
34 let step_size = step_m.max(0.1);
35 let intervals = (max_dist / step_size).ceil();
36 if !intervals.is_finite() || intervals > MAX_TRAJECTORY_SAMPLES as f64 {
40 return Err(BallisticsError::from(format!(
41 "trajectory sample limit of {MAX_TRAJECTORY_SAMPLES} exceeded"
42 )));
43 }
44
45 let intervals = intervals as usize;
46 let candidate_count = intervals.checked_add(1).ok_or_else(|| {
47 BallisticsError::from(format!(
48 "trajectory sample limit of {MAX_TRAJECTORY_SAMPLES} exceeded"
49 ))
50 })?;
51 let final_candidate_m = intervals as f64 * step_size;
52 let retained_count = if final_candidate_m > max_dist + 0.1 {
53 candidate_count - 1
54 } else {
55 candidate_count
56 };
57
58 if retained_count > MAX_TRAJECTORY_SAMPLES {
59 Err(BallisticsError::from(format!(
60 "trajectory sample limit of {MAX_TRAJECTORY_SAMPLES} exceeded"
61 )))
62 } else {
63 Ok(retained_count)
64 }
65}
66
67#[derive(Debug, Clone, PartialEq, Eq, Hash)]
69pub enum TrajectoryFlag {
70 ZeroCrossing,
71 MachTransition,
72 Apex,
73}
74
75impl fmt::Display for TrajectoryFlag {
76 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
77 formatter.write_str(match self {
78 TrajectoryFlag::ZeroCrossing => "zero_crossing",
79 TrajectoryFlag::MachTransition => "mach_transition",
80 TrajectoryFlag::Apex => "apex",
81 })
82 }
83}
84
85impl TrajectoryFlag {
86 #[allow(clippy::inherent_to_string_shadow_display)] pub fn to_string(&self) -> String {
92 match self {
93 TrajectoryFlag::ZeroCrossing => "zero_crossing".to_owned(),
94 TrajectoryFlag::MachTransition => "mach_transition".to_owned(),
95 TrajectoryFlag::Apex => "apex".to_owned(),
96 }
97 }
98}
99
100#[derive(Debug, Clone)]
102pub struct TrajectorySample {
103 pub distance_m: f64,
104 pub drop_m: f64,
105 pub wind_drift_m: f64,
106 pub velocity_mps: f64,
107 pub energy_j: f64,
108 pub time_s: f64,
109 pub flags: Vec<TrajectoryFlag>,
110}
111
112#[derive(Debug, Clone)]
114pub struct TrajectoryData {
115 pub times: Vec<f64>,
116 pub positions: Vec<Vector3<f64>>, pub velocities: Vec<Vector3<f64>>, pub transonic_distances: Vec<f64>,
122 pub mach_1_2_distance_m: Option<f64>,
125 pub mach_1_0_distance_m: Option<f64>,
128 pub mach_0_9_distance_m: Option<f64>,
132}
133
134#[derive(Debug, Clone)]
136pub struct TrajectoryOutputs {
137 pub target_distance_horiz_m: f64,
138 pub target_vertical_height_m: f64,
139 pub time_of_flight_s: f64,
140 pub max_ord_dist_horiz_m: f64,
141 pub sight_height_m: f64,
144}
145
146pub fn sample_trajectory(
153 trajectory_data: &TrajectoryData,
154 outputs: &TrajectoryOutputs,
155 step_m: f64,
156 mass_kg: f64,
157) -> Result<Vec<TrajectorySample>, BallisticsError> {
158 let max_dist = outputs.target_distance_horiz_m;
160 let num_steps = projected_sample_count(max_dist, step_m)?;
161 if num_steps == 0 {
162 return Ok(Vec::new());
163 }
164 let step_size = step_m.max(0.1);
165
166 let downrange_vals: Vec<f64> = trajectory_data.positions.iter().map(|p| p.x).collect();
168 let y_vals: Vec<f64> = trajectory_data.positions.iter().map(|p| p.y).collect();
169 let lateral_vals: Vec<f64> = trajectory_data.positions.iter().map(|p| p.z).collect();
170
171 let speeds: Vec<f64> = trajectory_data
173 .velocities
174 .iter()
175 .map(|v| v.norm())
176 .collect();
177
178 let distances: Vec<f64> = (0..num_steps)
180 .map(|i| i as f64 * step_size)
181 .filter(|&d| d <= max_dist + 0.1) .collect();
183
184 let mut samples = Vec::with_capacity(distances.len());
186
187 for &distance in &distances {
188 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;
195
196 let los_y = outputs.sight_height_m
211 + (outputs.target_vertical_height_m - outputs.sight_height_m) * distance / max_dist;
212 let drop = los_y - y_interp; samples.push(TrajectorySample {
215 distance_m: distance,
216 drop_m: drop,
217 wind_drift_m: wind_drift,
218 velocity_mps: velocity,
219 energy_j: energy,
220 time_s: time,
221 flags: Vec::new(), });
223 }
224
225 add_trajectory_flags(&mut samples, &trajectory_data.transonic_distances, max_dist);
227
228 Ok(samples)
229}
230
231fn interpolate(x_vals: &[f64], y_vals: &[f64], x: f64) -> f64 {
233 if x_vals.is_empty() || y_vals.is_empty() {
234 return 0.0;
235 }
236
237 if x_vals.len() != y_vals.len() {
238 return 0.0;
239 }
240
241 if x <= x_vals[0] {
242 return y_vals[0];
243 }
244
245 if x >= x_vals[x_vals.len() - 1] {
246 return y_vals[y_vals.len() - 1];
247 }
248
249 let mut left = 0;
251 let mut right = x_vals.len() - 1;
252
253 while right - left > 1 {
254 let mid = (left + right) / 2;
255 if x_vals[mid] <= x {
256 left = mid;
257 } else {
258 right = mid;
259 }
260 }
261
262 let x1 = x_vals[left];
264 let x2 = x_vals[right];
265 let y1 = y_vals[left];
266 let y2 = y_vals[right];
267
268 if (x2 - x1).abs() < f64::EPSILON {
269 return y1;
270 }
271
272 y1 + (y2 - y1) * (x - x1) / (x2 - x1)
273}
274
275fn add_trajectory_flags(
277 samples: &mut [TrajectorySample],
278 transonic_distances: &[f64],
279 target_distance_input_m: f64,
280) {
281 let tolerance = 1e-6;
282
283 detect_zero_crossings(samples, tolerance);
285
286 for &transonic_dist in transonic_distances {
288 if let Some(idx) = find_closest_sample_index(samples, transonic_dist) {
289 samples[idx].flags.push(TrajectoryFlag::MachTransition);
290 }
291 }
292
293 if samples.len() > 2 {
297 let target_distance_m = target_distance_input_m;
299
300 let first_drop = samples[0].drop_m;
303 let mut min_drop = first_drop;
304 let mut apex_idx: Option<usize> = None;
305
306 for (i, sample) in samples.iter().enumerate().skip(1) {
308 if sample.distance_m > target_distance_m {
310 break;
311 }
312
313 if sample.drop_m < min_drop {
314 min_drop = sample.drop_m;
315 apex_idx = Some(i);
316 }
317 }
318
319 if let Some(idx) = apex_idx {
320 samples[idx].flags.push(TrajectoryFlag::Apex);
321 }
322 }
323}
324
325fn detect_zero_crossings(samples: &mut [TrajectorySample], tolerance: f64) {
327 if samples.len() < 2 {
328 return;
329 }
330
331 let drops: Vec<f64> = samples.iter().map(|s| s.drop_m).collect();
332
333 for i in 0..(drops.len() - 1) {
335 let current = drops[i];
336 let next = drops[i + 1];
337
338 let crosses_zero = (current < -tolerance && next >= -tolerance)
340 || (current > tolerance && next <= tolerance);
341
342 if crosses_zero {
343 samples[i + 1].flags.push(TrajectoryFlag::ZeroCrossing);
344 }
345 }
346
347 for (i, &drop) in drops.iter().enumerate() {
349 if drop.abs() <= tolerance {
350 samples[i].flags.push(TrajectoryFlag::ZeroCrossing);
351 }
352 }
353
354 for sample in samples.iter_mut() {
356 let mut unique_flags = Vec::new();
357 let mut seen = HashSet::new();
358
359 for flag in &sample.flags {
360 if seen.insert(flag.clone()) {
361 unique_flags.push(flag.clone());
362 }
363 }
364 sample.flags = unique_flags;
365 }
366}
367
368fn find_closest_sample_index(samples: &[TrajectorySample], target_distance: f64) -> Option<usize> {
370 if samples.is_empty() {
371 return None;
372 }
373
374 let distances: Vec<f64> = samples.iter().map(|s| s.distance_m).collect();
376
377 let mut left = 0;
378 let mut right = distances.len();
379
380 while left < right {
381 let mid = (left + right) / 2;
382 if distances[mid] < target_distance {
383 left = mid + 1;
384 } else {
385 right = mid;
386 }
387 }
388
389 let mut best_idx = left.min(distances.len() - 1);
391
392 if left > 0 {
393 let left_dist = (distances[left - 1] - target_distance).abs();
394 let right_dist = (distances[best_idx] - target_distance).abs();
395
396 if left_dist <= right_dist {
398 best_idx = left - 1;
399 }
400 }
401
402 Some(best_idx)
403}
404
405pub fn trajectory_samples_to_dicts(samples: &[TrajectorySample]) -> Vec<TrajectoryDict> {
407 samples
408 .iter()
409 .map(|sample| TrajectoryDict {
410 distance_m: sample.distance_m,
411 drop_m: sample.drop_m,
412 wind_drift_m: sample.wind_drift_m,
413 velocity_mps: sample.velocity_mps,
414 energy_j: sample.energy_j,
415 time_s: sample.time_s,
416 flags: sample.flags.iter().map(|f| f.to_string()).collect(),
417 })
418 .collect()
419}
420
421#[derive(Debug, Clone)]
423pub struct TrajectoryDict {
424 pub distance_m: f64,
425 pub drop_m: f64,
426 pub wind_drift_m: f64,
427 pub velocity_mps: f64,
428 pub energy_j: f64,
429 pub time_s: f64,
430 pub flags: Vec<String>,
431}
432
433#[cfg(test)]
434mod tests {
435 use super::*;
436
437 fn linear_fixture(max_dist: f64) -> (TrajectoryData, TrajectoryOutputs) {
438 (
439 TrajectoryData {
440 times: vec![0.0, 1.0],
441 positions: vec![
442 Vector3::new(0.0, -1.0, 0.0),
443 Vector3::new(max_dist, -1.0, 0.0),
444 ],
445 velocities: vec![
446 Vector3::new(800.0, 0.0, 0.0),
447 Vector3::new(700.0, 0.0, 0.0),
448 ],
449 transonic_distances: vec![],
450 mach_1_2_distance_m: None,
451 mach_1_0_distance_m: None,
452 mach_0_9_distance_m: None,
453 },
454 TrajectoryOutputs {
455 target_distance_horiz_m: max_dist,
456 target_vertical_height_m: 0.0,
457 time_of_flight_s: 1.0,
458 max_ord_dist_horiz_m: 0.0,
459 sight_height_m: 0.0,
460 },
461 )
462 }
463
464 #[test]
465 fn mba1299_projected_sample_count_checks_exact_limit_and_overflow() {
466 assert_eq!(MAX_TRAJECTORY_SAMPLES, crate::MAX_TRAJECTORY_POINTS);
467 assert_eq!(
468 projected_sample_count((MAX_TRAJECTORY_SAMPLES - 1) as f64, 1.0)
469 .expect("the exact sample cap should be accepted"),
470 MAX_TRAJECTORY_SAMPLES
471 );
472 assert_eq!(
473 projected_sample_count(MAX_TRAJECTORY_SAMPLES as f64 - 0.5, 1.0)
474 .expect("a filtered final candidate must not reject an exact-cap grid"),
475 MAX_TRAJECTORY_SAMPLES
476 );
477 assert_eq!(
478 projected_sample_count(0.2, 0.01)
479 .expect("the historical 0.1 meter interval floor should remain valid"),
480 3
481 );
482
483 for (range, interval) in [
484 (MAX_TRAJECTORY_SAMPLES as f64, 1.0),
485 (f64::MAX, 0.1),
486 ] {
487 let error = projected_sample_count(range, interval)
488 .expect_err("a grid above the sample cap must fail");
489 assert!(
490 error
491 .to_string()
492 .contains("trajectory sample limit of 250000 exceeded"),
493 "unexpected sampling limit error: {error}"
494 );
495 }
496 }
497
498 #[test]
499 fn mba1299_public_sampler_accepts_the_exact_cap() {
500 for max_dist in [
501 (MAX_TRAJECTORY_SAMPLES - 1) as f64,
502 MAX_TRAJECTORY_SAMPLES as f64 - 0.5,
503 ] {
504 let (trajectory_data, outputs) = linear_fixture(max_dist);
505 let samples = sample_trajectory(&trajectory_data, &outputs, 1.0, 0.01)
506 .expect("an exact-cap sample grid should succeed");
507
508 assert_eq!(samples.len(), MAX_TRAJECTORY_SAMPLES);
509 assert_eq!(samples.first().expect("muzzle sample").distance_m, 0.0);
510 assert_eq!(
511 samples.last().expect("terminal sample").distance_m,
512 (MAX_TRAJECTORY_SAMPLES - 1) as f64
513 );
514 }
515 }
516
517 #[test]
518 fn mba1299_public_sampler_rejects_oversized_grids_before_allocation() {
519 for max_dist in [MAX_TRAJECTORY_SAMPLES as f64, f64::MAX] {
520 let (trajectory_data, outputs) = linear_fixture(max_dist);
521 let error = sample_trajectory(&trajectory_data, &outputs, 1.0, 0.01)
522 .expect_err("an oversized public sampling request must fail");
523 assert!(
524 error
525 .to_string()
526 .contains("trajectory sample limit of 250000 exceeded"),
527 "unexpected sampling limit error: {error}"
528 );
529 }
530 }
531
532 #[test]
533 fn test_interpolate() {
534 let x_vals = vec![0.0, 1.0, 2.0, 3.0];
535 let y_vals = vec![0.0, 10.0, 20.0, 30.0];
536
537 assert_eq!(interpolate(&x_vals, &y_vals, 0.5), 5.0);
538 assert_eq!(interpolate(&x_vals, &y_vals, 1.5), 15.0);
539 assert_eq!(interpolate(&x_vals, &y_vals, 2.5), 25.0);
540
541 assert_eq!(interpolate(&x_vals, &y_vals, -1.0), 0.0); assert_eq!(interpolate(&x_vals, &y_vals, 4.0), 30.0); }
545
546 #[test]
547 fn test_find_closest_sample_index() {
548 let samples = vec![
549 TrajectorySample {
550 distance_m: 0.0,
551 drop_m: 0.0,
552 wind_drift_m: 0.0,
553 velocity_mps: 100.0,
554 energy_j: 1000.0,
555 time_s: 0.0,
556 flags: Vec::new(),
557 },
558 TrajectorySample {
559 distance_m: 10.0,
560 drop_m: -1.0,
561 wind_drift_m: 0.1,
562 velocity_mps: 95.0,
563 energy_j: 950.0,
564 time_s: 0.1,
565 flags: Vec::new(),
566 },
567 TrajectorySample {
568 distance_m: 20.0,
569 drop_m: -4.0,
570 wind_drift_m: 0.2,
571 velocity_mps: 90.0,
572 energy_j: 900.0,
573 time_s: 0.2,
574 flags: Vec::new(),
575 },
576 ];
577
578 assert_eq!(find_closest_sample_index(&samples, 5.0), Some(0));
579 assert_eq!(find_closest_sample_index(&samples, 12.0), Some(1));
580 assert_eq!(find_closest_sample_index(&samples, 18.0), Some(2));
581 }
582
583 #[test]
584 fn test_detect_zero_crossings() {
585 let mut samples = vec![
586 TrajectorySample {
587 distance_m: 0.0,
588 drop_m: 1.0, wind_drift_m: 0.0,
590 velocity_mps: 100.0,
591 energy_j: 1000.0,
592 time_s: 0.0,
593 flags: Vec::new(),
594 },
595 TrajectorySample {
596 distance_m: 10.0,
597 drop_m: -0.5, wind_drift_m: 0.1,
599 velocity_mps: 95.0,
600 energy_j: 950.0,
601 time_s: 0.1,
602 flags: Vec::new(),
603 },
604 TrajectorySample {
605 distance_m: 20.0,
606 drop_m: -2.0, wind_drift_m: 0.2,
608 velocity_mps: 90.0,
609 energy_j: 900.0,
610 time_s: 0.2,
611 flags: Vec::new(),
612 },
613 ];
614
615 detect_zero_crossings(&mut samples, 1e-6);
616
617 assert!(!samples[0].flags.contains(&TrajectoryFlag::ZeroCrossing));
619 assert!(samples[1].flags.contains(&TrajectoryFlag::ZeroCrossing));
620 assert!(!samples[2].flags.contains(&TrajectoryFlag::ZeroCrossing));
621 }
622
623 #[test]
624 fn test_sample_trajectory_basic() {
625 let trajectory_data = TrajectoryData {
628 times: vec![0.0, 1.0, 2.0],
629 positions: vec![
630 Vector3::new(0.0, 0.0, 0.0), Vector3::new(100.0, 10.0, 1.0), Vector3::new(200.0, 5.0, 2.0), ],
634 velocities: vec![
635 Vector3::new(1.0, 10.0, 100.0),
636 Vector3::new(1.0, 5.0, 95.0),
637 Vector3::new(1.0, 0.0, 90.0),
638 ],
639 transonic_distances: vec![150.0],
640 mach_1_2_distance_m: None,
641 mach_1_0_distance_m: Some(150.0),
642 mach_0_9_distance_m: None,
643 };
644
645 let outputs = TrajectoryOutputs {
646 target_distance_horiz_m: 200.0,
647 target_vertical_height_m: 0.0,
648 time_of_flight_s: 2.0,
649 max_ord_dist_horiz_m: 100.0,
650 sight_height_m: 0.0, };
652
653 let samples = sample_trajectory(&trajectory_data, &outputs, 50.0, 0.1)
654 .expect("normal sampling should succeed");
655
656 assert_eq!(samples.len(), 5);
658 assert_eq!(samples[0].distance_m, 0.0);
659 assert_eq!(samples[1].distance_m, 50.0);
660 assert_eq!(samples[2].distance_m, 100.0);
661 assert_eq!(samples[3].distance_m, 150.0);
662 assert_eq!(samples[4].distance_m, 200.0);
663
664 assert!(samples[1].velocity_mps > 90.0 && samples[1].velocity_mps < 100.0);
666
667 assert!(samples[2].flags.contains(&TrajectoryFlag::Apex)); assert!(samples[3].flags.contains(&TrajectoryFlag::MachTransition)); }
671
672 #[test]
673 fn sampled_energy_is_derived_from_interpolated_speed() {
674 let mass_kg = 0.01;
675 let trajectory_data = TrajectoryData {
676 times: vec![0.0, 1.0],
677 positions: vec![Vector3::zeros(), Vector3::new(100.0, 0.0, 0.0)],
678 velocities: vec![Vector3::new(800.0, 0.0, 0.0), Vector3::new(700.0, 0.0, 0.0)],
679 transonic_distances: vec![],
680 mach_1_2_distance_m: None,
681 mach_1_0_distance_m: None,
682 mach_0_9_distance_m: None,
683 };
684 let outputs = TrajectoryOutputs {
685 target_distance_horiz_m: 100.0,
686 target_vertical_height_m: 0.0,
687 time_of_flight_s: 1.0,
688 max_ord_dist_horiz_m: 0.0,
689 sight_height_m: 0.0,
690 };
691
692 let samples = sample_trajectory(&trajectory_data, &outputs, 50.0, mass_kg)
693 .expect("normal sampling should succeed");
694 assert_eq!(samples.len(), 3);
695 assert_eq!(samples[1].velocity_mps.to_bits(), 750.0_f64.to_bits());
696 assert_eq!(samples[1].energy_j.to_bits(), 2812.5_f64.to_bits());
697 for sample in samples {
698 let expected_energy = 0.5 * mass_kg * sample.velocity_mps * sample.velocity_mps;
699 assert_eq!(sample.energy_j.to_bits(), expected_energy.to_bits());
700 }
701 }
702}