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 // A profile's or `.a7p`'s drag curve is the projectile's own (the same reading as
745 // `--drag-table`), flown over sectional density; `DsfSolveInputs` has no reference kind.
746 custom_drag_reference: false,
747
748 bc_type_str: None,
749 };
750
751 // Zero angle: flat (0.0) when no zero is configured, same as a bare
752 // `trajectory --saved-profile NAME` with no --auto-zero (MBA-1357 Task 8 review,
753 // Finding 2 — restores the historical Option<f64> semantics the first version of this
754 // function dropped).
755 if let Some(zero_distance_yd) = inputs.zero_distance_yd {
756 let zero_distance_m = zero_distance_yd * 0.9144;
757 ballistic_inputs.muzzle_angle = calculate_zero_angle_with_conditions(
758 ballistic_inputs.clone(),
759 zero_distance_m,
760 bore_height_m + sight_height_m,
761 wind.clone(),
762 atmosphere.clone(),
763 )
764 .map_err(|e| e.to_string())?;
765 // MBA-1359: the returned angle carries the vertical POI bias; the horizontal bias
766 // is an azimuth term the flight inputs must apply themselves.
767 ballistic_inputs.azimuth_angle += ballistic_inputs.windage_zero_bias_rad(zero_distance_m);
768 }
769
770 let mut solver = TrajectorySolver::new(ballistic_inputs, wind, atmosphere);
771 solver.set_max_range(max_range_m);
772 solver.set_time_step(0.001);
773 solver.solve().map_err(|e| e.to_string())
774}
775
776#[cfg(test)]
777mod tests {
778 use super::*;
779 use crate::cli_api::TrajectoryPoint;
780 use crate::trajectory_observation::TrajectoryTermination;
781 use crate::trajectory_sampling::{TrajectoryFlag, TrajectorySample};
782 use nalgebra::Vector3;
783
784 fn pt(mach: f64, dsf: f64) -> DsfPoint {
785 DsfPoint { mach, dsf }
786 }
787
788 // ---- factor_at semantics ----
789
790 #[test]
791 fn factor_at_identity_at_and_above_ceiling() {
792 let table = DsfTable::from_points(vec![pt(0.9, 1.2)]).unwrap();
793 assert_eq!(table.factor_at(1.2), 1.0);
794 assert_eq!(table.factor_at(1.5), 1.0);
795 assert_eq!(table.factor_at(3.0), 1.0);
796 }
797
798 #[test]
799 fn factor_at_empty_table_is_always_identity() {
800 let table = DsfTable::from_points(vec![]).unwrap();
801 assert_eq!(table.factor_at(0.5), 1.0);
802 assert_eq!(table.factor_at(1.0), 1.0);
803 assert_eq!(table.factor_at(1.2), 1.0);
804 }
805
806 #[test]
807 fn factor_at_single_point_interpolates_to_the_implicit_anchor() {
808 // (0.9, 1.15) -> anchor (1.2, 1.0). Halfway (mach 1.05) is halfway between the two
809 // DSF values.
810 let table = DsfTable::from_points(vec![pt(0.9, 1.15)]).unwrap();
811 let expected_half = 1.15 + (1.0 - 1.15) * 0.5;
812 assert!((table.factor_at(1.05) - expected_half).abs() < 1e-12);
813 // At the point itself: its own dsf.
814 assert_eq!(table.factor_at(0.9), 1.15);
815 // Continuity at the ceiling boundary: interpolating right up to 1.2 approaches 1.0.
816 let near_ceiling = table.factor_at(1.2 - 1e-9);
817 assert!((near_ceiling - 1.0).abs() < 1e-6);
818 }
819
820 #[test]
821 fn factor_at_linear_between_two_keys() {
822 // (0.8, 1.2) and (1.0, 1.05); at mach 0.9 (halfway) expect halfway between the DSFs.
823 let table = DsfTable::from_points(vec![pt(0.8, 1.2), pt(1.0, 1.05)]).unwrap();
824 let expected = 1.2 + (1.05 - 1.2) * 0.5;
825 assert!((table.factor_at(0.9) - expected).abs() < 1e-12);
826 // Exactly at a key: that key's own dsf.
827 assert_eq!(table.factor_at(0.8), 1.2);
828 assert_eq!(table.factor_at(1.0), 1.05);
829 }
830
831 #[test]
832 fn factor_at_flat_clamp_below_lowest() {
833 let table = DsfTable::from_points(vec![pt(0.8, 1.2), pt(1.0, 1.05)]).unwrap();
834 assert_eq!(table.factor_at(0.5), 1.2);
835 assert_eq!(table.factor_at(0.0001), 1.2);
836 }
837
838 #[test]
839 fn factor_at_interpolates_between_highest_key_and_anchor() {
840 // Highest key (1.0, 1.05) -> anchor (1.2, 1.0). At mach 1.1 (halfway).
841 let table = DsfTable::from_points(vec![pt(0.8, 1.2), pt(1.0, 1.05)]).unwrap();
842 let expected = 1.05 + (1.0 - 1.05) * 0.5;
843 assert!((table.factor_at(1.1) - expected).abs() < 1e-12);
844 }
845
846 // ---- validation rejections ----
847
848 #[test]
849 fn from_points_rejects_mach_at_or_above_ceiling() {
850 assert!(DsfTable::from_points(vec![pt(1.2, 1.1)]).is_err());
851 assert!(DsfTable::from_points(vec![pt(1.3, 1.1)]).is_err());
852 }
853
854 #[test]
855 fn from_points_rejects_non_positive_mach() {
856 assert!(DsfTable::from_points(vec![pt(0.0, 1.1)]).is_err());
857 assert!(DsfTable::from_points(vec![pt(-0.5, 1.1)]).is_err());
858 }
859
860 #[test]
861 fn from_points_rejects_dsf_out_of_range() {
862 assert!(DsfTable::from_points(vec![pt(0.9, 0.0)]).is_err());
863 assert!(DsfTable::from_points(vec![pt(0.9, -1.0)]).is_err());
864 assert!(DsfTable::from_points(vec![pt(0.9, 0.5)]).is_err()); // exclusive bound
865 assert!(DsfTable::from_points(vec![pt(0.9, 2.0)]).is_err()); // exclusive bound
866 assert!(DsfTable::from_points(vec![pt(0.9, 2.5)]).is_err());
867 assert!(DsfTable::from_points(vec![pt(0.9, f64::NAN)]).is_err());
868 }
869
870 #[test]
871 fn from_points_rejects_more_than_six_points() {
872 let points: Vec<DsfPoint> = (0..7).map(|i| pt(0.1 + i as f64 * 0.1, 1.1)).collect();
873 let err = DsfTable::from_points(points).unwrap_err();
874 assert!(
875 err.contains('6'),
876 "error should name the 6-point cap: {err}"
877 );
878 }
879
880 #[test]
881 fn from_points_sorts_ascending_by_mach() {
882 let table = DsfTable::from_points(vec![pt(0.9, 1.1), pt(0.3, 1.3), pt(0.6, 1.2)]).unwrap();
883 let machs: Vec<f64> = table.points().iter().map(|p| p.mach).collect();
884 assert_eq!(machs, vec![0.3, 0.6, 0.9]);
885 }
886
887 // ---- upsert ----
888
889 #[test]
890 fn upsert_appends_when_no_existing_point_is_within_tolerance() {
891 let mut table = DsfTable::from_points(vec![pt(0.5, 1.1)]).unwrap();
892 let outcome = table.upsert(pt(0.8, 1.2)).unwrap();
893 assert_eq!(outcome, UpsertOutcome::Appended);
894 assert_eq!(table.points().len(), 2);
895 }
896
897 #[test]
898 fn upsert_replaces_within_tolerance() {
899 let mut table = DsfTable::from_points(vec![pt(0.5, 1.1)]).unwrap();
900 let new_point = pt(0.53, 1.25); // within 0.05 of 0.5
901 let outcome = table.upsert(new_point).unwrap();
902 match outcome {
903 UpsertOutcome::Replaced { old } => assert_eq!(old, pt(0.5, 1.1)),
904 other => panic!("expected Replaced, got {other:?}"),
905 }
906 assert_eq!(table.points().len(), 1);
907 assert_eq!(table.points()[0], new_point);
908 }
909
910 #[test]
911 fn upsert_boundary_just_outside_tolerance_appends() {
912 let mut table = DsfTable::from_points(vec![pt(0.5, 1.1)]).unwrap();
913 let outcome = table.upsert(pt(0.551, 1.2)).unwrap(); // 0.051 away: outside tolerance
914 assert_eq!(outcome, UpsertOutcome::Appended);
915 assert_eq!(table.points().len(), 2);
916 }
917
918 #[test]
919 fn upsert_errors_at_seventh_distinct_point_naming_the_cap() {
920 let mut table =
921 DsfTable::from_points((0..6).map(|i| pt(0.1 + i as f64 * 0.15, 1.1)).collect())
922 .unwrap();
923 assert_eq!(table.points().len(), 6);
924 // Far from every existing point (nearest is 0.85, 0.15 away — outside the 0.05 tolerance).
925 let err = table.upsert(pt(1.0, 1.3)).unwrap_err();
926 assert!(
927 err.contains('6'),
928 "error should name the 6-point cap: {err}"
929 );
930 assert_eq!(table.points().len(), 6, "rejected point must not be added");
931 }
932
933 #[test]
934 fn upsert_rejects_invalid_point_without_mutating_table() {
935 let mut table = DsfTable::from_points(vec![pt(0.5, 1.1)]).unwrap();
936 assert!(table.upsert(pt(1.2, 1.1)).is_err());
937 assert!(table.upsert(pt(0.6, 3.0)).is_err());
938 assert_eq!(
939 table.points().len(),
940 1,
941 "invalid upsert must not mutate the table"
942 );
943 }
944
945 // ---- apply_dsf drop-only invariant ----
946
947 fn trajectory_point(
948 time: f64,
949 x: f64,
950 y: f64,
951 z: f64,
952 velocity_magnitude: f64,
953 ) -> TrajectoryPoint {
954 TrajectoryPoint {
955 time,
956 position: Vector3::new(x, y, z),
957 velocity_magnitude,
958 kinetic_energy: 0.5 * 0.01 * velocity_magnitude * velocity_magnitude,
959 drag_coefficient: None,
960 }
961 }
962
963 fn trajectory_sample(
964 distance_m: f64,
965 drop_m: f64,
966 wind_drift_m: f64,
967 velocity_mps: f64,
968 time_s: f64,
969 flags: Vec<TrajectoryFlag>,
970 ) -> TrajectorySample {
971 TrajectorySample {
972 distance_m,
973 drop_m,
974 wind_drift_m,
975 velocity_mps,
976 energy_j: 0.5 * 0.01 * velocity_mps * velocity_mps,
977 time_s,
978 flags,
979 }
980 }
981
982 fn fixture_result(points: Vec<TrajectoryPoint>) -> TrajectoryResult {
983 TrajectoryResult {
984 max_range: 500.0,
985 max_height: 2.0,
986 time_of_flight: 1.234,
987 impact_velocity: 300.0,
988 impact_energy: 1800.0,
989 projectile_mass_kg: 0.01,
990 line_of_sight_height_m: 0.05,
991 station_speed_of_sound_mps: 340.0,
992 termination: TrajectoryTermination::MaxRange,
993 points,
994 sampled_points: None,
995 min_pitch_damping: None,
996 transonic_mach: None,
997 angular_state: None,
998 max_yaw_angle: None,
999 max_precession_angle: None,
1000 aerodynamic_jump: None,
1001 mach_1_2_distance_m: None,
1002 mach_1_0_distance_m: None,
1003 mach_0_9_distance_m: None,
1004 }
1005 }
1006
1007 #[test]
1008 fn apply_dsf_scales_only_drop_leaving_everything_else_byte_identical() {
1009 let sos = 340.0;
1010 let points = vec![
1011 // mach 1.3: supersonic, above the ceiling -> factor 1.0 (untouched drop too).
1012 trajectory_point(0.0, 0.0, 0.05, 0.0, 1.3 * sos),
1013 // mach 0.9: within the table -> interpolated factor.
1014 trajectory_point(0.5, 250.0, 0.02, 1.0, 0.9 * sos),
1015 // mach 0.5: below the lowest key -> flat-clamped factor.
1016 trajectory_point(1.0, 500.0, -1.0, 2.0, 0.5 * sos),
1017 ];
1018 let mut original = fixture_result(points);
1019 // MBA-1357 Task 2 review, Critical #2: sampled_points is a SEPARATE array from
1020 // `points` and must be scaled too (same Mach coverage: 1.3/0.9/0.5, so the same
1021 // expected_factors below apply to both).
1022 original.sampled_points = Some(vec![
1023 // drop_m values chosen to match the drop_before values the points loop below
1024 // derives (los - y): 0.0, 0.03, 1.05 — so the same expected_factors apply.
1025 trajectory_sample(0.0, 0.0, 0.0, 1.3 * sos, 0.0, vec![]),
1026 trajectory_sample(
1027 250.0,
1028 0.03,
1029 1.0,
1030 0.9 * sos,
1031 0.5,
1032 vec![TrajectoryFlag::MachTransition],
1033 ),
1034 trajectory_sample(500.0, 1.05, 2.0, 0.5 * sos, 1.0, vec![TrajectoryFlag::Apex]),
1035 ]);
1036 let table = DsfTable::from_points(vec![pt(0.8, 1.2), pt(1.0, 1.05)]).unwrap();
1037
1038 let mut scaled = original.clone();
1039 apply_dsf(&mut scaled, &table);
1040
1041 for (orig, new) in original.points.iter().zip(scaled.points.iter()) {
1042 assert_eq!(orig.time, new.time, "time must be byte-identical");
1043 assert_eq!(
1044 orig.velocity_magnitude, new.velocity_magnitude,
1045 "velocity must be byte-identical"
1046 );
1047 assert_eq!(
1048 orig.kinetic_energy, new.kinetic_energy,
1049 "energy must be byte-identical"
1050 );
1051 assert_eq!(
1052 orig.position.x, new.position.x,
1053 "downrange must be byte-identical"
1054 );
1055 assert_eq!(
1056 orig.position.z, new.position.z,
1057 "windage must be byte-identical"
1058 );
1059 }
1060 // Top-level fields are untouched too — every one of them.
1061 assert_eq!(original.max_range, scaled.max_range);
1062 assert_eq!(original.max_height, scaled.max_height);
1063 assert_eq!(original.time_of_flight, scaled.time_of_flight);
1064 assert_eq!(original.impact_velocity, scaled.impact_velocity);
1065 assert_eq!(original.impact_energy, scaled.impact_energy);
1066 assert_eq!(original.projectile_mass_kg, scaled.projectile_mass_kg);
1067 assert_eq!(
1068 original.line_of_sight_height_m,
1069 scaled.line_of_sight_height_m
1070 );
1071 assert_eq!(
1072 original.station_speed_of_sound_mps,
1073 scaled.station_speed_of_sound_mps
1074 );
1075 assert_eq!(original.termination, scaled.termination);
1076 assert_eq!(original.min_pitch_damping, scaled.min_pitch_damping);
1077 assert_eq!(original.transonic_mach, scaled.transonic_mach);
1078 assert_eq!(original.max_yaw_angle, scaled.max_yaw_angle);
1079 assert_eq!(original.max_precession_angle, scaled.max_precession_angle);
1080 // AerodynamicJumpComponents has no PartialEq; the fixture carries None and
1081 // apply_dsf must leave it that way.
1082 assert!(original.aerodynamic_jump.is_none() && scaled.aerodynamic_jump.is_none());
1083
1084 let los = original.line_of_sight_height_m;
1085 let mach_09_factor = 1.2 + (1.05 - 1.2) * 0.5; // mach 0.9: halfway between the two keys
1086 let expected_factors = [
1087 1.0,
1088 mach_09_factor,
1089 1.2, /* flat clamp below lowest key */
1090 ];
1091 for (i, (orig, new)) in original.points.iter().zip(scaled.points.iter()).enumerate() {
1092 let drop_before = los - orig.position.y;
1093 let drop_after = los - new.position.y;
1094 let expected_drop = drop_before * expected_factors[i];
1095 assert!(
1096 (drop_after - expected_drop).abs() < 1e-9,
1097 "point {i}: expected scaled drop {expected_drop}, got {drop_after}"
1098 );
1099 }
1100 // The untouched (mach >= 1.2) point's position.y must be exactly unchanged.
1101 assert_eq!(original.points[0].position.y, scaled.points[0].position.y);
1102
1103 // Critical #2: sampled_points scales the SAME way, and every other field on
1104 // each sample is byte-identical.
1105 let orig_samples = original.sampled_points.as_ref().unwrap();
1106 let scaled_samples = scaled.sampled_points.as_ref().unwrap();
1107 assert_eq!(orig_samples.len(), scaled_samples.len());
1108 for (i, (orig, new)) in orig_samples.iter().zip(scaled_samples.iter()).enumerate() {
1109 assert_eq!(
1110 orig.distance_m, new.distance_m,
1111 "sample {i}: distance_m must be byte-identical"
1112 );
1113 assert_eq!(
1114 orig.wind_drift_m, new.wind_drift_m,
1115 "sample {i}: wind_drift_m must be byte-identical"
1116 );
1117 assert_eq!(
1118 orig.velocity_mps, new.velocity_mps,
1119 "sample {i}: velocity_mps must be byte-identical"
1120 );
1121 assert_eq!(
1122 orig.energy_j, new.energy_j,
1123 "sample {i}: energy_j must be byte-identical"
1124 );
1125 assert_eq!(
1126 orig.time_s, new.time_s,
1127 "sample {i}: time_s must be byte-identical"
1128 );
1129 assert_eq!(
1130 orig.flags, new.flags,
1131 "sample {i}: flags must be byte-identical"
1132 );
1133
1134 let expected_drop = orig.drop_m * expected_factors[i];
1135 assert!(
1136 (new.drop_m - expected_drop).abs() < 1e-9,
1137 "sample {i}: expected scaled drop_m {expected_drop}, got {}",
1138 new.drop_m
1139 );
1140 }
1141 // The untouched (mach >= 1.2) sample's drop_m must be exactly unchanged.
1142 assert_eq!(orig_samples[0].drop_m, scaled_samples[0].drop_m);
1143 }
1144
1145 #[test]
1146 fn apply_dsf_leaves_sampled_points_none_when_absent() {
1147 // Same non-empty table as the invariant test above, but sampled_points is None
1148 // (e.g. a solve without --sample-trajectory) — apply_dsf must not panic or
1149 // conjure a Some, it must stay None.
1150 let points = vec![trajectory_point(0.5, 250.0, 0.02, 1.0, 0.9 * 340.0)];
1151 let original = fixture_result(points);
1152 assert!(original.sampled_points.is_none());
1153 let table = DsfTable::from_points(vec![pt(0.8, 1.2), pt(1.0, 1.05)]).unwrap();
1154
1155 let mut scaled = original.clone();
1156 apply_dsf(&mut scaled, &table);
1157
1158 assert!(scaled.sampled_points.is_none(), "None must stay None");
1159 }
1160
1161 #[test]
1162 fn apply_dsf_with_empty_table_leaves_drop_unchanged() {
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 let table = DsfTable::from_points(vec![]).unwrap();
1166
1167 let mut scaled = original.clone();
1168 apply_dsf(&mut scaled, &table);
1169
1170 assert_eq!(original.points[0].position.y, scaled.points[0].position.y);
1171 }
1172}