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}