Skip to main content

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::atmosphere::{StationAtmospherePresence, StationReading};
45use crate::cli_api::{
46    calculate_zero_angle_with_station_presence, AtmosphericConditions, BallisticInputs,
47    BcReferenceStandard, DropsReference, TrajectoryPoint, TrajectoryResult, TrajectorySolver,
48    WindConditions,
49};
50use crate::truing::{fallback_bullet_length_m, DragModelArg, TruingModelInputsV1};
51use crate::DragModel;
52
53/// Upper bound (exclusive) of the Mach domain a [`DsfPoint`] may describe. Observations at
54/// or above this Mach belong to muzzle-velocity truing, not the DSF table; it doubles as
55/// the implicit anchor's Mach coordinate (`(DSF_MACH_CEILING, 1.0)`) in
56/// [`DsfTable::factor_at`].
57pub const DSF_MACH_CEILING: f64 = 1.2;
58
59/// DSF value of the implicit anchor at [`DSF_MACH_CEILING`] — identity, matching the
60/// MV-trued supersonic regime this table hands off from.
61pub const DSF_ANCHOR_VALUE: f64 = 1.0;
62
63/// Exclusive lower bound a point's `dsf` must clear.
64pub const DSF_MIN: f64 = 0.5;
65
66/// Exclusive upper bound a point's `dsf` must clear.
67pub const DSF_MAX: f64 = 2.0;
68
69/// Maximum number of distinct points a [`DsfTable`] may hold.
70pub const DSF_MAX_POINTS: usize = 6;
71
72/// A new point within this many Mach units of an existing one supersedes it in
73/// [`DsfTable::upsert`] instead of being appended.
74pub const DSF_SUPERSEDE_TOLERANCE_MACH: f64 = 0.05;
75
76/// One observed drop-scale-factor keyed to the Mach number it was recorded at.
77///
78/// `mach` must satisfy `0 < mach < 1.2`; `dsf` must be finite and satisfy
79/// `0.5 < dsf < 2.0`. Both bounds are enforced by [`DsfTable::from_points`] and
80/// [`DsfTable::upsert`] — this struct itself carries no invariant beyond the field types
81/// (serde needs to deserialize arbitrary saved-profile content before it can be
82/// validated).
83#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
84pub struct DsfPoint {
85    pub mach: f64,
86    pub dsf: f64,
87}
88
89/// What [`DsfTable::upsert`] did with the incoming point.
90#[derive(Debug, Clone, Copy, PartialEq)]
91pub enum UpsertOutcome {
92    /// No existing point was within [`DSF_SUPERSEDE_TOLERANCE_MACH`] Mach; the point was
93    /// added as a new entry.
94    Appended,
95    /// An existing point was within [`DSF_SUPERSEDE_TOLERANCE_MACH`] Mach and was replaced.
96    /// `old` is the point that was overwritten.
97    Replaced { old: DsfPoint },
98}
99
100/// A validated, Mach-sorted table of up to [`DSF_MAX_POINTS`] [`DsfPoint`]s.
101///
102/// Construct via [`DsfTable::from_points`] (bulk, e.g. loading a saved profile) or by
103/// starting from an empty table (`DsfTable::from_points(Vec::new())`, infallible) and
104/// growing it with [`DsfTable::upsert`] (the `dsf` CLI verb's one-observation-at-a-time
105/// path in a later task).
106#[derive(Debug, Clone, PartialEq)]
107pub struct DsfTable {
108    /// Sorted ascending by `mach`. Never exceeds [`DSF_MAX_POINTS`] entries.
109    points: Vec<DsfPoint>,
110}
111
112fn validate_point(point: &DsfPoint) -> Result<(), String> {
113    if !point.mach.is_finite() || point.mach <= 0.0 || point.mach >= DSF_MACH_CEILING {
114        return Err(format!(
115            "DSF point Mach {} is out of range: must be finite and satisfy 0 < mach < {DSF_MACH_CEILING} \
116             (observations at/above Mach {DSF_MACH_CEILING} belong to muzzle-velocity truing, not the DSF table)",
117            point.mach
118        ));
119    }
120    if !point.dsf.is_finite() || point.dsf <= DSF_MIN || point.dsf >= DSF_MAX {
121        return Err(format!(
122            "DSF value {} is out of range: must be finite and satisfy {DSF_MIN} < dsf < {DSF_MAX}",
123            point.dsf
124        ));
125    }
126    Ok(())
127}
128
129fn sort_by_mach(points: &mut [DsfPoint]) {
130    points.sort_by(|a, b| {
131        a.mach
132            .partial_cmp(&b.mach)
133            .expect("DsfPoint.mach is validated finite before insertion")
134    });
135}
136
137/// Linear interpolation of `y` at `x`, between `(x0, y0)` and `(x1, y1)`.
138fn lerp(x0: f64, y0: f64, x1: f64, y1: f64, x: f64) -> f64 {
139    if x1 == x0 {
140        return y0;
141    }
142    y0 + (y1 - y0) * (x - x0) / (x1 - x0)
143}
144
145impl DsfTable {
146    /// Validate, cap-check, and sort a bulk set of points (e.g. deserialized from a saved
147    /// profile). Rejects any point failing validation (see [`DsfPoint`] bounds) and rejects more than
148    /// [`DSF_MAX_POINTS`] points outright. Does NOT dedupe near-Mach points against each
149    /// other — that supersede behavior is [`DsfTable::upsert`]'s job; a caller assembling
150    /// points one at a time should use `upsert`, not construct a `Vec` with near-duplicates
151    /// and pass it here.
152    pub fn from_points(points: Vec<DsfPoint>) -> Result<DsfTable, String> {
153        if points.len() > DSF_MAX_POINTS {
154            return Err(format!(
155                "DSF table supports at most {DSF_MAX_POINTS} points; got {} (remove one first, e.g. --clear-dsf)",
156                points.len()
157            ));
158        }
159        for point in &points {
160            validate_point(point)?;
161        }
162        let mut sorted = points;
163        sort_by_mach(&mut sorted);
164        Ok(DsfTable { points: sorted })
165    }
166
167    /// Insert or supersede a point. A new point within [`DSF_SUPERSEDE_TOLERANCE_MACH`]
168    /// Mach of an existing one replaces it (returning [`UpsertOutcome::Replaced`] with the
169    /// overwritten point); otherwise it is appended (returning
170    /// [`UpsertOutcome::Appended`]), unless the table is already at [`DSF_MAX_POINTS`]
171    /// distinct points, in which case this errors naming the cap.
172    pub fn upsert(&mut self, point: DsfPoint) -> Result<UpsertOutcome, String> {
173        validate_point(&point)?;
174
175        if let Some(existing) = self
176            .points
177            .iter_mut()
178            .find(|p| (p.mach - point.mach).abs() <= DSF_SUPERSEDE_TOLERANCE_MACH)
179        {
180            let old = *existing;
181            *existing = point;
182            sort_by_mach(&mut self.points);
183            return Ok(UpsertOutcome::Replaced { old });
184        }
185
186        if self.points.len() >= DSF_MAX_POINTS {
187            return Err(format!(
188                "DSF table already holds the maximum {DSF_MAX_POINTS} points; remove one first \
189                 (e.g. --clear-dsf) before adding another"
190            ));
191        }
192
193        self.points.push(point);
194        sort_by_mach(&mut self.points);
195        Ok(UpsertOutcome::Appended)
196    }
197
198    /// The drop-scale-factor at `mach`.
199    ///
200    /// - Identity (`1.0`) at or above [`DSF_MACH_CEILING`], and for an empty table at any
201    ///   Mach — there is nothing to scale by.
202    /// - Flat-clamped to the lowest point's `dsf` at or below the lowest point's Mach.
203    /// - Piecewise-linear between successive points.
204    /// - Piecewise-linear between the highest point and the implicit anchor
205    ///   `(DSF_MACH_CEILING, DSF_ANCHOR_VALUE)` for Mach between the highest point and the
206    ///   ceiling (this includes single-point tables, where "highest" and "lowest" are the
207    ///   same point).
208    pub fn factor_at(&self, mach: f64) -> f64 {
209        if !mach.is_finite() || mach >= DSF_MACH_CEILING || self.points.is_empty() {
210            return DSF_ANCHOR_VALUE;
211        }
212
213        let lowest = self.points[0];
214        if mach <= lowest.mach {
215            return lowest.dsf;
216        }
217
218        for pair in self.points.windows(2) {
219            let (lo, hi) = (pair[0], pair[1]);
220            if mach <= hi.mach {
221                return lerp(lo.mach, lo.dsf, hi.mach, hi.dsf, mach);
222            }
223        }
224
225        // mach is above every explicit key (but still below the ceiling, checked above):
226        // interpolate against the implicit anchor.
227        let highest = *self.points.last().expect("checked non-empty above");
228        lerp(
229            highest.mach,
230            highest.dsf,
231            DSF_MACH_CEILING,
232            DSF_ANCHOR_VALUE,
233            mach,
234        )
235    }
236
237    /// The table's points, sorted ascending by Mach.
238    pub fn points(&self) -> &[DsfPoint] {
239        &self.points
240    }
241}
242
243/// Apply a DSF table to an already-solved trajectory, IN PLACE, scaling only each
244/// point's drop below the line of sight — in BOTH `result.points` and, when present,
245/// `result.sampled_points`.
246///
247/// For each `point` in `result.points`:
248/// 1. Per-point Mach is `point.velocity_magnitude / result.station_speed_of_sound_mps` —
249///    the same frozen "station" speed of sound the solver itself divides into
250///    `velocity_magnitude` for its own per-point Mach diagnostics (Mach-transition
251///    tracking, pitch-damping, precession/nutation; see the "MBA-1136 (rank 30)" comments
252///    next to `resolved_atmosphere()` in `cli_api.rs`'s integration loops). The engine
253///    does NOT store a re-derived per-altitude local speed of sound on `TrajectoryPoint`
254///    itself, so this is "the way the solver does it", not a sea-level constant and not a
255///    new per-point atmosphere recompute. It is also the same divisor the truing
256///    observation path uses to derive an observation's Mach (`trajectory_observation.rs`),
257///    so a DSF point keyed at derivation time lands back on the identical Mach at
258///    application time — a per-point local recompute here would skew the two apart.
259/// 2. `drop = result.line_of_sight_height_m - point.position.y` (drop below the
260///    horizontal line of sight, in the solver's ground-referenced frame — the same
261///    `drop_offset - y` convention `cli_api::fit_value_at` uses for BC-fit drop curves).
262/// 3. `point.position.y` is rewritten so that the (possibly rescaled) drop is
263///    `drop * table.factor_at(mach)`.
264///
265/// `result.sampled_points` (populated when `--sample-trajectory` is requested; read by
266/// the Table's "Sampled Trajectory" section, CSV `--full`, and the PDF dope card — the
267/// PDF dope card *always* requires sampling) is a SEPARATE `Vec<TrajectorySample>` from
268/// `points` and was, until MBA-1357 Task 2's review (Critical #2), left untouched by this
269/// function — those outputs silently rendered untrued drops even with an active DSF
270/// table. Each `TrajectorySample` already stores its drop directly as `drop_m` (the same
271/// `LOS - actual` sign convention derived from `points` above — see
272/// `trajectory_sampling.rs`'s `sample_trajectory` doc comment), so no position
273/// reconstruction is needed: `sample.drop_m` is simply multiplied by
274/// `table.factor_at(mach)`, with `mach` computed from `sample.velocity_mps` via the
275/// identical frozen `station_speed_of_sound_mps` divisor used for `points` above. This
276/// mirrors `run_sampled_trajectory`'s (come-ups' own sampled-trajectory path, `main.rs`)
277/// hand-rolled version of the same transform, so both paths now agree. `None` stays
278/// `None` — nothing to scale.
279///
280/// Nothing else is touched: `position.x` (downrange), `position.z` (windage/lateral),
281/// `velocity_magnitude`, `kinetic_energy`, and `time` are byte-identical to their
282/// pre-call values on `points`; `distance_m`, `wind_drift_m`, `velocity_mps`,
283/// `energy_j`, `time_s`, and `flags` are byte-identical on `sampled_points`; every
284/// top-level scalar on `result` itself (`time_of_flight`, `impact_velocity`,
285/// `impact_energy`, `max_range`, `max_height`, ...) is untouched too.
286pub fn apply_dsf(result: &mut TrajectoryResult, table: &DsfTable) {
287    let line_of_sight_height_m = result.line_of_sight_height_m;
288    let station_speed_of_sound_mps = result.station_speed_of_sound_mps;
289
290    for point in result.points.iter_mut() {
291        let mach = if station_speed_of_sound_mps > 0.0 {
292            point.velocity_magnitude / station_speed_of_sound_mps
293        } else {
294            0.0
295        };
296        let factor = table.factor_at(mach);
297        let drop = line_of_sight_height_m - point.position.y;
298        point.position.y = line_of_sight_height_m - drop * factor;
299    }
300
301    if let Some(samples) = result.sampled_points.as_mut() {
302        for sample in samples.iter_mut() {
303            let mach = if station_speed_of_sound_mps > 0.0 {
304                sample.velocity_mps / station_speed_of_sound_mps
305            } else {
306                0.0
307            };
308            sample.drop_m *= table.factor_at(mach);
309        }
310    }
311}
312
313/// Linearly interpolate `(position.y, velocity_magnitude)` at horizontal distance
314/// `target_dist_m` from a solved trajectory's points (`position.x` = downrange). Mirrors
315/// `cli_api::fit_value_at`'s interpolation (private to that module), but resolves both
316/// quantities from the same bracketing pair in one pass since the `dsf` verb needs drop
317/// AND Mach at the identical range. `None` if the trajectory never reaches `target_dist_m`.
318pub fn interpolate_position_and_velocity(
319    points: &[TrajectoryPoint],
320    target_dist_m: f64,
321) -> Option<(f64, f64)> {
322    for i in 0..points.len() {
323        if points[i].position.x >= target_dist_m {
324            if i == 0 {
325                return Some((points[0].position.y, points[0].velocity_magnitude));
326            }
327            let p1 = &points[i - 1];
328            let p2 = &points[i];
329            let dx = p2.position.x - p1.position.x;
330            if dx.abs() < 1e-9 {
331                return Some((p2.position.y, p2.velocity_magnitude));
332            }
333            let t = (target_dist_m - p1.position.x) / dx;
334            let y = p1.position.y + t * (p2.position.y - p1.position.y);
335            let v = p1.velocity_magnitude + t * (p2.velocity_magnitude - p1.velocity_magnitude);
336            return Some((y, v));
337        }
338    }
339    None
340}
341
342/// Whether an observation range is beyond 90% of the trajectory's solved max range —
343/// past this point the solution's reliability degrades (short-range extrapolation of a
344/// trajectory that terminated, e.g., at ground impact just past the observation).
345///
346/// Moved out of `main.rs` alongside [`dsf_observation_warrants_90pct_warning`] (MBA-1357
347/// Task 8): it isn't one of that task's four named helpers, but
348/// `dsf_observation_warrants_90pct_warning` calls it, and a library function cannot call a
349/// private binary-crate function, so it had to move too. Verbatim, unchanged.
350pub fn dsf_observation_beyond_90pct(range_m: f64, solved_max_range_m: f64) -> bool {
351    solved_max_range_m > 0.0 && range_m > 0.9 * solved_max_range_m
352}
353
354/// The downrange distance (meters) where the trajectory's station Mach first drops
355/// below 1.0 (the "crossed_subsonic" transition), linearly interpolated between the
356/// bracketing solved points.
357///
358/// Mirrors `MachTransitionTracker::record_downward_crossings`'s `crossed_subsonic` event
359/// (`cli_api.rs` ~1744), reimplemented here because that tracker is a private
360/// `cli_api.rs` type, and the crossing distances it collects (`transonic_distances`)
361/// aren't retained on `TrajectoryResult` itself — they're only consumed to flag
362/// `TrajectorySample`s (`trajectory_sampling::add_trajectory_flags`), which requires
363/// trajectory sampling to be enabled. `solve_profile_for_dsf` solves with sampling off
364/// (it only needs `result.points`), so this recomputes the same crossing from the plain
365/// points array using the identical Mach divisor `apply_dsf` and the observation path
366/// use (`velocity_magnitude / station_speed_of_sound_mps`).
367///
368/// Returns `None` if the trajectory never goes subsonic within the solved points (still
369/// supersonic/transonic at the last point, an empty/degenerate solve, or a non-finite
370/// station speed of sound).
371pub fn mach_1_crossing_range_m(result: &TrajectoryResult) -> Option<f64> {
372    let sos = result.station_speed_of_sound_mps;
373    if sos <= 0.0 || !sos.is_finite() {
374        return None;
375    }
376
377    let mut previous: Option<(f64, f64)> = None; // (downrange_m, mach)
378    for point in &result.points {
379        let mach = point.velocity_magnitude / sos;
380        if let Some((prev_x, prev_mach)) = previous {
381            if prev_mach >= 1.0 && mach < 1.0 {
382                let denom = prev_mach - mach;
383                if denom.abs() < f64::EPSILON {
384                    return Some(point.position.x);
385                }
386                let t = (prev_mach - 1.0) / denom;
387                return Some(prev_x + t * (point.position.x - prev_x));
388            }
389        }
390        previous = Some((point.position.x, mach));
391    }
392    None
393}
394
395/// Whether the `dsf` verb's "solution reliability degrades" warning should fire.
396///
397/// Two independent gates, BOTH required (MBA-1357 Task 2 review, Critical #1): a solve
398/// envelope sized to comfortably exceed a typical observation made the 90%-of-solved-
399/// range check alone fire unconditionally, since the envelope tracked the observation
400/// itself rather than the profile's own real reach. Requiring the observation to also be
401/// beyond the trajectory's Mach-1.0 crossing ties the warning to an actual downrange-
402/// position judgment (deep into the subsonic regime the DSF table's low end targets),
403/// not merely wherever the caller happened to stop solving.
404pub fn dsf_observation_warrants_90pct_warning(
405    range_m: f64,
406    mach_1_crossing_range_m: Option<f64>,
407    solved_max_range_m: f64,
408) -> bool {
409    let beyond_mach_1_crossing = mach_1_crossing_range_m
410        .map(|crossing_m| range_m > crossing_m)
411        .unwrap_or(false);
412    beyond_mach_1_crossing && dsf_observation_beyond_90pct(range_m, solved_max_range_m)
413}
414
415/// Full input set for [`solve_for_dsf`] — the scalar-BC model (mirroring
416/// [`TruingModelInputsV1`]'s fields) plus every profile field the CLI's historical
417/// `solve_profile_for_dsf` fed into the physics that `TruingModelInputsV1` alone has no
418/// slot for (MBA-1357 Task 8 review, Finding 1). `None` on any `Option` field means
419/// exactly what it meant to the historical code when a profile didn't carry that field —
420/// the same physically neutral default, documented per field below — so a profile that
421/// sets none of them solves byte-identically to a bare converted `TruingModelInputsV1`,
422/// and one that does gets ALL of it honored, not silently dropped.
423///
424/// Three fields the old profile-driven solve also read are intentionally NOT carried
425/// here, confirmed empirically rather than just by reading the physics: a stored
426/// twist-rate override, twist direction, and a stored bullet-length override. Every place
427/// any of the three reaches the trajectory (`enable_magnus`'s Magnus force,
428/// `enable_aerodynamic_jump`'s Litz jump estimator, `enable_precession_nutation`'s
429/// spin-rate term) is gated behind one of those flags, all permanently off in this solve
430/// (see [`solve_for_dsf`]); the only other consumer is the CLI's own separately-computed
431/// "Stability (SG)" display line, which `dsf` never prints. Verified by diffing
432/// `trajectory --saved-profile` output between two otherwise-identical saved profiles
433/// differing only in `--twist-rate` (7 vs 20) and, separately, only in `--bullet-length`
434/// (1.0in vs 2.5in): `Max Range`, `Max Height`, `Zero Angle`, `Time of Flight`, `Impact
435/// Velocity`, `Impact Energy`, and the ground-impact range were byte-identical in both
436/// pairs; only the SG display line (not part of `TrajectoryResult`) differed.
437#[derive(Debug, Clone)]
438pub struct DsfSolveInputs {
439    /// Muzzle velocity, feet/second.
440    pub muzzle_velocity_fps: f64,
441    /// Nominal scalar ballistic coefficient — superseded by `bc_segments`/
442    /// `custom_drag_table` when either is `Some`, same precedence the solver applies.
443    pub ballistic_coefficient: f64,
444    /// Full drag-model family (MBA-1357 Task 8 review round 3, Critical #1): a saved
445    /// profile can be built with any of G1/G2/G5/G6/G7/G8/GI/GS/RA4 (`profile save
446    /// --drag-model`), and `dsf`'s solve honors whichever one the profile actually
447    /// carries — same as `trajectory --saved-profile`. Unlike this struct, the
448    /// scalar-BC [`TruingModelInputsV1`] model `true-velocity`/`true-wind`/`plan-truing`
449    /// share is deliberately G1/G7-only ([`DragModelArg`]); this field is wider because
450    /// `dsf`'s historical profile-driven solve was always wider, and narrowing it here
451    /// silently coerced non-G1/G7 profiles to G1 — see the `From<&TruingModelInputsV1>`
452    /// impl below for how a bridge caller's G1/G7-only model maps onto this field.
453    pub drag_model: DragModel,
454    /// Bullet mass, grains.
455    pub mass_gr: f64,
456    /// Bullet diameter, inches.
457    pub diameter_in: f64,
458    /// Sight height over bore, inches.
459    pub sight_height_in: f64,
460    /// Ambient temperature, degrees Fahrenheit, and whether it was a supplied reading
461    /// (0.49.0). An omitted reading keeps the legacy default-sentinel rule.
462    pub temperature_f: StationReading,
463    /// Station pressure, inches of mercury, and whether it was a supplied reading (0.49.0).
464    /// An omitted reading keeps the legacy default-sentinel rule.
465    pub pressure_inhg: StationReading,
466    /// Relative humidity, percent (0 through 100).
467    pub humidity_pct: f64,
468    /// Altitude, feet.
469    pub altitude_ft: f64,
470
471    /// Wind speed, meters/second. `None` (the default) is calm — byte-identical to a bare
472    /// `TruingModelInputsV1` solve.
473    pub wind_speed_mps: Option<f64>,
474    /// Wind direction, radians, wind-FROM convention (0 = headwind). `None` is 0.0.
475    pub wind_direction_rad: Option<f64>,
476    /// Uphill (positive) / downhill (negative) shooting angle, radians. `None` is level
477    /// (0.0). Materially changes predicted drop when set — this is the field most likely
478    /// to matter of everything in this struct.
479    pub shooting_angle_rad: Option<f64>,
480    /// Deliberate vertical point-of-impact offset AT THE ZERO RANGE, meters (MBA-1359
481    /// semantics — see [`crate::cli_api::BallisticInputs::zero_poi_vertical_m`]). `None`
482    /// is 0.0 (no bias). Directly shifts predicted drop, unlike the two lateral-only
483    /// fields below.
484    pub zero_poi_vertical_m: Option<f64>,
485    /// Deliberate horizontal point-of-impact offset at the zero range, meters. `None` is
486    /// 0.0. Carried for full-fidelity `TrajectoryResult` output (lateral position); inert
487    /// for the vertical drop/Mach the `dsf` command itself reads off the result.
488    pub zero_poi_horizontal_m: Option<f64>,
489    /// Lateral sight-to-bore mount offset, meters (MBA-1396). `None` is 0.0. Same
490    /// "carried for fidelity, lateral-only" note as `zero_poi_horizontal_m`.
491    pub sight_offset_lateral_m: Option<f64>,
492    /// Which standard atmosphere `ballistic_coefficient`/`bc_segments` are referenced to.
493    /// `None` is ICAO (matches every profile saved before this field existed). A real,
494    /// non-cosmetic ~1.8% retardation difference when Army Standard Metro.
495    pub bc_reference_standard: Option<BcReferenceStandard>,
496    /// The profile's own `use_bc_segments` opt-in flag (MBA-1357 Task 8 review round 2,
497    /// Finding: this is NOT redundant with `bc_segments.is_some()`, despite what an
498    /// earlier version of this doc comment claimed). `solve_for_dsf` ORs this with
499    /// `bc_segments.is_some()` (reproducing the historical
500    /// `profile.use_bc_segments.unwrap_or(false) || bc_segments_data.is_some()` exactly)
501    /// because the RK4 derivative path (`derivatives.rs`'s `get_bc_for_velocity` /
502    /// `estimate_bc_segments_for`) treats `use_bc_segments == true` with NO explicit
503    /// segments as an opt-in to AUTO-ESTIMATE a velocity-dependent BC curve from
504    /// diameter/mass/BC — a real drag-model change, not a no-op. Only the separate,
505    /// Mach-keyed gate in `cli_api.rs` (`use_bc_segments && !segments.is_empty()`) treats
506    /// an empty/absent table as inert; the derivative path does not. `profile save
507    /// --use-bc-segments` makes flag-true-with-no-array a real saved state, so this is
508    /// reachable, not theoretical.
509    pub use_bc_segments: bool,
510    /// Velocity-banded BC schedule (MBA-1323 Phase 2). `None`/empty falls through to
511    /// `use_bc_segments` above (auto-estimation if that's `true`, the scalar
512    /// `ballistic_coefficient` otherwise). When `Some` and non-empty, this REPLACES the
513    /// scalar BC for the solve unconditionally (the solver's segments-then-scalar
514    /// precedence), same as the profile path always used.
515    pub bc_segments: Option<Vec<crate::BCSegmentData>>,
516    /// Full Mach/Cd drag curve (MBA-1323 Phase 2, `.a7p` CUSTOM import). `None` uses
517    /// `ballistic_coefficient`/`bc_segments` instead. When `Some`, replaces the BC model
518    /// entirely, same as the profile path.
519    pub custom_drag_table: Option<crate::drag::DragTable>,
520    /// Resolved zero distance, YARDS (the same imperial convention every other field here
521    /// that has one uses). `None` means no zero is configured — the solve stays flat
522    /// (`muzzle_angle` 0.0), byte-identical to the CLI's historical behaviour for a
523    /// profile with neither `auto_zero` nor `zero_distance` set and no `--zero-set`
524    /// selected (MBA-1357 Task 8 review, Finding 2).
525    pub zero_distance_yd: Option<f64>,
526    /// Bore height above ground, meters — the same MBA-1339 `--bore-height` geometry
527    /// `trajectory`/`come-ups` take explicitly, used here only to size the ground-impact
528    /// plane the solve terminates at (`target_height: 0.0`, `ground_threshold: 0.0` in
529    /// [`solve_for_dsf`]). Saved profiles predate that unified flag and never stored one,
530    /// so the CLI adapter falls back to the same historical default
531    /// `trajectory --saved-profile` uses absent an explicit value: 60 in (imperial units)
532    /// or 1500 mm (metric units) — NOT the same numeric default in both unit systems
533    /// (60 in = 1.524 m, 1500 mm = 1.500 m, a genuine ~24 mm difference, not a rounding
534    /// artifact of one shared constant).
535    pub bore_height_m: f64,
536}
537
538impl From<&TruingModelInputsV1> for DsfSolveInputs {
539    /// Widen a bare scalar-BC model into the full [`DsfSolveInputs`] shape a caller with
540    /// no profile-shaped extras reaches (Task 9's `true.dsf` bridge command). EXPLICIT
541    /// bridge defaults — spelled out here because an app author needs to know what this
542    /// assumes: **no wind, a level shot (0.0 shooting angle), no zero-POI bias, no
543    /// sight-mount offset, ICAO BC reference, no BC-segment auto-estimation
544    /// (`use_bc_segments: false`), no explicit BC-segment schedule, no custom drag
545    /// curve, and a 60 in (1.524 m) imperial-default bore height** (a bridge caller has
546    /// no unit system of its own — every other field here is already imperial, e.g.
547    /// `mass_gr`/`diameter_in`, so the imperial bore-height default is the consistent
548    /// choice). `zero_distance_yd` carries `inputs.zero_distance_yd` (mandatory on
549    /// `TruingModelInputsV1`, so always `Some` here — never the "flat, no zero" case).
550    /// `drag_model` widens `inputs.drag_model` ([`DragModelArg`], G1 or G7 only — the
551    /// bridge request's own model has no wider choice) into the corresponding
552    /// [`DragModel`] variant; since the bridge never offers anything outside G1/G7,
553    /// nothing is lost by this widening, unlike the CLI's saved-profile path which can
554    /// carry the full family.
555    fn from(inputs: &TruingModelInputsV1) -> Self {
556        DsfSolveInputs {
557            muzzle_velocity_fps: inputs.muzzle_velocity_fps,
558            ballistic_coefficient: inputs.ballistic_coefficient,
559            drag_model: match inputs.drag_model {
560                DragModelArg::G1 => DragModel::G1,
561                DragModelArg::G7 => DragModel::G7,
562            },
563            mass_gr: inputs.mass_gr,
564            diameter_in: inputs.diameter_in,
565            sight_height_in: inputs.sight_height_in,
566            temperature_f: inputs.temperature_f,
567            pressure_inhg: inputs.pressure_inhg,
568            humidity_pct: inputs.humidity_pct,
569            altitude_ft: inputs.altitude_ft,
570            wind_speed_mps: None,
571            wind_direction_rad: None,
572            shooting_angle_rad: None,
573            zero_poi_vertical_m: None,
574            zero_poi_horizontal_m: None,
575            sight_offset_lateral_m: None,
576            bc_reference_standard: None,
577            use_bc_segments: false,
578            bc_segments: None,
579            custom_drag_table: None,
580            zero_distance_yd: Some(inputs.zero_distance_yd),
581            bore_height_m: 60.0 * 0.0254,
582        }
583    }
584}
585
586/// Solve a [`DsfSolveInputs`]'s own trajectory for the `dsf` command's derivation step
587/// (MBA-1357 Task 8), given plain values directly rather than a saved `Profile` — the
588/// JSON bridge cannot construct a `Profile`, and must not read one from disk.
589///
590/// This is the library half of what was `main.rs`'s private `solve_profile_for_dsf`. The
591/// CLI keeps its saved-profile path by converting a loaded profile into `DsfSolveInputs`
592/// (ALL of the profile fields the historical solve honored — see that struct's own doc
593/// comment for exactly which, and which three are deliberately absent because they're
594/// provably inert here) and delegating. A bridge caller with only a `TruingModelInputsV1`
595/// can go through `DsfSolveInputs::from` instead — see that impl's doc comment for the
596/// explicit defaults it assumes.
597///
598/// `max_range_m` is the solve envelope; `inputs.zero_distance_yd` (`None` = flat, no zero
599/// applied — see that field's doc comment) supplies the zero.
600///
601/// Advanced physics toggles this struct has no field for (Magnus/Coriolis/spin-drift/
602/// aerodynamic-jump/wind-shear/pitch-damping/precession/powder-sensitivity/`cd_scale`,
603/// plus twist rate/direction and bullet length — see `DsfSolveInputs`'s doc comment) stay
604/// off/neutral, matching `solve_profile_for_dsf`'s own historical behaviour for every
605/// profile-only command (`come-ups`, `lead`, `mpbr`).
606pub fn solve_for_dsf(
607    inputs: &DsfSolveInputs,
608    max_range_m: f64,
609) -> Result<TrajectoryResult, String> {
610    let velocity_m = inputs.muzzle_velocity_fps * 0.3048;
611    let mass_kg = inputs.mass_gr * crate::constants::GRAINS_TO_KG;
612    let diameter_m = inputs.diameter_in * 0.0254;
613    let sight_height_m = inputs.sight_height_in * 0.0254;
614    let bullet_length_m = fallback_bullet_length_m(diameter_m, mass_kg);
615
616    // Unit-aware bore height (MBA-1357 Task 10 review, Fix 2): the CLI adapter and the
617    // bridge's `From<&TruingModelInputsV1>` each resolve their own default (60 in
618    // imperial / 1500 mm metric for the CLI, always the imperial 60 in for the bridge,
619    // which has no unit system) and hand it in here; this function no longer picks one
620    // itself.
621    let bore_height_m = inputs.bore_height_m;
622
623    let temperature_c = (inputs.temperature_f.value - 32.0) * 5.0 / 9.0;
624    let pressure_hpa = inputs.pressure_inhg.value * 33.8639;
625    let altitude_m = inputs.altitude_ft * 0.3048;
626    // Resolved from the converted metric pair inside the presence twins below, never
627    // pre-resolved and converted back (that round trip is not bit-exact).
628    let station_presence =
629        StationAtmospherePresence::from_readings(inputs.temperature_f, inputs.pressure_inhg);
630
631    // `inputs.drag_model` is already the full `DragModel` family (MBA-1357 Task 8 review
632    // round 3, Critical #1) — no G1/G7 coercion here.
633    let drag_model = inputs.drag_model;
634
635    let wind_speed_m = inputs.wind_speed_mps.unwrap_or(0.0);
636    let wind_direction_rad = inputs.wind_direction_rad.unwrap_or(0.0);
637    let shooting_angle_rad = inputs.shooting_angle_rad.unwrap_or(0.0);
638    let zero_poi_vertical_m = inputs.zero_poi_vertical_m.unwrap_or(0.0);
639    let zero_poi_horizontal_m = inputs.zero_poi_horizontal_m.unwrap_or(0.0);
640    let sight_offset_lateral_m = inputs.sight_offset_lateral_m.unwrap_or(0.0);
641    let bc_reference_standard = inputs
642        .bc_reference_standard
643        .unwrap_or(BcReferenceStandard::Icao);
644    let bc_segments_data = inputs.bc_segments.clone();
645    // MBA-1357 Task 8 review round 2: reproduces the historical
646    // profile.use_bc_segments.unwrap_or(false) || bc_segments_data.is_some() exactly — the
647    // OR matters because the RK4 derivative path auto-estimates a velocity-dependent BC
648    // curve when use_bc_segments is true even with no explicit segments (see
649    // DsfSolveInputs::use_bc_segments's doc comment); this is NOT simplifiable to
650    // bc_segments_data.is_some() alone.
651    let use_bc_segments = inputs.use_bc_segments || bc_segments_data.is_some();
652
653    let wind = WindConditions {
654        speed: wind_speed_m,
655        direction: wind_direction_rad,
656        vertical_speed: 0.0,
657    };
658    let atmosphere = AtmosphericConditions {
659        temperature: temperature_c,
660        pressure: pressure_hpa,
661        // NOTE: matches solve_profile_for_dsf's (and run_trajectory's) own convention —
662        // the same raw (0-100) value feeds both AtmosphericConditions.humidity (percent,
663        // what the solve actually reads) and BallisticInputs.humidity below (nominally a
664        // 0-1 fraction per its own doc comment). Replicated rather than "fixed" — see
665        // that helper's own note; BallisticInputs.humidity is otherwise inert for a plain
666        // (non-Monte-Carlo) solve.
667        humidity: inputs.humidity_pct,
668        altitude: altitude_m,
669    };
670
671    let mut ballistic_inputs = BallisticInputs {
672        bc_value: inputs.ballistic_coefficient,
673        bc_type: drag_model,
674        bc_reference_standard,
675        bullet_mass: mass_kg,
676        muzzle_velocity: velocity_m,
677        bullet_diameter: diameter_m,
678        bullet_length: bullet_length_m,
679
680        muzzle_angle: 0.0,
681        target_distance: max_range_m,
682        azimuth_angle: 0.0,
683        shot_azimuth: 0.0,
684        shooting_angle: shooting_angle_rad,
685        cant_angle: 0.0,
686        sight_height: sight_height_m,
687        sight_offset_lateral_m,
688        muzzle_height: bore_height_m,
689        target_height: 0.0,
690        zero_poi_vertical_m,
691        zero_poi_horizontal_m,
692        // Ground-impact detection ON (0.0), matching solve_profile_for_dsf's override of
693        // the engine's own default (-100.0, effectively disabled) — the `dsf` command has
694        // no way to disable it.
695        ground_threshold: 0.0,
696
697        altitude: altitude_m,
698        temperature: temperature_c,
699        pressure: pressure_hpa,
700        humidity: inputs.humidity_pct,
701        latitude: None,
702
703        wind_speed: wind_speed_m,
704        wind_angle: wind_direction_rad,
705
706        // Confirmed inert for this solve's own drop/Mach output (see DsfSolveInputs's doc
707        // comment) — kept at the same historical default this helper always fell back to
708        // when a profile carried no override (twist_right stays the historical `true`
709        // default too, for the same reason).
710        twist_rate: crate::stability::default_twist_inches(diameter_m, mass_kg, velocity_m),
711        is_twist_right: true,
712        caliber_inches: diameter_m / 0.0254,
713        weight_grains: mass_kg / crate::constants::GRAINS_TO_KG,
714        manufacturer: None,
715        bullet_model: None,
716        bullet_id: None,
717        bullet_cluster: None,
718
719        use_rk4: true,
720        use_adaptive_rk45: true,
721
722        enable_advanced_effects: false,
723        enable_magnus: false,
724        enable_coriolis: false,
725        use_powder_sensitivity: false,
726        powder_temp_sensitivity: 0.0,
727        powder_temp: 0.0,
728        powder_temp_curve: None,
729        powder_curve_temp_c: None,
730        tipoff_yaw: 0.0,
731        cd_delta2: 7.5,
732        tipoff_decay_distance: 50.0,
733
734        use_bc_segments,
735        bc_segments: None,
736        bc_segments_data,
737        use_enhanced_spin_drift: false,
738        use_form_factor: false,
739        enable_wind_shear: false,
740        wind_shear_model: "none".to_string(),
741        enable_trajectory_sampling: false,
742        sample_interval: 0.0,
743        drops_reference: DropsReference::Los,
744        enable_pitch_damping: false,
745        enable_precession_nutation: false,
746        enable_aerodynamic_jump: false,
747        use_cluster_bc: false,
748
749        custom_drag_table: inputs.custom_drag_table.clone(),
750        cd_scale: 1.0,
751        // A profile's or `.a7p`'s drag curve is the projectile's own (the same reading as
752        // `--drag-table`), flown over sectional density; `DsfSolveInputs` has no reference kind.
753        custom_drag_reference: false,
754
755        bc_type_str: None,
756    };
757
758    // Zero angle: flat (0.0) when no zero is configured, same as a bare
759    // `trajectory --saved-profile NAME` with no --auto-zero (MBA-1357 Task 8 review,
760    // Finding 2 — restores the historical Option<f64> semantics the first version of this
761    // function dropped).
762    if let Some(zero_distance_yd) = inputs.zero_distance_yd {
763        let zero_distance_m = zero_distance_yd * 0.9144;
764        ballistic_inputs.muzzle_angle = calculate_zero_angle_with_station_presence(
765            ballistic_inputs.clone(),
766            zero_distance_m,
767            bore_height_m + sight_height_m,
768            wind.clone(),
769            atmosphere.clone(),
770            station_presence,
771        )
772        .map_err(|e| e.to_string())?;
773        // MBA-1359: the returned angle carries the vertical POI bias; the horizontal bias
774        // is an azimuth term the flight inputs must apply themselves.
775        ballistic_inputs.azimuth_angle += ballistic_inputs.windage_zero_bias_rad(zero_distance_m);
776    }
777
778    let mut solver = TrajectorySolver::new_with_station_presence(
779        ballistic_inputs,
780        wind,
781        atmosphere,
782        station_presence,
783    );
784    solver.set_max_range(max_range_m);
785    solver.set_time_step(0.001);
786    solver.solve().map_err(|e| e.to_string())
787}
788
789#[cfg(test)]
790mod tests {
791    use super::*;
792    use crate::cli_api::TrajectoryPoint;
793    use crate::trajectory_observation::TrajectoryTermination;
794    use crate::trajectory_sampling::{TrajectoryFlag, TrajectorySample};
795    use nalgebra::Vector3;
796
797    fn pt(mach: f64, dsf: f64) -> DsfPoint {
798        DsfPoint { mach, dsf }
799    }
800
801    // ---- factor_at semantics ----
802
803    #[test]
804    fn factor_at_identity_at_and_above_ceiling() {
805        let table = DsfTable::from_points(vec![pt(0.9, 1.2)]).unwrap();
806        assert_eq!(table.factor_at(1.2), 1.0);
807        assert_eq!(table.factor_at(1.5), 1.0);
808        assert_eq!(table.factor_at(3.0), 1.0);
809    }
810
811    #[test]
812    fn factor_at_empty_table_is_always_identity() {
813        let table = DsfTable::from_points(vec![]).unwrap();
814        assert_eq!(table.factor_at(0.5), 1.0);
815        assert_eq!(table.factor_at(1.0), 1.0);
816        assert_eq!(table.factor_at(1.2), 1.0);
817    }
818
819    #[test]
820    fn factor_at_single_point_interpolates_to_the_implicit_anchor() {
821        // (0.9, 1.15) -> anchor (1.2, 1.0). Halfway (mach 1.05) is halfway between the two
822        // DSF values.
823        let table = DsfTable::from_points(vec![pt(0.9, 1.15)]).unwrap();
824        let expected_half = 1.15 + (1.0 - 1.15) * 0.5;
825        assert!((table.factor_at(1.05) - expected_half).abs() < 1e-12);
826        // At the point itself: its own dsf.
827        assert_eq!(table.factor_at(0.9), 1.15);
828        // Continuity at the ceiling boundary: interpolating right up to 1.2 approaches 1.0.
829        let near_ceiling = table.factor_at(1.2 - 1e-9);
830        assert!((near_ceiling - 1.0).abs() < 1e-6);
831    }
832
833    #[test]
834    fn factor_at_linear_between_two_keys() {
835        // (0.8, 1.2) and (1.0, 1.05); at mach 0.9 (halfway) expect halfway between the DSFs.
836        let table = DsfTable::from_points(vec![pt(0.8, 1.2), pt(1.0, 1.05)]).unwrap();
837        let expected = 1.2 + (1.05 - 1.2) * 0.5;
838        assert!((table.factor_at(0.9) - expected).abs() < 1e-12);
839        // Exactly at a key: that key's own dsf.
840        assert_eq!(table.factor_at(0.8), 1.2);
841        assert_eq!(table.factor_at(1.0), 1.05);
842    }
843
844    #[test]
845    fn factor_at_flat_clamp_below_lowest() {
846        let table = DsfTable::from_points(vec![pt(0.8, 1.2), pt(1.0, 1.05)]).unwrap();
847        assert_eq!(table.factor_at(0.5), 1.2);
848        assert_eq!(table.factor_at(0.0001), 1.2);
849    }
850
851    #[test]
852    fn factor_at_interpolates_between_highest_key_and_anchor() {
853        // Highest key (1.0, 1.05) -> anchor (1.2, 1.0). At mach 1.1 (halfway).
854        let table = DsfTable::from_points(vec![pt(0.8, 1.2), pt(1.0, 1.05)]).unwrap();
855        let expected = 1.05 + (1.0 - 1.05) * 0.5;
856        assert!((table.factor_at(1.1) - expected).abs() < 1e-12);
857    }
858
859    // ---- validation rejections ----
860
861    #[test]
862    fn from_points_rejects_mach_at_or_above_ceiling() {
863        assert!(DsfTable::from_points(vec![pt(1.2, 1.1)]).is_err());
864        assert!(DsfTable::from_points(vec![pt(1.3, 1.1)]).is_err());
865    }
866
867    #[test]
868    fn from_points_rejects_non_positive_mach() {
869        assert!(DsfTable::from_points(vec![pt(0.0, 1.1)]).is_err());
870        assert!(DsfTable::from_points(vec![pt(-0.5, 1.1)]).is_err());
871    }
872
873    #[test]
874    fn from_points_rejects_dsf_out_of_range() {
875        assert!(DsfTable::from_points(vec![pt(0.9, 0.0)]).is_err());
876        assert!(DsfTable::from_points(vec![pt(0.9, -1.0)]).is_err());
877        assert!(DsfTable::from_points(vec![pt(0.9, 0.5)]).is_err()); // exclusive bound
878        assert!(DsfTable::from_points(vec![pt(0.9, 2.0)]).is_err()); // exclusive bound
879        assert!(DsfTable::from_points(vec![pt(0.9, 2.5)]).is_err());
880        assert!(DsfTable::from_points(vec![pt(0.9, f64::NAN)]).is_err());
881    }
882
883    #[test]
884    fn from_points_rejects_more_than_six_points() {
885        let points: Vec<DsfPoint> = (0..7).map(|i| pt(0.1 + i as f64 * 0.1, 1.1)).collect();
886        let err = DsfTable::from_points(points).unwrap_err();
887        assert!(
888            err.contains('6'),
889            "error should name the 6-point cap: {err}"
890        );
891    }
892
893    #[test]
894    fn from_points_sorts_ascending_by_mach() {
895        let table = DsfTable::from_points(vec![pt(0.9, 1.1), pt(0.3, 1.3), pt(0.6, 1.2)]).unwrap();
896        let machs: Vec<f64> = table.points().iter().map(|p| p.mach).collect();
897        assert_eq!(machs, vec![0.3, 0.6, 0.9]);
898    }
899
900    // ---- upsert ----
901
902    #[test]
903    fn upsert_appends_when_no_existing_point_is_within_tolerance() {
904        let mut table = DsfTable::from_points(vec![pt(0.5, 1.1)]).unwrap();
905        let outcome = table.upsert(pt(0.8, 1.2)).unwrap();
906        assert_eq!(outcome, UpsertOutcome::Appended);
907        assert_eq!(table.points().len(), 2);
908    }
909
910    #[test]
911    fn upsert_replaces_within_tolerance() {
912        let mut table = DsfTable::from_points(vec![pt(0.5, 1.1)]).unwrap();
913        let new_point = pt(0.53, 1.25); // within 0.05 of 0.5
914        let outcome = table.upsert(new_point).unwrap();
915        match outcome {
916            UpsertOutcome::Replaced { old } => assert_eq!(old, pt(0.5, 1.1)),
917            other => panic!("expected Replaced, got {other:?}"),
918        }
919        assert_eq!(table.points().len(), 1);
920        assert_eq!(table.points()[0], new_point);
921    }
922
923    #[test]
924    fn upsert_boundary_just_outside_tolerance_appends() {
925        let mut table = DsfTable::from_points(vec![pt(0.5, 1.1)]).unwrap();
926        let outcome = table.upsert(pt(0.551, 1.2)).unwrap(); // 0.051 away: outside tolerance
927        assert_eq!(outcome, UpsertOutcome::Appended);
928        assert_eq!(table.points().len(), 2);
929    }
930
931    #[test]
932    fn upsert_errors_at_seventh_distinct_point_naming_the_cap() {
933        let mut table =
934            DsfTable::from_points((0..6).map(|i| pt(0.1 + i as f64 * 0.15, 1.1)).collect())
935                .unwrap();
936        assert_eq!(table.points().len(), 6);
937        // Far from every existing point (nearest is 0.85, 0.15 away — outside the 0.05 tolerance).
938        let err = table.upsert(pt(1.0, 1.3)).unwrap_err();
939        assert!(
940            err.contains('6'),
941            "error should name the 6-point cap: {err}"
942        );
943        assert_eq!(table.points().len(), 6, "rejected point must not be added");
944    }
945
946    #[test]
947    fn upsert_rejects_invalid_point_without_mutating_table() {
948        let mut table = DsfTable::from_points(vec![pt(0.5, 1.1)]).unwrap();
949        assert!(table.upsert(pt(1.2, 1.1)).is_err());
950        assert!(table.upsert(pt(0.6, 3.0)).is_err());
951        assert_eq!(
952            table.points().len(),
953            1,
954            "invalid upsert must not mutate the table"
955        );
956    }
957
958    // ---- apply_dsf drop-only invariant ----
959
960    fn trajectory_point(
961        time: f64,
962        x: f64,
963        y: f64,
964        z: f64,
965        velocity_magnitude: f64,
966    ) -> TrajectoryPoint {
967        TrajectoryPoint {
968            time,
969            position: Vector3::new(x, y, z),
970            velocity_magnitude,
971            kinetic_energy: 0.5 * 0.01 * velocity_magnitude * velocity_magnitude,
972            drag_coefficient: None,
973        }
974    }
975
976    fn trajectory_sample(
977        distance_m: f64,
978        drop_m: f64,
979        wind_drift_m: f64,
980        velocity_mps: f64,
981        time_s: f64,
982        flags: Vec<TrajectoryFlag>,
983    ) -> TrajectorySample {
984        TrajectorySample {
985            distance_m,
986            drop_m,
987            wind_drift_m,
988            velocity_mps,
989            energy_j: 0.5 * 0.01 * velocity_mps * velocity_mps,
990            time_s,
991            flags,
992        }
993    }
994
995    fn fixture_result(points: Vec<TrajectoryPoint>) -> TrajectoryResult {
996        TrajectoryResult {
997            max_range: 500.0,
998            max_height: 2.0,
999            time_of_flight: 1.234,
1000            impact_velocity: 300.0,
1001            impact_energy: 1800.0,
1002            projectile_mass_kg: 0.01,
1003            line_of_sight_height_m: 0.05,
1004            station_speed_of_sound_mps: 340.0,
1005            termination: TrajectoryTermination::MaxRange,
1006            points,
1007            sampled_points: None,
1008            min_pitch_damping: None,
1009            transonic_mach: None,
1010            angular_state: None,
1011            max_yaw_angle: None,
1012            max_precession_angle: None,
1013            aerodynamic_jump: None,
1014            mach_1_2_distance_m: None,
1015            mach_1_0_distance_m: None,
1016            mach_0_9_distance_m: None,
1017        }
1018    }
1019
1020    #[test]
1021    fn apply_dsf_scales_only_drop_leaving_everything_else_byte_identical() {
1022        let sos = 340.0;
1023        let points = vec![
1024            // mach 1.3: supersonic, above the ceiling -> factor 1.0 (untouched drop too).
1025            trajectory_point(0.0, 0.0, 0.05, 0.0, 1.3 * sos),
1026            // mach 0.9: within the table -> interpolated factor.
1027            trajectory_point(0.5, 250.0, 0.02, 1.0, 0.9 * sos),
1028            // mach 0.5: below the lowest key -> flat-clamped factor.
1029            trajectory_point(1.0, 500.0, -1.0, 2.0, 0.5 * sos),
1030        ];
1031        let mut original = fixture_result(points);
1032        // MBA-1357 Task 2 review, Critical #2: sampled_points is a SEPARATE array from
1033        // `points` and must be scaled too (same Mach coverage: 1.3/0.9/0.5, so the same
1034        // expected_factors below apply to both).
1035        original.sampled_points = Some(vec![
1036            // drop_m values chosen to match the drop_before values the points loop below
1037            // derives (los - y): 0.0, 0.03, 1.05 — so the same expected_factors apply.
1038            trajectory_sample(0.0, 0.0, 0.0, 1.3 * sos, 0.0, vec![]),
1039            trajectory_sample(
1040                250.0,
1041                0.03,
1042                1.0,
1043                0.9 * sos,
1044                0.5,
1045                vec![TrajectoryFlag::MachTransition],
1046            ),
1047            trajectory_sample(500.0, 1.05, 2.0, 0.5 * sos, 1.0, vec![TrajectoryFlag::Apex]),
1048        ]);
1049        let table = DsfTable::from_points(vec![pt(0.8, 1.2), pt(1.0, 1.05)]).unwrap();
1050
1051        let mut scaled = original.clone();
1052        apply_dsf(&mut scaled, &table);
1053
1054        for (orig, new) in original.points.iter().zip(scaled.points.iter()) {
1055            assert_eq!(orig.time, new.time, "time must be byte-identical");
1056            assert_eq!(
1057                orig.velocity_magnitude, new.velocity_magnitude,
1058                "velocity must be byte-identical"
1059            );
1060            assert_eq!(
1061                orig.kinetic_energy, new.kinetic_energy,
1062                "energy must be byte-identical"
1063            );
1064            assert_eq!(
1065                orig.position.x, new.position.x,
1066                "downrange must be byte-identical"
1067            );
1068            assert_eq!(
1069                orig.position.z, new.position.z,
1070                "windage must be byte-identical"
1071            );
1072        }
1073        // Top-level fields are untouched too — every one of them.
1074        assert_eq!(original.max_range, scaled.max_range);
1075        assert_eq!(original.max_height, scaled.max_height);
1076        assert_eq!(original.time_of_flight, scaled.time_of_flight);
1077        assert_eq!(original.impact_velocity, scaled.impact_velocity);
1078        assert_eq!(original.impact_energy, scaled.impact_energy);
1079        assert_eq!(original.projectile_mass_kg, scaled.projectile_mass_kg);
1080        assert_eq!(
1081            original.line_of_sight_height_m,
1082            scaled.line_of_sight_height_m
1083        );
1084        assert_eq!(
1085            original.station_speed_of_sound_mps,
1086            scaled.station_speed_of_sound_mps
1087        );
1088        assert_eq!(original.termination, scaled.termination);
1089        assert_eq!(original.min_pitch_damping, scaled.min_pitch_damping);
1090        assert_eq!(original.transonic_mach, scaled.transonic_mach);
1091        assert_eq!(original.max_yaw_angle, scaled.max_yaw_angle);
1092        assert_eq!(original.max_precession_angle, scaled.max_precession_angle);
1093        // AerodynamicJumpComponents has no PartialEq; the fixture carries None and
1094        // apply_dsf must leave it that way.
1095        assert!(original.aerodynamic_jump.is_none() && scaled.aerodynamic_jump.is_none());
1096
1097        let los = original.line_of_sight_height_m;
1098        let mach_09_factor = 1.2 + (1.05 - 1.2) * 0.5; // mach 0.9: halfway between the two keys
1099        let expected_factors = [
1100            1.0,
1101            mach_09_factor,
1102            1.2, /* flat clamp below lowest key */
1103        ];
1104        for (i, (orig, new)) in original.points.iter().zip(scaled.points.iter()).enumerate() {
1105            let drop_before = los - orig.position.y;
1106            let drop_after = los - new.position.y;
1107            let expected_drop = drop_before * expected_factors[i];
1108            assert!(
1109                (drop_after - expected_drop).abs() < 1e-9,
1110                "point {i}: expected scaled drop {expected_drop}, got {drop_after}"
1111            );
1112        }
1113        // The untouched (mach >= 1.2) point's position.y must be exactly unchanged.
1114        assert_eq!(original.points[0].position.y, scaled.points[0].position.y);
1115
1116        // Critical #2: sampled_points scales the SAME way, and every other field on
1117        // each sample is byte-identical.
1118        let orig_samples = original.sampled_points.as_ref().unwrap();
1119        let scaled_samples = scaled.sampled_points.as_ref().unwrap();
1120        assert_eq!(orig_samples.len(), scaled_samples.len());
1121        for (i, (orig, new)) in orig_samples.iter().zip(scaled_samples.iter()).enumerate() {
1122            assert_eq!(
1123                orig.distance_m, new.distance_m,
1124                "sample {i}: distance_m must be byte-identical"
1125            );
1126            assert_eq!(
1127                orig.wind_drift_m, new.wind_drift_m,
1128                "sample {i}: wind_drift_m must be byte-identical"
1129            );
1130            assert_eq!(
1131                orig.velocity_mps, new.velocity_mps,
1132                "sample {i}: velocity_mps must be byte-identical"
1133            );
1134            assert_eq!(
1135                orig.energy_j, new.energy_j,
1136                "sample {i}: energy_j must be byte-identical"
1137            );
1138            assert_eq!(
1139                orig.time_s, new.time_s,
1140                "sample {i}: time_s must be byte-identical"
1141            );
1142            assert_eq!(
1143                orig.flags, new.flags,
1144                "sample {i}: flags must be byte-identical"
1145            );
1146
1147            let expected_drop = orig.drop_m * expected_factors[i];
1148            assert!(
1149                (new.drop_m - expected_drop).abs() < 1e-9,
1150                "sample {i}: expected scaled drop_m {expected_drop}, got {}",
1151                new.drop_m
1152            );
1153        }
1154        // The untouched (mach >= 1.2) sample's drop_m must be exactly unchanged.
1155        assert_eq!(orig_samples[0].drop_m, scaled_samples[0].drop_m);
1156    }
1157
1158    #[test]
1159    fn apply_dsf_leaves_sampled_points_none_when_absent() {
1160        // Same non-empty table as the invariant test above, but sampled_points is None
1161        // (e.g. a solve without --sample-trajectory) — apply_dsf must not panic or
1162        // conjure a Some, it must stay None.
1163        let points = vec![trajectory_point(0.5, 250.0, 0.02, 1.0, 0.9 * 340.0)];
1164        let original = fixture_result(points);
1165        assert!(original.sampled_points.is_none());
1166        let table = DsfTable::from_points(vec![pt(0.8, 1.2), pt(1.0, 1.05)]).unwrap();
1167
1168        let mut scaled = original.clone();
1169        apply_dsf(&mut scaled, &table);
1170
1171        assert!(scaled.sampled_points.is_none(), "None must stay None");
1172    }
1173
1174    #[test]
1175    fn apply_dsf_with_empty_table_leaves_drop_unchanged() {
1176        let points = vec![trajectory_point(0.5, 250.0, 0.02, 1.0, 0.9 * 340.0)];
1177        let original = fixture_result(points);
1178        let table = DsfTable::from_points(vec![]).unwrap();
1179
1180        let mut scaled = original.clone();
1181        apply_dsf(&mut scaled, &table);
1182
1183        assert_eq!(original.points[0].position.y, scaled.points[0].position.y);
1184    }
1185}