1use serde::{Deserialize, Serialize};
43
44use crate::cli_api::TrajectoryResult;
45
46pub const DSF_MACH_CEILING: f64 = 1.2;
51
52pub const DSF_ANCHOR_VALUE: f64 = 1.0;
55
56pub const DSF_MIN: f64 = 0.5;
58
59pub const DSF_MAX: f64 = 2.0;
61
62pub const DSF_MAX_POINTS: usize = 6;
64
65pub const DSF_SUPERSEDE_TOLERANCE_MACH: f64 = 0.05;
68
69#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
77pub struct DsfPoint {
78 pub mach: f64,
79 pub dsf: f64,
80}
81
82#[derive(Debug, Clone, Copy, PartialEq)]
84pub enum UpsertOutcome {
85 Appended,
88 Replaced { old: DsfPoint },
91}
92
93#[derive(Debug, Clone, PartialEq)]
100pub struct DsfTable {
101 points: Vec<DsfPoint>,
103}
104
105fn validate_point(point: &DsfPoint) -> Result<(), String> {
106 if !point.mach.is_finite() || point.mach <= 0.0 || point.mach >= DSF_MACH_CEILING {
107 return Err(format!(
108 "DSF point Mach {} is out of range: must be finite and satisfy 0 < mach < {DSF_MACH_CEILING} \
109 (observations at/above Mach {DSF_MACH_CEILING} belong to muzzle-velocity truing, not the DSF table)",
110 point.mach
111 ));
112 }
113 if !point.dsf.is_finite() || point.dsf <= DSF_MIN || point.dsf >= DSF_MAX {
114 return Err(format!(
115 "DSF value {} is out of range: must be finite and satisfy {DSF_MIN} < dsf < {DSF_MAX}",
116 point.dsf
117 ));
118 }
119 Ok(())
120}
121
122fn sort_by_mach(points: &mut [DsfPoint]) {
123 points.sort_by(|a, b| {
124 a.mach
125 .partial_cmp(&b.mach)
126 .expect("DsfPoint.mach is validated finite before insertion")
127 });
128}
129
130fn lerp(x0: f64, y0: f64, x1: f64, y1: f64, x: f64) -> f64 {
132 if x1 == x0 {
133 return y0;
134 }
135 y0 + (y1 - y0) * (x - x0) / (x1 - x0)
136}
137
138impl DsfTable {
139 pub fn from_points(points: Vec<DsfPoint>) -> Result<DsfTable, String> {
146 if points.len() > DSF_MAX_POINTS {
147 return Err(format!(
148 "DSF table supports at most {DSF_MAX_POINTS} points; got {} (remove one first, e.g. --clear-dsf)",
149 points.len()
150 ));
151 }
152 for point in &points {
153 validate_point(point)?;
154 }
155 let mut sorted = points;
156 sort_by_mach(&mut sorted);
157 Ok(DsfTable { points: sorted })
158 }
159
160 pub fn upsert(&mut self, point: DsfPoint) -> Result<UpsertOutcome, String> {
166 validate_point(&point)?;
167
168 if let Some(existing) = self
169 .points
170 .iter_mut()
171 .find(|p| (p.mach - point.mach).abs() <= DSF_SUPERSEDE_TOLERANCE_MACH)
172 {
173 let old = *existing;
174 *existing = point;
175 sort_by_mach(&mut self.points);
176 return Ok(UpsertOutcome::Replaced { old });
177 }
178
179 if self.points.len() >= DSF_MAX_POINTS {
180 return Err(format!(
181 "DSF table already holds the maximum {DSF_MAX_POINTS} points; remove one first \
182 (e.g. --clear-dsf) before adding another"
183 ));
184 }
185
186 self.points.push(point);
187 sort_by_mach(&mut self.points);
188 Ok(UpsertOutcome::Appended)
189 }
190
191 pub fn factor_at(&self, mach: f64) -> f64 {
202 if !mach.is_finite() || mach >= DSF_MACH_CEILING || self.points.is_empty() {
203 return DSF_ANCHOR_VALUE;
204 }
205
206 let lowest = self.points[0];
207 if mach <= lowest.mach {
208 return lowest.dsf;
209 }
210
211 for pair in self.points.windows(2) {
212 let (lo, hi) = (pair[0], pair[1]);
213 if mach <= hi.mach {
214 return lerp(lo.mach, lo.dsf, hi.mach, hi.dsf, mach);
215 }
216 }
217
218 let highest = *self.points.last().expect("checked non-empty above");
221 lerp(
222 highest.mach,
223 highest.dsf,
224 DSF_MACH_CEILING,
225 DSF_ANCHOR_VALUE,
226 mach,
227 )
228 }
229
230 pub fn points(&self) -> &[DsfPoint] {
232 &self.points
233 }
234}
235
236pub fn apply_dsf(result: &mut TrajectoryResult, table: &DsfTable) {
280 let line_of_sight_height_m = result.line_of_sight_height_m;
281 let station_speed_of_sound_mps = result.station_speed_of_sound_mps;
282
283 for point in result.points.iter_mut() {
284 let mach = if station_speed_of_sound_mps > 0.0 {
285 point.velocity_magnitude / station_speed_of_sound_mps
286 } else {
287 0.0
288 };
289 let factor = table.factor_at(mach);
290 let drop = line_of_sight_height_m - point.position.y;
291 point.position.y = line_of_sight_height_m - drop * factor;
292 }
293
294 if let Some(samples) = result.sampled_points.as_mut() {
295 for sample in samples.iter_mut() {
296 let mach = if station_speed_of_sound_mps > 0.0 {
297 sample.velocity_mps / station_speed_of_sound_mps
298 } else {
299 0.0
300 };
301 sample.drop_m *= table.factor_at(mach);
302 }
303 }
304}
305
306#[cfg(test)]
307mod tests {
308 use super::*;
309 use crate::cli_api::{TrajectoryPoint};
310 use crate::trajectory_observation::TrajectoryTermination;
311 use crate::trajectory_sampling::{TrajectoryFlag, TrajectorySample};
312 use nalgebra::Vector3;
313
314 fn pt(mach: f64, dsf: f64) -> DsfPoint {
315 DsfPoint { mach, dsf }
316 }
317
318 #[test]
321 fn factor_at_identity_at_and_above_ceiling() {
322 let table = DsfTable::from_points(vec![pt(0.9, 1.2)]).unwrap();
323 assert_eq!(table.factor_at(1.2), 1.0);
324 assert_eq!(table.factor_at(1.5), 1.0);
325 assert_eq!(table.factor_at(3.0), 1.0);
326 }
327
328 #[test]
329 fn factor_at_empty_table_is_always_identity() {
330 let table = DsfTable::from_points(vec![]).unwrap();
331 assert_eq!(table.factor_at(0.5), 1.0);
332 assert_eq!(table.factor_at(1.0), 1.0);
333 assert_eq!(table.factor_at(1.2), 1.0);
334 }
335
336 #[test]
337 fn factor_at_single_point_interpolates_to_the_implicit_anchor() {
338 let table = DsfTable::from_points(vec![pt(0.9, 1.15)]).unwrap();
341 let expected_half = 1.15 + (1.0 - 1.15) * 0.5;
342 assert!((table.factor_at(1.05) - expected_half).abs() < 1e-12);
343 assert_eq!(table.factor_at(0.9), 1.15);
345 let near_ceiling = table.factor_at(1.2 - 1e-9);
347 assert!((near_ceiling - 1.0).abs() < 1e-6);
348 }
349
350 #[test]
351 fn factor_at_linear_between_two_keys() {
352 let table = DsfTable::from_points(vec![pt(0.8, 1.2), pt(1.0, 1.05)]).unwrap();
354 let expected = 1.2 + (1.05 - 1.2) * 0.5;
355 assert!((table.factor_at(0.9) - expected).abs() < 1e-12);
356 assert_eq!(table.factor_at(0.8), 1.2);
358 assert_eq!(table.factor_at(1.0), 1.05);
359 }
360
361 #[test]
362 fn factor_at_flat_clamp_below_lowest() {
363 let table = DsfTable::from_points(vec![pt(0.8, 1.2), pt(1.0, 1.05)]).unwrap();
364 assert_eq!(table.factor_at(0.5), 1.2);
365 assert_eq!(table.factor_at(0.0001), 1.2);
366 }
367
368 #[test]
369 fn factor_at_interpolates_between_highest_key_and_anchor() {
370 let table = DsfTable::from_points(vec![pt(0.8, 1.2), pt(1.0, 1.05)]).unwrap();
372 let expected = 1.05 + (1.0 - 1.05) * 0.5;
373 assert!((table.factor_at(1.1) - expected).abs() < 1e-12);
374 }
375
376 #[test]
379 fn from_points_rejects_mach_at_or_above_ceiling() {
380 assert!(DsfTable::from_points(vec![pt(1.2, 1.1)]).is_err());
381 assert!(DsfTable::from_points(vec![pt(1.3, 1.1)]).is_err());
382 }
383
384 #[test]
385 fn from_points_rejects_non_positive_mach() {
386 assert!(DsfTable::from_points(vec![pt(0.0, 1.1)]).is_err());
387 assert!(DsfTable::from_points(vec![pt(-0.5, 1.1)]).is_err());
388 }
389
390 #[test]
391 fn from_points_rejects_dsf_out_of_range() {
392 assert!(DsfTable::from_points(vec![pt(0.9, 0.0)]).is_err());
393 assert!(DsfTable::from_points(vec![pt(0.9, -1.0)]).is_err());
394 assert!(DsfTable::from_points(vec![pt(0.9, 0.5)]).is_err()); assert!(DsfTable::from_points(vec![pt(0.9, 2.0)]).is_err()); assert!(DsfTable::from_points(vec![pt(0.9, 2.5)]).is_err());
397 assert!(DsfTable::from_points(vec![pt(0.9, f64::NAN)]).is_err());
398 }
399
400 #[test]
401 fn from_points_rejects_more_than_six_points() {
402 let points: Vec<DsfPoint> = (0..7).map(|i| pt(0.1 + i as f64 * 0.1, 1.1)).collect();
403 let err = DsfTable::from_points(points).unwrap_err();
404 assert!(
405 err.contains('6'),
406 "error should name the 6-point cap: {err}"
407 );
408 }
409
410 #[test]
411 fn from_points_sorts_ascending_by_mach() {
412 let table = DsfTable::from_points(vec![pt(0.9, 1.1), pt(0.3, 1.3), pt(0.6, 1.2)]).unwrap();
413 let machs: Vec<f64> = table.points().iter().map(|p| p.mach).collect();
414 assert_eq!(machs, vec![0.3, 0.6, 0.9]);
415 }
416
417 #[test]
420 fn upsert_appends_when_no_existing_point_is_within_tolerance() {
421 let mut table = DsfTable::from_points(vec![pt(0.5, 1.1)]).unwrap();
422 let outcome = table.upsert(pt(0.8, 1.2)).unwrap();
423 assert_eq!(outcome, UpsertOutcome::Appended);
424 assert_eq!(table.points().len(), 2);
425 }
426
427 #[test]
428 fn upsert_replaces_within_tolerance() {
429 let mut table = DsfTable::from_points(vec![pt(0.5, 1.1)]).unwrap();
430 let new_point = pt(0.53, 1.25); let outcome = table.upsert(new_point).unwrap();
432 match outcome {
433 UpsertOutcome::Replaced { old } => assert_eq!(old, pt(0.5, 1.1)),
434 other => panic!("expected Replaced, got {other:?}"),
435 }
436 assert_eq!(table.points().len(), 1);
437 assert_eq!(table.points()[0], new_point);
438 }
439
440 #[test]
441 fn upsert_boundary_just_outside_tolerance_appends() {
442 let mut table = DsfTable::from_points(vec![pt(0.5, 1.1)]).unwrap();
443 let outcome = table.upsert(pt(0.551, 1.2)).unwrap(); assert_eq!(outcome, UpsertOutcome::Appended);
445 assert_eq!(table.points().len(), 2);
446 }
447
448 #[test]
449 fn upsert_errors_at_seventh_distinct_point_naming_the_cap() {
450 let mut table = DsfTable::from_points(
451 (0..6).map(|i| pt(0.1 + i as f64 * 0.15, 1.1)).collect(),
452 )
453 .unwrap();
454 assert_eq!(table.points().len(), 6);
455 let err = table.upsert(pt(1.0, 1.3)).unwrap_err();
457 assert!(
458 err.contains('6'),
459 "error should name the 6-point cap: {err}"
460 );
461 assert_eq!(table.points().len(), 6, "rejected point must not be added");
462 }
463
464 #[test]
465 fn upsert_rejects_invalid_point_without_mutating_table() {
466 let mut table = DsfTable::from_points(vec![pt(0.5, 1.1)]).unwrap();
467 assert!(table.upsert(pt(1.2, 1.1)).is_err());
468 assert!(table.upsert(pt(0.6, 3.0)).is_err());
469 assert_eq!(table.points().len(), 1, "invalid upsert must not mutate the table");
470 }
471
472 fn trajectory_point(time: f64, x: f64, y: f64, z: f64, velocity_magnitude: f64) -> TrajectoryPoint {
475 TrajectoryPoint {
476 time,
477 position: Vector3::new(x, y, z),
478 velocity_magnitude,
479 kinetic_energy: 0.5 * 0.01 * velocity_magnitude * velocity_magnitude,
480 }
481 }
482
483 fn trajectory_sample(
484 distance_m: f64,
485 drop_m: f64,
486 wind_drift_m: f64,
487 velocity_mps: f64,
488 time_s: f64,
489 flags: Vec<TrajectoryFlag>,
490 ) -> TrajectorySample {
491 TrajectorySample {
492 distance_m,
493 drop_m,
494 wind_drift_m,
495 velocity_mps,
496 energy_j: 0.5 * 0.01 * velocity_mps * velocity_mps,
497 time_s,
498 flags,
499 }
500 }
501
502 fn fixture_result(points: Vec<TrajectoryPoint>) -> TrajectoryResult {
503 TrajectoryResult {
504 max_range: 500.0,
505 max_height: 2.0,
506 time_of_flight: 1.234,
507 impact_velocity: 300.0,
508 impact_energy: 1800.0,
509 projectile_mass_kg: 0.01,
510 line_of_sight_height_m: 0.05,
511 station_speed_of_sound_mps: 340.0,
512 termination: TrajectoryTermination::MaxRange,
513 points,
514 sampled_points: None,
515 min_pitch_damping: None,
516 transonic_mach: None,
517 angular_state: None,
518 max_yaw_angle: None,
519 max_precession_angle: None,
520 aerodynamic_jump: None,
521 mach_1_2_distance_m: None,
522 mach_1_0_distance_m: None,
523 mach_0_9_distance_m: None,
524 }
525 }
526
527 #[test]
528 fn apply_dsf_scales_only_drop_leaving_everything_else_byte_identical() {
529 let sos = 340.0;
530 let points = vec![
531 trajectory_point(0.0, 0.0, 0.05, 0.0, 1.3 * sos),
533 trajectory_point(0.5, 250.0, 0.02, 1.0, 0.9 * sos),
535 trajectory_point(1.0, 500.0, -1.0, 2.0, 0.5 * sos),
537 ];
538 let mut original = fixture_result(points);
539 original.sampled_points = Some(vec![
543 trajectory_sample(0.0, 0.0, 0.0, 1.3 * sos, 0.0, vec![]),
546 trajectory_sample(250.0, 0.03, 1.0, 0.9 * sos, 0.5, vec![TrajectoryFlag::MachTransition]),
547 trajectory_sample(500.0, 1.05, 2.0, 0.5 * sos, 1.0, vec![TrajectoryFlag::Apex]),
548 ]);
549 let table = DsfTable::from_points(vec![pt(0.8, 1.2), pt(1.0, 1.05)]).unwrap();
550
551 let mut scaled = original.clone();
552 apply_dsf(&mut scaled, &table);
553
554 for (orig, new) in original.points.iter().zip(scaled.points.iter()) {
555 assert_eq!(orig.time, new.time, "time must be byte-identical");
556 assert_eq!(
557 orig.velocity_magnitude, new.velocity_magnitude,
558 "velocity must be byte-identical"
559 );
560 assert_eq!(
561 orig.kinetic_energy, new.kinetic_energy,
562 "energy must be byte-identical"
563 );
564 assert_eq!(orig.position.x, new.position.x, "downrange must be byte-identical");
565 assert_eq!(orig.position.z, new.position.z, "windage must be byte-identical");
566 }
567 assert_eq!(original.max_range, scaled.max_range);
569 assert_eq!(original.max_height, scaled.max_height);
570 assert_eq!(original.time_of_flight, scaled.time_of_flight);
571 assert_eq!(original.impact_velocity, scaled.impact_velocity);
572 assert_eq!(original.impact_energy, scaled.impact_energy);
573 assert_eq!(original.projectile_mass_kg, scaled.projectile_mass_kg);
574 assert_eq!(original.line_of_sight_height_m, scaled.line_of_sight_height_m);
575 assert_eq!(
576 original.station_speed_of_sound_mps,
577 scaled.station_speed_of_sound_mps
578 );
579 assert_eq!(original.termination, scaled.termination);
580 assert_eq!(original.min_pitch_damping, scaled.min_pitch_damping);
581 assert_eq!(original.transonic_mach, scaled.transonic_mach);
582 assert_eq!(original.max_yaw_angle, scaled.max_yaw_angle);
583 assert_eq!(original.max_precession_angle, scaled.max_precession_angle);
584 assert!(original.aerodynamic_jump.is_none() && scaled.aerodynamic_jump.is_none());
587
588 let los = original.line_of_sight_height_m;
589 let mach_09_factor = 1.2 + (1.05 - 1.2) * 0.5; let expected_factors = [1.0, mach_09_factor, 1.2 ];
591 for (i, (orig, new)) in original.points.iter().zip(scaled.points.iter()).enumerate() {
592 let drop_before = los - orig.position.y;
593 let drop_after = los - new.position.y;
594 let expected_drop = drop_before * expected_factors[i];
595 assert!(
596 (drop_after - expected_drop).abs() < 1e-9,
597 "point {i}: expected scaled drop {expected_drop}, got {drop_after}"
598 );
599 }
600 assert_eq!(original.points[0].position.y, scaled.points[0].position.y);
602
603 let orig_samples = original.sampled_points.as_ref().unwrap();
606 let scaled_samples = scaled.sampled_points.as_ref().unwrap();
607 assert_eq!(orig_samples.len(), scaled_samples.len());
608 for (i, (orig, new)) in orig_samples.iter().zip(scaled_samples.iter()).enumerate() {
609 assert_eq!(orig.distance_m, new.distance_m, "sample {i}: distance_m must be byte-identical");
610 assert_eq!(
611 orig.wind_drift_m, new.wind_drift_m,
612 "sample {i}: wind_drift_m must be byte-identical"
613 );
614 assert_eq!(
615 orig.velocity_mps, new.velocity_mps,
616 "sample {i}: velocity_mps must be byte-identical"
617 );
618 assert_eq!(orig.energy_j, new.energy_j, "sample {i}: energy_j must be byte-identical");
619 assert_eq!(orig.time_s, new.time_s, "sample {i}: time_s must be byte-identical");
620 assert_eq!(orig.flags, new.flags, "sample {i}: flags must be byte-identical");
621
622 let expected_drop = orig.drop_m * expected_factors[i];
623 assert!(
624 (new.drop_m - expected_drop).abs() < 1e-9,
625 "sample {i}: expected scaled drop_m {expected_drop}, got {}",
626 new.drop_m
627 );
628 }
629 assert_eq!(orig_samples[0].drop_m, scaled_samples[0].drop_m);
631 }
632
633 #[test]
634 fn apply_dsf_leaves_sampled_points_none_when_absent() {
635 let points = vec![trajectory_point(0.5, 250.0, 0.02, 1.0, 0.9 * 340.0)];
639 let original = fixture_result(points);
640 assert!(original.sampled_points.is_none());
641 let table = DsfTable::from_points(vec![pt(0.8, 1.2), pt(1.0, 1.05)]).unwrap();
642
643 let mut scaled = original.clone();
644 apply_dsf(&mut scaled, &table);
645
646 assert!(scaled.sampled_points.is_none(), "None must stay None");
647 }
648
649 #[test]
650 fn apply_dsf_with_empty_table_leaves_drop_unchanged() {
651 let points = vec![trajectory_point(0.5, 250.0, 0.02, 1.0, 0.9 * 340.0)];
652 let original = fixture_result(points);
653 let table = DsfTable::from_points(vec![]).unwrap();
654
655 let mut scaled = original.clone();
656 apply_dsf(&mut scaled, &table);
657
658 assert_eq!(original.points[0].position.y, scaled.points[0].position.y);
659 }
660}