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