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