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>, }
120
121#[derive(Debug, Clone)]
123pub struct TrajectoryOutputs {
124 pub target_distance_horiz_m: f64,
125 pub target_vertical_height_m: f64,
126 pub time_of_flight_s: f64,
127 pub max_ord_dist_horiz_m: f64,
128 pub sight_height_m: f64,
131}
132
133pub fn sample_trajectory(
140 trajectory_data: &TrajectoryData,
141 outputs: &TrajectoryOutputs,
142 step_m: f64,
143 mass_kg: f64,
144) -> Result<Vec<TrajectorySample>, BallisticsError> {
145 let max_dist = outputs.target_distance_horiz_m;
147 let num_steps = projected_sample_count(max_dist, step_m)?;
148 if num_steps == 0 {
149 return Ok(Vec::new());
150 }
151 let step_size = step_m.max(0.1);
152
153 let downrange_vals: Vec<f64> = trajectory_data.positions.iter().map(|p| p.x).collect();
155 let y_vals: Vec<f64> = trajectory_data.positions.iter().map(|p| p.y).collect();
156 let lateral_vals: Vec<f64> = trajectory_data.positions.iter().map(|p| p.z).collect();
157
158 let speeds: Vec<f64> = trajectory_data
160 .velocities
161 .iter()
162 .map(|v| v.norm())
163 .collect();
164
165 let distances: Vec<f64> = (0..num_steps)
167 .map(|i| i as f64 * step_size)
168 .filter(|&d| d <= max_dist + 0.1) .collect();
170
171 let mut samples = Vec::with_capacity(distances.len());
173
174 for &distance in &distances {
175 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;
182
183 let los_y = outputs.sight_height_m
198 + (outputs.target_vertical_height_m - outputs.sight_height_m) * distance / max_dist;
199 let drop = los_y - y_interp; samples.push(TrajectorySample {
202 distance_m: distance,
203 drop_m: drop,
204 wind_drift_m: wind_drift,
205 velocity_mps: velocity,
206 energy_j: energy,
207 time_s: time,
208 flags: Vec::new(), });
210 }
211
212 add_trajectory_flags(&mut samples, &trajectory_data.transonic_distances, max_dist);
214
215 Ok(samples)
216}
217
218fn interpolate(x_vals: &[f64], y_vals: &[f64], x: f64) -> f64 {
220 if x_vals.is_empty() || y_vals.is_empty() {
221 return 0.0;
222 }
223
224 if x_vals.len() != y_vals.len() {
225 return 0.0;
226 }
227
228 if x <= x_vals[0] {
229 return y_vals[0];
230 }
231
232 if x >= x_vals[x_vals.len() - 1] {
233 return y_vals[y_vals.len() - 1];
234 }
235
236 let mut left = 0;
238 let mut right = x_vals.len() - 1;
239
240 while right - left > 1 {
241 let mid = (left + right) / 2;
242 if x_vals[mid] <= x {
243 left = mid;
244 } else {
245 right = mid;
246 }
247 }
248
249 let x1 = x_vals[left];
251 let x2 = x_vals[right];
252 let y1 = y_vals[left];
253 let y2 = y_vals[right];
254
255 if (x2 - x1).abs() < f64::EPSILON {
256 return y1;
257 }
258
259 y1 + (y2 - y1) * (x - x1) / (x2 - x1)
260}
261
262fn add_trajectory_flags(
264 samples: &mut [TrajectorySample],
265 transonic_distances: &[f64],
266 target_distance_input_m: f64,
267) {
268 let tolerance = 1e-6;
269
270 detect_zero_crossings(samples, tolerance);
272
273 for &transonic_dist in transonic_distances {
275 if let Some(idx) = find_closest_sample_index(samples, transonic_dist) {
276 samples[idx].flags.push(TrajectoryFlag::MachTransition);
277 }
278 }
279
280 if samples.len() > 2 {
284 let target_distance_m = target_distance_input_m;
286
287 let first_drop = samples[0].drop_m;
290 let mut min_drop = first_drop;
291 let mut apex_idx: Option<usize> = None;
292
293 for (i, sample) in samples.iter().enumerate().skip(1) {
295 if sample.distance_m > target_distance_m {
297 break;
298 }
299
300 if sample.drop_m < min_drop {
301 min_drop = sample.drop_m;
302 apex_idx = Some(i);
303 }
304 }
305
306 if let Some(idx) = apex_idx {
307 samples[idx].flags.push(TrajectoryFlag::Apex);
308 }
309 }
310}
311
312fn detect_zero_crossings(samples: &mut [TrajectorySample], tolerance: f64) {
314 if samples.len() < 2 {
315 return;
316 }
317
318 let drops: Vec<f64> = samples.iter().map(|s| s.drop_m).collect();
319
320 for i in 0..(drops.len() - 1) {
322 let current = drops[i];
323 let next = drops[i + 1];
324
325 let crosses_zero = (current < -tolerance && next >= -tolerance)
327 || (current > tolerance && next <= tolerance);
328
329 if crosses_zero {
330 samples[i + 1].flags.push(TrajectoryFlag::ZeroCrossing);
331 }
332 }
333
334 for (i, &drop) in drops.iter().enumerate() {
336 if drop.abs() <= tolerance {
337 samples[i].flags.push(TrajectoryFlag::ZeroCrossing);
338 }
339 }
340
341 for sample in samples.iter_mut() {
343 let mut unique_flags = Vec::new();
344 let mut seen = HashSet::new();
345
346 for flag in &sample.flags {
347 if seen.insert(flag.clone()) {
348 unique_flags.push(flag.clone());
349 }
350 }
351 sample.flags = unique_flags;
352 }
353}
354
355fn find_closest_sample_index(samples: &[TrajectorySample], target_distance: f64) -> Option<usize> {
357 if samples.is_empty() {
358 return None;
359 }
360
361 let distances: Vec<f64> = samples.iter().map(|s| s.distance_m).collect();
363
364 let mut left = 0;
365 let mut right = distances.len();
366
367 while left < right {
368 let mid = (left + right) / 2;
369 if distances[mid] < target_distance {
370 left = mid + 1;
371 } else {
372 right = mid;
373 }
374 }
375
376 let mut best_idx = left.min(distances.len() - 1);
378
379 if left > 0 {
380 let left_dist = (distances[left - 1] - target_distance).abs();
381 let right_dist = (distances[best_idx] - target_distance).abs();
382
383 if left_dist <= right_dist {
385 best_idx = left - 1;
386 }
387 }
388
389 Some(best_idx)
390}
391
392pub fn trajectory_samples_to_dicts(samples: &[TrajectorySample]) -> Vec<TrajectoryDict> {
394 samples
395 .iter()
396 .map(|sample| TrajectoryDict {
397 distance_m: sample.distance_m,
398 drop_m: sample.drop_m,
399 wind_drift_m: sample.wind_drift_m,
400 velocity_mps: sample.velocity_mps,
401 energy_j: sample.energy_j,
402 time_s: sample.time_s,
403 flags: sample.flags.iter().map(|f| f.to_string()).collect(),
404 })
405 .collect()
406}
407
408#[derive(Debug, Clone)]
410pub struct TrajectoryDict {
411 pub distance_m: f64,
412 pub drop_m: f64,
413 pub wind_drift_m: f64,
414 pub velocity_mps: f64,
415 pub energy_j: f64,
416 pub time_s: f64,
417 pub flags: Vec<String>,
418}
419
420#[cfg(test)]
421mod tests {
422 use super::*;
423
424 fn linear_fixture(max_dist: f64) -> (TrajectoryData, TrajectoryOutputs) {
425 (
426 TrajectoryData {
427 times: vec![0.0, 1.0],
428 positions: vec![
429 Vector3::new(0.0, -1.0, 0.0),
430 Vector3::new(max_dist, -1.0, 0.0),
431 ],
432 velocities: vec![
433 Vector3::new(800.0, 0.0, 0.0),
434 Vector3::new(700.0, 0.0, 0.0),
435 ],
436 transonic_distances: vec![],
437 },
438 TrajectoryOutputs {
439 target_distance_horiz_m: max_dist,
440 target_vertical_height_m: 0.0,
441 time_of_flight_s: 1.0,
442 max_ord_dist_horiz_m: 0.0,
443 sight_height_m: 0.0,
444 },
445 )
446 }
447
448 #[test]
449 fn mba1299_projected_sample_count_checks_exact_limit_and_overflow() {
450 assert_eq!(MAX_TRAJECTORY_SAMPLES, crate::MAX_TRAJECTORY_POINTS);
451 assert_eq!(
452 projected_sample_count((MAX_TRAJECTORY_SAMPLES - 1) as f64, 1.0)
453 .expect("the exact sample cap should be accepted"),
454 MAX_TRAJECTORY_SAMPLES
455 );
456 assert_eq!(
457 projected_sample_count(MAX_TRAJECTORY_SAMPLES as f64 - 0.5, 1.0)
458 .expect("a filtered final candidate must not reject an exact-cap grid"),
459 MAX_TRAJECTORY_SAMPLES
460 );
461 assert_eq!(
462 projected_sample_count(0.2, 0.01)
463 .expect("the historical 0.1 meter interval floor should remain valid"),
464 3
465 );
466
467 for (range, interval) in [
468 (MAX_TRAJECTORY_SAMPLES as f64, 1.0),
469 (f64::MAX, 0.1),
470 ] {
471 let error = projected_sample_count(range, interval)
472 .expect_err("a grid above the sample cap must fail");
473 assert!(
474 error
475 .to_string()
476 .contains("trajectory sample limit of 250000 exceeded"),
477 "unexpected sampling limit error: {error}"
478 );
479 }
480 }
481
482 #[test]
483 fn mba1299_public_sampler_accepts_the_exact_cap() {
484 for max_dist in [
485 (MAX_TRAJECTORY_SAMPLES - 1) as f64,
486 MAX_TRAJECTORY_SAMPLES as f64 - 0.5,
487 ] {
488 let (trajectory_data, outputs) = linear_fixture(max_dist);
489 let samples = sample_trajectory(&trajectory_data, &outputs, 1.0, 0.01)
490 .expect("an exact-cap sample grid should succeed");
491
492 assert_eq!(samples.len(), MAX_TRAJECTORY_SAMPLES);
493 assert_eq!(samples.first().expect("muzzle sample").distance_m, 0.0);
494 assert_eq!(
495 samples.last().expect("terminal sample").distance_m,
496 (MAX_TRAJECTORY_SAMPLES - 1) as f64
497 );
498 }
499 }
500
501 #[test]
502 fn mba1299_public_sampler_rejects_oversized_grids_before_allocation() {
503 for max_dist in [MAX_TRAJECTORY_SAMPLES as f64, f64::MAX] {
504 let (trajectory_data, outputs) = linear_fixture(max_dist);
505 let error = sample_trajectory(&trajectory_data, &outputs, 1.0, 0.01)
506 .expect_err("an oversized public sampling request must fail");
507 assert!(
508 error
509 .to_string()
510 .contains("trajectory sample limit of 250000 exceeded"),
511 "unexpected sampling limit error: {error}"
512 );
513 }
514 }
515
516 #[test]
517 fn test_interpolate() {
518 let x_vals = vec![0.0, 1.0, 2.0, 3.0];
519 let y_vals = vec![0.0, 10.0, 20.0, 30.0];
520
521 assert_eq!(interpolate(&x_vals, &y_vals, 0.5), 5.0);
522 assert_eq!(interpolate(&x_vals, &y_vals, 1.5), 15.0);
523 assert_eq!(interpolate(&x_vals, &y_vals, 2.5), 25.0);
524
525 assert_eq!(interpolate(&x_vals, &y_vals, -1.0), 0.0); assert_eq!(interpolate(&x_vals, &y_vals, 4.0), 30.0); }
529
530 #[test]
531 fn test_find_closest_sample_index() {
532 let samples = vec![
533 TrajectorySample {
534 distance_m: 0.0,
535 drop_m: 0.0,
536 wind_drift_m: 0.0,
537 velocity_mps: 100.0,
538 energy_j: 1000.0,
539 time_s: 0.0,
540 flags: Vec::new(),
541 },
542 TrajectorySample {
543 distance_m: 10.0,
544 drop_m: -1.0,
545 wind_drift_m: 0.1,
546 velocity_mps: 95.0,
547 energy_j: 950.0,
548 time_s: 0.1,
549 flags: Vec::new(),
550 },
551 TrajectorySample {
552 distance_m: 20.0,
553 drop_m: -4.0,
554 wind_drift_m: 0.2,
555 velocity_mps: 90.0,
556 energy_j: 900.0,
557 time_s: 0.2,
558 flags: Vec::new(),
559 },
560 ];
561
562 assert_eq!(find_closest_sample_index(&samples, 5.0), Some(0));
563 assert_eq!(find_closest_sample_index(&samples, 12.0), Some(1));
564 assert_eq!(find_closest_sample_index(&samples, 18.0), Some(2));
565 }
566
567 #[test]
568 fn test_detect_zero_crossings() {
569 let mut samples = vec![
570 TrajectorySample {
571 distance_m: 0.0,
572 drop_m: 1.0, wind_drift_m: 0.0,
574 velocity_mps: 100.0,
575 energy_j: 1000.0,
576 time_s: 0.0,
577 flags: Vec::new(),
578 },
579 TrajectorySample {
580 distance_m: 10.0,
581 drop_m: -0.5, wind_drift_m: 0.1,
583 velocity_mps: 95.0,
584 energy_j: 950.0,
585 time_s: 0.1,
586 flags: Vec::new(),
587 },
588 TrajectorySample {
589 distance_m: 20.0,
590 drop_m: -2.0, wind_drift_m: 0.2,
592 velocity_mps: 90.0,
593 energy_j: 900.0,
594 time_s: 0.2,
595 flags: Vec::new(),
596 },
597 ];
598
599 detect_zero_crossings(&mut samples, 1e-6);
600
601 assert!(!samples[0].flags.contains(&TrajectoryFlag::ZeroCrossing));
603 assert!(samples[1].flags.contains(&TrajectoryFlag::ZeroCrossing));
604 assert!(!samples[2].flags.contains(&TrajectoryFlag::ZeroCrossing));
605 }
606
607 #[test]
608 fn test_sample_trajectory_basic() {
609 let trajectory_data = TrajectoryData {
612 times: vec![0.0, 1.0, 2.0],
613 positions: vec![
614 Vector3::new(0.0, 0.0, 0.0), Vector3::new(100.0, 10.0, 1.0), Vector3::new(200.0, 5.0, 2.0), ],
618 velocities: vec![
619 Vector3::new(1.0, 10.0, 100.0),
620 Vector3::new(1.0, 5.0, 95.0),
621 Vector3::new(1.0, 0.0, 90.0),
622 ],
623 transonic_distances: vec![150.0],
624 };
625
626 let outputs = TrajectoryOutputs {
627 target_distance_horiz_m: 200.0,
628 target_vertical_height_m: 0.0,
629 time_of_flight_s: 2.0,
630 max_ord_dist_horiz_m: 100.0,
631 sight_height_m: 0.0, };
633
634 let samples = sample_trajectory(&trajectory_data, &outputs, 50.0, 0.1)
635 .expect("normal sampling should succeed");
636
637 assert_eq!(samples.len(), 5);
639 assert_eq!(samples[0].distance_m, 0.0);
640 assert_eq!(samples[1].distance_m, 50.0);
641 assert_eq!(samples[2].distance_m, 100.0);
642 assert_eq!(samples[3].distance_m, 150.0);
643 assert_eq!(samples[4].distance_m, 200.0);
644
645 assert!(samples[1].velocity_mps > 90.0 && samples[1].velocity_mps < 100.0);
647
648 assert!(samples[2].flags.contains(&TrajectoryFlag::Apex)); assert!(samples[3].flags.contains(&TrajectoryFlag::MachTransition)); }
652
653 #[test]
654 fn sampled_energy_is_derived_from_interpolated_speed() {
655 let mass_kg = 0.01;
656 let trajectory_data = TrajectoryData {
657 times: vec![0.0, 1.0],
658 positions: vec![Vector3::zeros(), Vector3::new(100.0, 0.0, 0.0)],
659 velocities: vec![Vector3::new(800.0, 0.0, 0.0), Vector3::new(700.0, 0.0, 0.0)],
660 transonic_distances: vec![],
661 };
662 let outputs = TrajectoryOutputs {
663 target_distance_horiz_m: 100.0,
664 target_vertical_height_m: 0.0,
665 time_of_flight_s: 1.0,
666 max_ord_dist_horiz_m: 0.0,
667 sight_height_m: 0.0,
668 };
669
670 let samples = sample_trajectory(&trajectory_data, &outputs, 50.0, mass_kg)
671 .expect("normal sampling should succeed");
672 assert_eq!(samples.len(), 3);
673 assert_eq!(samples[1].velocity_mps.to_bits(), 750.0_f64.to_bits());
674 assert_eq!(samples[1].energy_j.to_bits(), 2812.5_f64.to_bits());
675 for sample in samples {
676 let expected_energy = 0.5 * mass_kg * sample.velocity_mps * sample.velocity_mps;
677 assert_eq!(sample.energy_j.to_bits(), expected_energy.to_bits());
678 }
679 }
680}