use serde::{Deserialize, Serialize};
use crate::cli_api::TrajectoryResult;
pub const DSF_MACH_CEILING: f64 = 1.2;
pub const DSF_ANCHOR_VALUE: f64 = 1.0;
pub const DSF_MIN: f64 = 0.5;
pub const DSF_MAX: f64 = 2.0;
pub const DSF_MAX_POINTS: usize = 6;
pub const DSF_SUPERSEDE_TOLERANCE_MACH: f64 = 0.05;
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct DsfPoint {
pub mach: f64,
pub dsf: f64,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum UpsertOutcome {
Appended,
Replaced { old: DsfPoint },
}
#[derive(Debug, Clone, PartialEq)]
pub struct DsfTable {
points: Vec<DsfPoint>,
}
fn validate_point(point: &DsfPoint) -> Result<(), String> {
if !point.mach.is_finite() || point.mach <= 0.0 || point.mach >= DSF_MACH_CEILING {
return Err(format!(
"DSF point Mach {} is out of range: must be finite and satisfy 0 < mach < {DSF_MACH_CEILING} \
(observations at/above Mach {DSF_MACH_CEILING} belong to muzzle-velocity truing, not the DSF table)",
point.mach
));
}
if !point.dsf.is_finite() || point.dsf <= DSF_MIN || point.dsf >= DSF_MAX {
return Err(format!(
"DSF value {} is out of range: must be finite and satisfy {DSF_MIN} < dsf < {DSF_MAX}",
point.dsf
));
}
Ok(())
}
fn sort_by_mach(points: &mut [DsfPoint]) {
points.sort_by(|a, b| {
a.mach
.partial_cmp(&b.mach)
.expect("DsfPoint.mach is validated finite before insertion")
});
}
fn lerp(x0: f64, y0: f64, x1: f64, y1: f64, x: f64) -> f64 {
if x1 == x0 {
return y0;
}
y0 + (y1 - y0) * (x - x0) / (x1 - x0)
}
impl DsfTable {
pub fn from_points(points: Vec<DsfPoint>) -> Result<DsfTable, String> {
if points.len() > DSF_MAX_POINTS {
return Err(format!(
"DSF table supports at most {DSF_MAX_POINTS} points; got {} (remove one first, e.g. --clear-dsf)",
points.len()
));
}
for point in &points {
validate_point(point)?;
}
let mut sorted = points;
sort_by_mach(&mut sorted);
Ok(DsfTable { points: sorted })
}
pub fn upsert(&mut self, point: DsfPoint) -> Result<UpsertOutcome, String> {
validate_point(&point)?;
if let Some(existing) = self
.points
.iter_mut()
.find(|p| (p.mach - point.mach).abs() <= DSF_SUPERSEDE_TOLERANCE_MACH)
{
let old = *existing;
*existing = point;
sort_by_mach(&mut self.points);
return Ok(UpsertOutcome::Replaced { old });
}
if self.points.len() >= DSF_MAX_POINTS {
return Err(format!(
"DSF table already holds the maximum {DSF_MAX_POINTS} points; remove one first \
(e.g. --clear-dsf) before adding another"
));
}
self.points.push(point);
sort_by_mach(&mut self.points);
Ok(UpsertOutcome::Appended)
}
pub fn factor_at(&self, mach: f64) -> f64 {
if !mach.is_finite() || mach >= DSF_MACH_CEILING || self.points.is_empty() {
return DSF_ANCHOR_VALUE;
}
let lowest = self.points[0];
if mach <= lowest.mach {
return lowest.dsf;
}
for pair in self.points.windows(2) {
let (lo, hi) = (pair[0], pair[1]);
if mach <= hi.mach {
return lerp(lo.mach, lo.dsf, hi.mach, hi.dsf, mach);
}
}
let highest = *self.points.last().expect("checked non-empty above");
lerp(
highest.mach,
highest.dsf,
DSF_MACH_CEILING,
DSF_ANCHOR_VALUE,
mach,
)
}
pub fn points(&self) -> &[DsfPoint] {
&self.points
}
}
pub fn apply_dsf(result: &mut TrajectoryResult, table: &DsfTable) {
let line_of_sight_height_m = result.line_of_sight_height_m;
let station_speed_of_sound_mps = result.station_speed_of_sound_mps;
for point in result.points.iter_mut() {
let mach = if station_speed_of_sound_mps > 0.0 {
point.velocity_magnitude / station_speed_of_sound_mps
} else {
0.0
};
let factor = table.factor_at(mach);
let drop = line_of_sight_height_m - point.position.y;
point.position.y = line_of_sight_height_m - drop * factor;
}
if let Some(samples) = result.sampled_points.as_mut() {
for sample in samples.iter_mut() {
let mach = if station_speed_of_sound_mps > 0.0 {
sample.velocity_mps / station_speed_of_sound_mps
} else {
0.0
};
sample.drop_m *= table.factor_at(mach);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli_api::{TrajectoryPoint};
use crate::trajectory_observation::TrajectoryTermination;
use crate::trajectory_sampling::{TrajectoryFlag, TrajectorySample};
use nalgebra::Vector3;
fn pt(mach: f64, dsf: f64) -> DsfPoint {
DsfPoint { mach, dsf }
}
#[test]
fn factor_at_identity_at_and_above_ceiling() {
let table = DsfTable::from_points(vec![pt(0.9, 1.2)]).unwrap();
assert_eq!(table.factor_at(1.2), 1.0);
assert_eq!(table.factor_at(1.5), 1.0);
assert_eq!(table.factor_at(3.0), 1.0);
}
#[test]
fn factor_at_empty_table_is_always_identity() {
let table = DsfTable::from_points(vec![]).unwrap();
assert_eq!(table.factor_at(0.5), 1.0);
assert_eq!(table.factor_at(1.0), 1.0);
assert_eq!(table.factor_at(1.2), 1.0);
}
#[test]
fn factor_at_single_point_interpolates_to_the_implicit_anchor() {
let table = DsfTable::from_points(vec![pt(0.9, 1.15)]).unwrap();
let expected_half = 1.15 + (1.0 - 1.15) * 0.5;
assert!((table.factor_at(1.05) - expected_half).abs() < 1e-12);
assert_eq!(table.factor_at(0.9), 1.15);
let near_ceiling = table.factor_at(1.2 - 1e-9);
assert!((near_ceiling - 1.0).abs() < 1e-6);
}
#[test]
fn factor_at_linear_between_two_keys() {
let table = DsfTable::from_points(vec![pt(0.8, 1.2), pt(1.0, 1.05)]).unwrap();
let expected = 1.2 + (1.05 - 1.2) * 0.5;
assert!((table.factor_at(0.9) - expected).abs() < 1e-12);
assert_eq!(table.factor_at(0.8), 1.2);
assert_eq!(table.factor_at(1.0), 1.05);
}
#[test]
fn factor_at_flat_clamp_below_lowest() {
let table = DsfTable::from_points(vec![pt(0.8, 1.2), pt(1.0, 1.05)]).unwrap();
assert_eq!(table.factor_at(0.5), 1.2);
assert_eq!(table.factor_at(0.0001), 1.2);
}
#[test]
fn factor_at_interpolates_between_highest_key_and_anchor() {
let table = DsfTable::from_points(vec![pt(0.8, 1.2), pt(1.0, 1.05)]).unwrap();
let expected = 1.05 + (1.0 - 1.05) * 0.5;
assert!((table.factor_at(1.1) - expected).abs() < 1e-12);
}
#[test]
fn from_points_rejects_mach_at_or_above_ceiling() {
assert!(DsfTable::from_points(vec![pt(1.2, 1.1)]).is_err());
assert!(DsfTable::from_points(vec![pt(1.3, 1.1)]).is_err());
}
#[test]
fn from_points_rejects_non_positive_mach() {
assert!(DsfTable::from_points(vec![pt(0.0, 1.1)]).is_err());
assert!(DsfTable::from_points(vec![pt(-0.5, 1.1)]).is_err());
}
#[test]
fn from_points_rejects_dsf_out_of_range() {
assert!(DsfTable::from_points(vec![pt(0.9, 0.0)]).is_err());
assert!(DsfTable::from_points(vec![pt(0.9, -1.0)]).is_err());
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());
assert!(DsfTable::from_points(vec![pt(0.9, f64::NAN)]).is_err());
}
#[test]
fn from_points_rejects_more_than_six_points() {
let points: Vec<DsfPoint> = (0..7).map(|i| pt(0.1 + i as f64 * 0.1, 1.1)).collect();
let err = DsfTable::from_points(points).unwrap_err();
assert!(
err.contains('6'),
"error should name the 6-point cap: {err}"
);
}
#[test]
fn from_points_sorts_ascending_by_mach() {
let table = DsfTable::from_points(vec![pt(0.9, 1.1), pt(0.3, 1.3), pt(0.6, 1.2)]).unwrap();
let machs: Vec<f64> = table.points().iter().map(|p| p.mach).collect();
assert_eq!(machs, vec![0.3, 0.6, 0.9]);
}
#[test]
fn upsert_appends_when_no_existing_point_is_within_tolerance() {
let mut table = DsfTable::from_points(vec![pt(0.5, 1.1)]).unwrap();
let outcome = table.upsert(pt(0.8, 1.2)).unwrap();
assert_eq!(outcome, UpsertOutcome::Appended);
assert_eq!(table.points().len(), 2);
}
#[test]
fn upsert_replaces_within_tolerance() {
let mut table = DsfTable::from_points(vec![pt(0.5, 1.1)]).unwrap();
let new_point = pt(0.53, 1.25); let outcome = table.upsert(new_point).unwrap();
match outcome {
UpsertOutcome::Replaced { old } => assert_eq!(old, pt(0.5, 1.1)),
other => panic!("expected Replaced, got {other:?}"),
}
assert_eq!(table.points().len(), 1);
assert_eq!(table.points()[0], new_point);
}
#[test]
fn upsert_boundary_just_outside_tolerance_appends() {
let mut table = DsfTable::from_points(vec![pt(0.5, 1.1)]).unwrap();
let outcome = table.upsert(pt(0.551, 1.2)).unwrap(); assert_eq!(outcome, UpsertOutcome::Appended);
assert_eq!(table.points().len(), 2);
}
#[test]
fn upsert_errors_at_seventh_distinct_point_naming_the_cap() {
let mut table = DsfTable::from_points(
(0..6).map(|i| pt(0.1 + i as f64 * 0.15, 1.1)).collect(),
)
.unwrap();
assert_eq!(table.points().len(), 6);
let err = table.upsert(pt(1.0, 1.3)).unwrap_err();
assert!(
err.contains('6'),
"error should name the 6-point cap: {err}"
);
assert_eq!(table.points().len(), 6, "rejected point must not be added");
}
#[test]
fn upsert_rejects_invalid_point_without_mutating_table() {
let mut table = DsfTable::from_points(vec![pt(0.5, 1.1)]).unwrap();
assert!(table.upsert(pt(1.2, 1.1)).is_err());
assert!(table.upsert(pt(0.6, 3.0)).is_err());
assert_eq!(table.points().len(), 1, "invalid upsert must not mutate the table");
}
fn trajectory_point(time: f64, x: f64, y: f64, z: f64, velocity_magnitude: f64) -> TrajectoryPoint {
TrajectoryPoint {
time,
position: Vector3::new(x, y, z),
velocity_magnitude,
kinetic_energy: 0.5 * 0.01 * velocity_magnitude * velocity_magnitude,
}
}
fn trajectory_sample(
distance_m: f64,
drop_m: f64,
wind_drift_m: f64,
velocity_mps: f64,
time_s: f64,
flags: Vec<TrajectoryFlag>,
) -> TrajectorySample {
TrajectorySample {
distance_m,
drop_m,
wind_drift_m,
velocity_mps,
energy_j: 0.5 * 0.01 * velocity_mps * velocity_mps,
time_s,
flags,
}
}
fn fixture_result(points: Vec<TrajectoryPoint>) -> TrajectoryResult {
TrajectoryResult {
max_range: 500.0,
max_height: 2.0,
time_of_flight: 1.234,
impact_velocity: 300.0,
impact_energy: 1800.0,
projectile_mass_kg: 0.01,
line_of_sight_height_m: 0.05,
station_speed_of_sound_mps: 340.0,
termination: TrajectoryTermination::MaxRange,
points,
sampled_points: None,
min_pitch_damping: None,
transonic_mach: None,
angular_state: None,
max_yaw_angle: None,
max_precession_angle: None,
aerodynamic_jump: None,
mach_1_2_distance_m: None,
mach_1_0_distance_m: None,
mach_0_9_distance_m: None,
}
}
#[test]
fn apply_dsf_scales_only_drop_leaving_everything_else_byte_identical() {
let sos = 340.0;
let points = vec![
trajectory_point(0.0, 0.0, 0.05, 0.0, 1.3 * sos),
trajectory_point(0.5, 250.0, 0.02, 1.0, 0.9 * sos),
trajectory_point(1.0, 500.0, -1.0, 2.0, 0.5 * sos),
];
let mut original = fixture_result(points);
original.sampled_points = Some(vec![
trajectory_sample(0.0, 0.0, 0.0, 1.3 * sos, 0.0, vec![]),
trajectory_sample(250.0, 0.03, 1.0, 0.9 * sos, 0.5, vec![TrajectoryFlag::MachTransition]),
trajectory_sample(500.0, 1.05, 2.0, 0.5 * sos, 1.0, vec![TrajectoryFlag::Apex]),
]);
let table = DsfTable::from_points(vec![pt(0.8, 1.2), pt(1.0, 1.05)]).unwrap();
let mut scaled = original.clone();
apply_dsf(&mut scaled, &table);
for (orig, new) in original.points.iter().zip(scaled.points.iter()) {
assert_eq!(orig.time, new.time, "time must be byte-identical");
assert_eq!(
orig.velocity_magnitude, new.velocity_magnitude,
"velocity must be byte-identical"
);
assert_eq!(
orig.kinetic_energy, new.kinetic_energy,
"energy must be byte-identical"
);
assert_eq!(orig.position.x, new.position.x, "downrange must be byte-identical");
assert_eq!(orig.position.z, new.position.z, "windage must be byte-identical");
}
assert_eq!(original.max_range, scaled.max_range);
assert_eq!(original.max_height, scaled.max_height);
assert_eq!(original.time_of_flight, scaled.time_of_flight);
assert_eq!(original.impact_velocity, scaled.impact_velocity);
assert_eq!(original.impact_energy, scaled.impact_energy);
assert_eq!(original.projectile_mass_kg, scaled.projectile_mass_kg);
assert_eq!(original.line_of_sight_height_m, scaled.line_of_sight_height_m);
assert_eq!(
original.station_speed_of_sound_mps,
scaled.station_speed_of_sound_mps
);
assert_eq!(original.termination, scaled.termination);
assert_eq!(original.min_pitch_damping, scaled.min_pitch_damping);
assert_eq!(original.transonic_mach, scaled.transonic_mach);
assert_eq!(original.max_yaw_angle, scaled.max_yaw_angle);
assert_eq!(original.max_precession_angle, scaled.max_precession_angle);
assert!(original.aerodynamic_jump.is_none() && scaled.aerodynamic_jump.is_none());
let los = original.line_of_sight_height_m;
let mach_09_factor = 1.2 + (1.05 - 1.2) * 0.5; let expected_factors = [1.0, mach_09_factor, 1.2 ];
for (i, (orig, new)) in original.points.iter().zip(scaled.points.iter()).enumerate() {
let drop_before = los - orig.position.y;
let drop_after = los - new.position.y;
let expected_drop = drop_before * expected_factors[i];
assert!(
(drop_after - expected_drop).abs() < 1e-9,
"point {i}: expected scaled drop {expected_drop}, got {drop_after}"
);
}
assert_eq!(original.points[0].position.y, scaled.points[0].position.y);
let orig_samples = original.sampled_points.as_ref().unwrap();
let scaled_samples = scaled.sampled_points.as_ref().unwrap();
assert_eq!(orig_samples.len(), scaled_samples.len());
for (i, (orig, new)) in orig_samples.iter().zip(scaled_samples.iter()).enumerate() {
assert_eq!(orig.distance_m, new.distance_m, "sample {i}: distance_m must be byte-identical");
assert_eq!(
orig.wind_drift_m, new.wind_drift_m,
"sample {i}: wind_drift_m must be byte-identical"
);
assert_eq!(
orig.velocity_mps, new.velocity_mps,
"sample {i}: velocity_mps must be byte-identical"
);
assert_eq!(orig.energy_j, new.energy_j, "sample {i}: energy_j must be byte-identical");
assert_eq!(orig.time_s, new.time_s, "sample {i}: time_s must be byte-identical");
assert_eq!(orig.flags, new.flags, "sample {i}: flags must be byte-identical");
let expected_drop = orig.drop_m * expected_factors[i];
assert!(
(new.drop_m - expected_drop).abs() < 1e-9,
"sample {i}: expected scaled drop_m {expected_drop}, got {}",
new.drop_m
);
}
assert_eq!(orig_samples[0].drop_m, scaled_samples[0].drop_m);
}
#[test]
fn apply_dsf_leaves_sampled_points_none_when_absent() {
let points = vec![trajectory_point(0.5, 250.0, 0.02, 1.0, 0.9 * 340.0)];
let original = fixture_result(points);
assert!(original.sampled_points.is_none());
let table = DsfTable::from_points(vec![pt(0.8, 1.2), pt(1.0, 1.05)]).unwrap();
let mut scaled = original.clone();
apply_dsf(&mut scaled, &table);
assert!(scaled.sampled_points.is_none(), "None must stay None");
}
#[test]
fn apply_dsf_with_empty_table_leaves_drop_unchanged() {
let points = vec![trajectory_point(0.5, 250.0, 0.02, 1.0, 0.9 * 340.0)];
let original = fixture_result(points);
let table = DsfTable::from_points(vec![]).unwrap();
let mut scaled = original.clone();
apply_dsf(&mut scaled, &table);
assert_eq!(original.points[0].position.y, scaled.points[0].position.y);
}
}