ballistics_engine/
truing_dsf.rs

1//! Mach-keyed drop-scale-factor (DSF) truing table (MBA-1357).
2//!
3//! Applied Ballistics' published two-stage truing workflow calibrates muzzle velocity
4//! (MV) first — that fixes the supersonic drag curve against a chronograph/observed-drop
5//! comparison at Mach >= 1.2. Below that, as the bullet moves through the transonic
6//! region and into the subsonic regime, no single MV correction can fix drop
7//! discrepancies that grow with range: the residual is a slowly-varying function of
8//! Mach, not a constant offset. AB's second stage records a handful of *observed drop /
9//! predicted drop* ratios at specific (subsonic-or-transonic) Mach numbers and uses them
10//! to scale predicted drop at nearby Mach numbers on later solves.
11//!
12//! This module is a **cleanroom reimplementation** of that workflow's *shape*, not a
13//! bit-for-bit copy of AB's unpublished interpolation — Kestrel/AB do not publish their
14//! exact curve. The design decision unique to this implementation is the **anchor**:
15//! the table's Mach domain is `(0, 1.2)` (points at/above Mach 1.2 belong to MV truing,
16//! not here — [`DsfTable::from_points`] rejects them), and every table implicitly
17//! continues with a DSF of `1.0` at Mach 1.2 — the exact boundary where MV calibration
18//! takes over. That implicit anchor point `(1.2, 1.0)` is never stored in
19//! [`DsfTable::points`]; it exists only inside [`DsfTable::factor_at`]'s interpolation so
20//! the transition from "supersonic, MV-trued, unscaled" to "transonic/subsonic,
21//! DSF-scaled" is continuous — a shot solved at Mach 1.1999 and one solved at Mach 1.2001
22//! get (to floating-point precision) the same drop. This is a functional-equivalence
23//! choice made for this engine, not a replication of AB's internal method.
24//!
25//! Below the lowest recorded point, [`DsfTable::factor_at`] flat-clamps to that point's
26//! DSF — there is no data past it, and AB's guidance is that further subsonic drop
27//! continues to track the last-calibrated regime rather than drift back toward identity.
28//!
29//! [`apply_dsf`] is a **drop-only** post-processing step over an already-solved
30//! [`crate::TrajectoryResult`]: it rescales each point's vertical position relative to
31//! the line of sight by the DSF at that point's Mach, and touches nothing else —
32//! velocity, kinetic energy, time, and downrange/windage position are byte-identical
33//! before and after. Per-point Mach is computed the same way the solver's own
34//! diagnostics compute it (see [`apply_dsf`]'s doc comment for the exact fields), NOT
35//! from a re-derived per-altitude local speed of sound the engine does not store per
36//! point.
37//!
38//! No feature gate: this module must compile for `wasm32-unknown-unknown`. It is
39//! fs-free (profile persistence of a table's points is the caller's job, e.g.
40//! `main.rs`'s saved-profile handling in a later task).
41
42use serde::{Deserialize, Serialize};
43
44use crate::cli_api::TrajectoryResult;
45
46/// Upper bound (exclusive) of the Mach domain a [`DsfPoint`] may describe. Observations at
47/// or above this Mach belong to muzzle-velocity truing, not the DSF table; it doubles as
48/// the implicit anchor's Mach coordinate (`(DSF_MACH_CEILING, 1.0)`) in
49/// [`DsfTable::factor_at`].
50pub const DSF_MACH_CEILING: f64 = 1.2;
51
52/// DSF value of the implicit anchor at [`DSF_MACH_CEILING`] — identity, matching the
53/// MV-trued supersonic regime this table hands off from.
54pub const DSF_ANCHOR_VALUE: f64 = 1.0;
55
56/// Exclusive lower bound a point's `dsf` must clear.
57pub const DSF_MIN: f64 = 0.5;
58
59/// Exclusive upper bound a point's `dsf` must clear.
60pub const DSF_MAX: f64 = 2.0;
61
62/// Maximum number of distinct points a [`DsfTable`] may hold.
63pub const DSF_MAX_POINTS: usize = 6;
64
65/// A new point within this many Mach units of an existing one supersedes it in
66/// [`DsfTable::upsert`] instead of being appended.
67pub const DSF_SUPERSEDE_TOLERANCE_MACH: f64 = 0.05;
68
69/// One observed drop-scale-factor keyed to the Mach number it was recorded at.
70///
71/// `mach` must satisfy `0 < mach < 1.2`; `dsf` must be finite and satisfy
72/// `0.5 < dsf < 2.0`. Both bounds are enforced by [`DsfTable::from_points`] and
73/// [`DsfTable::upsert`] — this struct itself carries no invariant beyond the field types
74/// (serde needs to deserialize arbitrary saved-profile content before it can be
75/// validated).
76#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
77pub struct DsfPoint {
78    pub mach: f64,
79    pub dsf: f64,
80}
81
82/// What [`DsfTable::upsert`] did with the incoming point.
83#[derive(Debug, Clone, Copy, PartialEq)]
84pub enum UpsertOutcome {
85    /// No existing point was within [`DSF_SUPERSEDE_TOLERANCE_MACH`] Mach; the point was
86    /// added as a new entry.
87    Appended,
88    /// An existing point was within [`DSF_SUPERSEDE_TOLERANCE_MACH`] Mach and was replaced.
89    /// `old` is the point that was overwritten.
90    Replaced { old: DsfPoint },
91}
92
93/// A validated, Mach-sorted table of up to [`DSF_MAX_POINTS`] [`DsfPoint`]s.
94///
95/// Construct via [`DsfTable::from_points`] (bulk, e.g. loading a saved profile) or by
96/// starting from an empty table (`DsfTable::from_points(Vec::new())`, infallible) and
97/// growing it with [`DsfTable::upsert`] (the `dsf` CLI verb's one-observation-at-a-time
98/// path in a later task).
99#[derive(Debug, Clone, PartialEq)]
100pub struct DsfTable {
101    /// Sorted ascending by `mach`. Never exceeds [`DSF_MAX_POINTS`] entries.
102    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
130/// Linear interpolation of `y` at `x`, between `(x0, y0)` and `(x1, y1)`.
131fn 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    /// Validate, cap-check, and sort a bulk set of points (e.g. deserialized from a saved
140    /// profile). Rejects any point failing validation (see [`DsfPoint`] bounds) and rejects more than
141    /// [`DSF_MAX_POINTS`] points outright. Does NOT dedupe near-Mach points against each
142    /// other — that supersede behavior is [`DsfTable::upsert`]'s job; a caller assembling
143    /// points one at a time should use `upsert`, not construct a `Vec` with near-duplicates
144    /// and pass it here.
145    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    /// Insert or supersede a point. A new point within [`DSF_SUPERSEDE_TOLERANCE_MACH`]
161    /// Mach of an existing one replaces it (returning [`UpsertOutcome::Replaced`] with the
162    /// overwritten point); otherwise it is appended (returning
163    /// [`UpsertOutcome::Appended`]), unless the table is already at [`DSF_MAX_POINTS`]
164    /// distinct points, in which case this errors naming the cap.
165    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    /// The drop-scale-factor at `mach`.
192    ///
193    /// - Identity (`1.0`) at or above [`DSF_MACH_CEILING`], and for an empty table at any
194    ///   Mach — there is nothing to scale by.
195    /// - Flat-clamped to the lowest point's `dsf` at or below the lowest point's Mach.
196    /// - Piecewise-linear between successive points.
197    /// - Piecewise-linear between the highest point and the implicit anchor
198    ///   `(DSF_MACH_CEILING, DSF_ANCHOR_VALUE)` for Mach between the highest point and the
199    ///   ceiling (this includes single-point tables, where "highest" and "lowest" are the
200    ///   same point).
201    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        // mach is above every explicit key (but still below the ceiling, checked above):
219        // interpolate against the implicit anchor.
220        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    /// The table's points, sorted ascending by Mach.
231    pub fn points(&self) -> &[DsfPoint] {
232        &self.points
233    }
234}
235
236/// Apply a DSF table to an already-solved trajectory, IN PLACE, scaling only each
237/// point's drop below the line of sight — in BOTH `result.points` and, when present,
238/// `result.sampled_points`.
239///
240/// For each `point` in `result.points`:
241/// 1. Per-point Mach is `point.velocity_magnitude / result.station_speed_of_sound_mps` —
242///    the same frozen "station" speed of sound the solver itself divides into
243///    `velocity_magnitude` for its own per-point Mach diagnostics (Mach-transition
244///    tracking, pitch-damping, precession/nutation; see the "MBA-1136 (rank 30)" comments
245///    next to `resolved_atmosphere()` in `cli_api.rs`'s integration loops). The engine
246///    does NOT store a re-derived per-altitude local speed of sound on `TrajectoryPoint`
247///    itself, so this is "the way the solver does it", not a sea-level constant and not a
248///    new per-point atmosphere recompute. It is also the same divisor the truing
249///    observation path uses to derive an observation's Mach (`trajectory_observation.rs`),
250///    so a DSF point keyed at derivation time lands back on the identical Mach at
251///    application time — a per-point local recompute here would skew the two apart.
252/// 2. `drop = result.line_of_sight_height_m - point.position.y` (drop below the
253///    horizontal line of sight, in the solver's ground-referenced frame — the same
254///    `drop_offset - y` convention `cli_api::fit_value_at` uses for BC-fit drop curves).
255/// 3. `point.position.y` is rewritten so that the (possibly rescaled) drop is
256///    `drop * table.factor_at(mach)`.
257///
258/// `result.sampled_points` (populated when `--sample-trajectory` is requested; read by
259/// the Table's "Sampled Trajectory" section, CSV `--full`, and the PDF dope card — the
260/// PDF dope card *always* requires sampling) is a SEPARATE `Vec<TrajectorySample>` from
261/// `points` and was, until MBA-1357 Task 2's review (Critical #2), left untouched by this
262/// function — those outputs silently rendered untrued drops even with an active DSF
263/// table. Each `TrajectorySample` already stores its drop directly as `drop_m` (the same
264/// `LOS - actual` sign convention derived from `points` above — see
265/// `trajectory_sampling.rs`'s `sample_trajectory` doc comment), so no position
266/// reconstruction is needed: `sample.drop_m` is simply multiplied by
267/// `table.factor_at(mach)`, with `mach` computed from `sample.velocity_mps` via the
268/// identical frozen `station_speed_of_sound_mps` divisor used for `points` above. This
269/// mirrors `run_sampled_trajectory`'s (come-ups' own sampled-trajectory path, `main.rs`)
270/// hand-rolled version of the same transform, so both paths now agree. `None` stays
271/// `None` — nothing to scale.
272///
273/// Nothing else is touched: `position.x` (downrange), `position.z` (windage/lateral),
274/// `velocity_magnitude`, `kinetic_energy`, and `time` are byte-identical to their
275/// pre-call values on `points`; `distance_m`, `wind_drift_m`, `velocity_mps`,
276/// `energy_j`, `time_s`, and `flags` are byte-identical on `sampled_points`; every
277/// top-level scalar on `result` itself (`time_of_flight`, `impact_velocity`,
278/// `impact_energy`, `max_range`, `max_height`, ...) is untouched too.
279pub 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    // ---- factor_at semantics ----
319
320    #[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        // (0.9, 1.15) -> anchor (1.2, 1.0). Halfway (mach 1.05) is halfway between the two
339        // DSF values.
340        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        // At the point itself: its own dsf.
344        assert_eq!(table.factor_at(0.9), 1.15);
345        // Continuity at the ceiling boundary: interpolating right up to 1.2 approaches 1.0.
346        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        // (0.8, 1.2) and (1.0, 1.05); at mach 0.9 (halfway) expect halfway between the DSFs.
353        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        // Exactly at a key: that key's own dsf.
357        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        // Highest key (1.0, 1.05) -> anchor (1.2, 1.0). At mach 1.1 (halfway).
371        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    // ---- validation rejections ----
377
378    #[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()); // exclusive bound
395        assert!(DsfTable::from_points(vec![pt(0.9, 2.0)]).is_err()); // exclusive bound
396        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    // ---- upsert ----
418
419    #[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); // within 0.05 of 0.5
431        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(); // 0.051 away: outside tolerance
444        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        // Far from every existing point (nearest is 0.85, 0.15 away — outside the 0.05 tolerance).
456        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    // ---- apply_dsf drop-only invariant ----
473
474    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            // mach 1.3: supersonic, above the ceiling -> factor 1.0 (untouched drop too).
532            trajectory_point(0.0, 0.0, 0.05, 0.0, 1.3 * sos),
533            // mach 0.9: within the table -> interpolated factor.
534            trajectory_point(0.5, 250.0, 0.02, 1.0, 0.9 * sos),
535            // mach 0.5: below the lowest key -> flat-clamped factor.
536            trajectory_point(1.0, 500.0, -1.0, 2.0, 0.5 * sos),
537        ];
538        let mut original = fixture_result(points);
539        // MBA-1357 Task 2 review, Critical #2: sampled_points is a SEPARATE array from
540        // `points` and must be scaled too (same Mach coverage: 1.3/0.9/0.5, so the same
541        // expected_factors below apply to both).
542        original.sampled_points = Some(vec![
543            // drop_m values chosen to match the drop_before values the points loop below
544            // derives (los - y): 0.0, 0.03, 1.05 — so the same expected_factors apply.
545            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        // Top-level fields are untouched too — every one of them.
568        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        // AerodynamicJumpComponents has no PartialEq; the fixture carries None and
585        // apply_dsf must leave it that way.
586        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; // mach 0.9: halfway between the two keys
590        let expected_factors = [1.0, mach_09_factor, 1.2 /* flat clamp below lowest key */];
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        // The untouched (mach >= 1.2) point's position.y must be exactly unchanged.
601        assert_eq!(original.points[0].position.y, scaled.points[0].position.y);
602
603        // Critical #2: sampled_points scales the SAME way, and every other field on
604        // each sample is byte-identical.
605        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        // The untouched (mach >= 1.2) sample's drop_m must be exactly unchanged.
630        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        // Same non-empty table as the invariant test above, but sampled_points is None
636        // (e.g. a solve without --sample-trajectory) — apply_dsf must not panic or
637        // conjure a Some, it must stay None.
638        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}