use serde::{Deserialize, Serialize};
use crate::cli_api::{
calculate_zero_angle_with_conditions, AtmosphericConditions, BallisticInputs,
BcReferenceStandard, DropsReference, TrajectoryPoint, TrajectoryResult, TrajectorySolver,
WindConditions,
};
use crate::truing::{fallback_bullet_length_m, DragModelArg, TruingModelInputsV1};
use crate::DragModel;
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);
}
}
}
pub fn interpolate_position_and_velocity(
points: &[TrajectoryPoint],
target_dist_m: f64,
) -> Option<(f64, f64)> {
for i in 0..points.len() {
if points[i].position.x >= target_dist_m {
if i == 0 {
return Some((points[0].position.y, points[0].velocity_magnitude));
}
let p1 = &points[i - 1];
let p2 = &points[i];
let dx = p2.position.x - p1.position.x;
if dx.abs() < 1e-9 {
return Some((p2.position.y, p2.velocity_magnitude));
}
let t = (target_dist_m - p1.position.x) / dx;
let y = p1.position.y + t * (p2.position.y - p1.position.y);
let v = p1.velocity_magnitude + t * (p2.velocity_magnitude - p1.velocity_magnitude);
return Some((y, v));
}
}
None
}
pub fn dsf_observation_beyond_90pct(range_m: f64, solved_max_range_m: f64) -> bool {
solved_max_range_m > 0.0 && range_m > 0.9 * solved_max_range_m
}
pub fn mach_1_crossing_range_m(result: &TrajectoryResult) -> Option<f64> {
let sos = result.station_speed_of_sound_mps;
if sos <= 0.0 || !sos.is_finite() {
return None;
}
let mut previous: Option<(f64, f64)> = None; for point in &result.points {
let mach = point.velocity_magnitude / sos;
if let Some((prev_x, prev_mach)) = previous {
if prev_mach >= 1.0 && mach < 1.0 {
let denom = prev_mach - mach;
if denom.abs() < f64::EPSILON {
return Some(point.position.x);
}
let t = (prev_mach - 1.0) / denom;
return Some(prev_x + t * (point.position.x - prev_x));
}
}
previous = Some((point.position.x, mach));
}
None
}
pub fn dsf_observation_warrants_90pct_warning(
range_m: f64,
mach_1_crossing_range_m: Option<f64>,
solved_max_range_m: f64,
) -> bool {
let beyond_mach_1_crossing = mach_1_crossing_range_m
.map(|crossing_m| range_m > crossing_m)
.unwrap_or(false);
beyond_mach_1_crossing && dsf_observation_beyond_90pct(range_m, solved_max_range_m)
}
#[derive(Debug, Clone)]
pub struct DsfSolveInputs {
pub muzzle_velocity_fps: f64,
pub ballistic_coefficient: f64,
pub drag_model: DragModel,
pub mass_gr: f64,
pub diameter_in: f64,
pub sight_height_in: f64,
pub temperature_f: f64,
pub pressure_inhg: f64,
pub humidity_pct: f64,
pub altitude_ft: f64,
pub wind_speed_mps: Option<f64>,
pub wind_direction_rad: Option<f64>,
pub shooting_angle_rad: Option<f64>,
pub zero_poi_vertical_m: Option<f64>,
pub zero_poi_horizontal_m: Option<f64>,
pub sight_offset_lateral_m: Option<f64>,
pub bc_reference_standard: Option<BcReferenceStandard>,
pub use_bc_segments: bool,
pub bc_segments: Option<Vec<crate::BCSegmentData>>,
pub custom_drag_table: Option<crate::drag::DragTable>,
pub zero_distance_yd: Option<f64>,
pub bore_height_m: f64,
}
impl From<&TruingModelInputsV1> for DsfSolveInputs {
fn from(inputs: &TruingModelInputsV1) -> Self {
DsfSolveInputs {
muzzle_velocity_fps: inputs.muzzle_velocity_fps,
ballistic_coefficient: inputs.ballistic_coefficient,
drag_model: match inputs.drag_model {
DragModelArg::G1 => DragModel::G1,
DragModelArg::G7 => DragModel::G7,
},
mass_gr: inputs.mass_gr,
diameter_in: inputs.diameter_in,
sight_height_in: inputs.sight_height_in,
temperature_f: inputs.temperature_f,
pressure_inhg: inputs.pressure_inhg,
humidity_pct: inputs.humidity_pct,
altitude_ft: inputs.altitude_ft,
wind_speed_mps: None,
wind_direction_rad: None,
shooting_angle_rad: None,
zero_poi_vertical_m: None,
zero_poi_horizontal_m: None,
sight_offset_lateral_m: None,
bc_reference_standard: None,
use_bc_segments: false,
bc_segments: None,
custom_drag_table: None,
zero_distance_yd: Some(inputs.zero_distance_yd),
bore_height_m: 60.0 * 0.0254,
}
}
}
pub fn solve_for_dsf(
inputs: &DsfSolveInputs,
max_range_m: f64,
) -> Result<TrajectoryResult, String> {
let velocity_m = inputs.muzzle_velocity_fps * 0.3048;
let mass_kg = inputs.mass_gr * crate::constants::GRAINS_TO_KG;
let diameter_m = inputs.diameter_in * 0.0254;
let sight_height_m = inputs.sight_height_in * 0.0254;
let bullet_length_m = fallback_bullet_length_m(diameter_m, mass_kg);
let bore_height_m = inputs.bore_height_m;
let temperature_c = (inputs.temperature_f - 32.0) * 5.0 / 9.0;
let pressure_hpa = inputs.pressure_inhg * 33.8639;
let altitude_m = inputs.altitude_ft * 0.3048;
let drag_model = inputs.drag_model;
let wind_speed_m = inputs.wind_speed_mps.unwrap_or(0.0);
let wind_direction_rad = inputs.wind_direction_rad.unwrap_or(0.0);
let shooting_angle_rad = inputs.shooting_angle_rad.unwrap_or(0.0);
let zero_poi_vertical_m = inputs.zero_poi_vertical_m.unwrap_or(0.0);
let zero_poi_horizontal_m = inputs.zero_poi_horizontal_m.unwrap_or(0.0);
let sight_offset_lateral_m = inputs.sight_offset_lateral_m.unwrap_or(0.0);
let bc_reference_standard = inputs
.bc_reference_standard
.unwrap_or(BcReferenceStandard::Icao);
let bc_segments_data = inputs.bc_segments.clone();
let use_bc_segments = inputs.use_bc_segments || bc_segments_data.is_some();
let wind = WindConditions {
speed: wind_speed_m,
direction: wind_direction_rad,
vertical_speed: 0.0,
};
let atmosphere = AtmosphericConditions {
temperature: temperature_c,
pressure: pressure_hpa,
humidity: inputs.humidity_pct,
altitude: altitude_m,
};
let mut ballistic_inputs = BallisticInputs {
bc_value: inputs.ballistic_coefficient,
bc_type: drag_model,
bc_reference_standard,
bullet_mass: mass_kg,
muzzle_velocity: velocity_m,
bullet_diameter: diameter_m,
bullet_length: bullet_length_m,
muzzle_angle: 0.0,
target_distance: max_range_m,
azimuth_angle: 0.0,
shot_azimuth: 0.0,
shooting_angle: shooting_angle_rad,
cant_angle: 0.0,
sight_height: sight_height_m,
sight_offset_lateral_m,
muzzle_height: bore_height_m,
target_height: 0.0,
zero_poi_vertical_m,
zero_poi_horizontal_m,
ground_threshold: 0.0,
altitude: altitude_m,
temperature: temperature_c,
pressure: pressure_hpa,
humidity: inputs.humidity_pct,
latitude: None,
wind_speed: wind_speed_m,
wind_angle: wind_direction_rad,
twist_rate: crate::stability::default_twist_inches(diameter_m, mass_kg, velocity_m),
is_twist_right: true,
caliber_inches: diameter_m / 0.0254,
weight_grains: mass_kg / crate::constants::GRAINS_TO_KG,
manufacturer: None,
bullet_model: None,
bullet_id: None,
bullet_cluster: None,
use_rk4: true,
use_adaptive_rk45: true,
enable_advanced_effects: false,
enable_magnus: false,
enable_coriolis: false,
use_powder_sensitivity: false,
powder_temp_sensitivity: 0.0,
powder_temp: 0.0,
powder_temp_curve: None,
powder_curve_temp_c: None,
tipoff_yaw: 0.0,
cd_delta2: 7.5,
tipoff_decay_distance: 50.0,
use_bc_segments,
bc_segments: None,
bc_segments_data,
use_enhanced_spin_drift: false,
use_form_factor: false,
enable_wind_shear: false,
wind_shear_model: "none".to_string(),
enable_trajectory_sampling: false,
sample_interval: 0.0,
drops_reference: DropsReference::Los,
enable_pitch_damping: false,
enable_precession_nutation: false,
enable_aerodynamic_jump: false,
use_cluster_bc: false,
custom_drag_table: inputs.custom_drag_table.clone(),
cd_scale: 1.0,
bc_type_str: None,
};
if let Some(zero_distance_yd) = inputs.zero_distance_yd {
let zero_distance_m = zero_distance_yd * 0.9144;
ballistic_inputs.muzzle_angle = calculate_zero_angle_with_conditions(
ballistic_inputs.clone(),
zero_distance_m,
bore_height_m + sight_height_m,
wind.clone(),
atmosphere.clone(),
)
.map_err(|e| e.to_string())?;
ballistic_inputs.azimuth_angle += ballistic_inputs.windage_zero_bias_rad(zero_distance_m);
}
let mut solver = TrajectorySolver::new(ballistic_inputs, wind, atmosphere);
solver.set_max_range(max_range_m);
solver.set_time_step(0.001);
solver.solve().map_err(|e| e.to_string())
}
#[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,
drag_coefficient: None,
}
}
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);
}
}