Skip to main content

ballistics_engine/
truing.rs

1//! Velocity / BC truing core (MBA-1343 Phase A).
2//!
3//! The multi-observation joint MV+BC calibration (MBA-1316) extracted from the
4//! CLI binary so non-CLI front ends (e.g. the WASM terminal) can reuse the
5//! exact compute path. All rendering (table / JSON / CSV) stays with the front
6//! ends; this module goes as far as building a [`MultiTruingReport`]. The
7//! module is silent: it never writes to stdout/stderr, so progress reporting
8//! is the caller's job (see [`validate_truing_observations`] for how the CLI
9//! sequences its progress line without double-reporting validation errors).
10
11use std::error::Error;
12
13use serde::Serialize;
14
15use crate::cli_api::UnitSystem;
16use crate::constants::GRAINS_TO_KG;
17use crate::{
18    AtmosphericConditions, BCSegmentData, BallisticInputs, DragModel, TrajectorySolver,
19    WindConditions,
20};
21
22/// Drag-model selector for the truing commands, in the shape the CLI exposes
23/// (a plain G1/G7 choice, lowercase-parsed by clap/the WASM terminal). It maps
24/// onto the engine's [`DragModel`] inside the solvers; unlike [`DragModel`] it
25/// deliberately offers only the two standard models the truing forward model
26/// supports.
27#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
28#[serde(rename_all = "lowercase")]
29#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
30pub enum DragModelArg {
31    G1,
32    G7,
33}
34
35/// Unit in which observed drops are supplied to (and residuals reported from)
36/// `true-velocity`'s multi-observation joint calibration (MBA-1316). The
37/// historical single-observation path is always MIL, so `mil` is the default and
38/// leaves that path byte-identical.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
40#[serde(rename_all = "lowercase")]
41#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
42pub enum DropUnit {
43    /// Milliradians (angular)
44    Mil,
45    /// Minutes of angle (angular, true 1/60°)
46    Moa,
47    /// Inches below line of sight (linear drop at the target)
48    In,
49}
50
51impl DropUnit {
52    /// Short label used in tables/JSON/CSV.
53    pub fn label(self) -> &'static str {
54        match self {
55            DropUnit::Mil => "mil",
56            DropUnit::Moa => "moa",
57            DropUnit::In => "in",
58        }
59    }
60
61    /// Parse a drop-unit token (`"mil"` | `"moa"` | `"in"`, case-insensitive)
62    /// without going through clap, for non-CLI front ends (e.g. the WASM
63    /// terminal parser).
64    pub fn parse(s: &str) -> Result<Self, String> {
65        match s.to_ascii_lowercase().as_str() {
66            "mil" => Ok(DropUnit::Mil),
67            "moa" => Ok(DropUnit::Moa),
68            "in" => Ok(DropUnit::In),
69            _ => Err(format!("invalid drop unit '{s}': expected mil, moa, or in")),
70        }
71    }
72
73    /// Express a linear vertical drop below the line of sight (`drop_m`, meters)
74    /// at horizontal distance `z_m` (meters) in this unit. Angular units use the
75    /// same small-angle convention the engine's MIL output has always used
76    /// (`drop/range`), so `mil` matches the legacy single-observation contract
77    /// exactly; `moa` is true minutes of angle and `in` is the linear drop itself.
78    pub fn express_drop_m(self, drop_m: f64, z_m: f64) -> f64 {
79        match self {
80            // (drop_m / z_m) radians * 1000 mrad/rad
81            DropUnit::Mil => (drop_m / z_m) * 1000.0,
82            // (drop_m / z_m) radians * (180/pi) deg/rad * 60 min/deg
83            DropUnit::Moa => (drop_m / z_m) * (180.0 / std::f64::consts::PI) * 60.0,
84            // linear drop, meters -> inches
85            DropUnit::In => drop_m / 0.0254,
86        }
87    }
88}
89
90/// Resolve a bullet length (meters) for a bullet whose length the shooter did not supply (MBA-1135).
91///
92/// Prefers the mass-based physical estimate ([`crate::stability::estimate_bullet_length_m`]),
93/// falling back to the historical 4.5-caliber heuristic only when mass is unavailable so the caller
94/// always gets a positive length. `diameter_m` and `mass_kg` are SI.
95pub fn fallback_bullet_length_m(diameter_m: f64, mass_kg: f64) -> f64 {
96    let est = crate::stability::estimate_bullet_length_m(diameter_m, mass_kg);
97    if est > 0.0 {
98        est
99    } else {
100        diameter_m * 4.5
101    }
102}
103
104/// Barrel twist supplied to the truing forward model (MBA-1392).
105///
106/// Drop-only truing never needed a twist rate — gyroscopic spin drift is a purely
107/// lateral effect and the fit only ever looked at vertical drop. Back-solving wind from
108/// an observed HORIZONTAL miss does need it: the spin component of that miss has to be
109/// modelled, or it is mistaken for wind. Supplying this turns
110/// [`crate::BallisticInputs::use_enhanced_spin_drift`] on for the truing solves;
111/// omitting it leaves the flag off, exactly as every pre-MBA-1392 truing solve did.
112#[derive(Debug, Clone, Copy, PartialEq)]
113pub struct TruingTwist {
114    /// Twist rate in inches per turn, positive (e.g. `11.0` for a 1:11" barrel).
115    pub rate_in: f64,
116    /// `true` = right-hand twist (drifts RIGHT), `false` = left-hand (drifts LEFT).
117    pub right_hand: bool,
118}
119
120/// Earth-frame position and pointing supplied to the truing forward model (MBA-1392).
121///
122/// Coriolis needs BOTH latitude and the shot's compass bearing, so the two travel as one
123/// value: there is no meaningful "latitude but no azimuth" configuration, and modelling
124/// one without the other would silently invent the missing half.
125#[derive(Debug, Clone, Copy, PartialEq)]
126pub struct TruingEarthFrame {
127    /// Firing latitude in degrees, north positive.
128    pub latitude_deg: f64,
129    /// Compass bearing the shot is fired ALONG, degrees, 0 = North, 90 = East. This is
130    /// [`crate::BallisticInputs::shot_azimuth`] expressed in degrees — NOT
131    /// `azimuth_angle`, which is a small horizontal aiming offset.
132    pub shot_azimuth_deg: f64,
133}
134
135/// Optional physics the truing forward model can include (MBA-1392).
136///
137/// [`TruingEnvironment::default()`] IS the historical truing forward model: calm air, no
138/// spin drift, no Coriolis — the state every truing solve hardcoded before MBA-1392, and
139/// therefore what every drop-only caller keeps passing. Each field is opt-in and is only
140/// enabled when the data that makes it meaningful is actually present; an absent field
141/// leaves the corresponding engine flag OFF rather than substituting a guess, so whatever
142/// that effect contributes stays absorbed into the fitted quantity. Callers that fit from
143/// a lateral observation are expected to say which effects they could not subtract.
144#[derive(Debug, Clone, Default)]
145pub struct TruingEnvironment {
146    /// Wind during the observed shots. The default (calm) reproduces the
147    /// `WindConditions::default()` that the truing solves have always hardcoded.
148    pub wind: WindConditions,
149    /// Barrel twist; `Some` enables gyroscopic spin drift on the truing solves.
150    pub twist: Option<TruingTwist>,
151    /// Latitude + shot azimuth; `Some` enables Coriolis on the truing solves.
152    pub earth: Option<TruingEarthFrame>,
153}
154
155impl TruingEnvironment {
156    /// Apply the opt-in physics to a freshly assembled truing [`BallisticInputs`].
157    ///
158    /// A bit-exact no-op for [`TruingEnvironment::default()`]: every mutation sits behind
159    /// an `if let Some(..)`, so the drop-only callers assemble byte-identical inputs to
160    /// the ones they assembled before MBA-1392.
161    fn apply_to(&self, inputs: &mut BallisticInputs) {
162        if let Some(twist) = self.twist {
163            inputs.twist_rate = twist.rate_in;
164            inputs.is_twist_right = twist.right_hand;
165            inputs.use_enhanced_spin_drift = true;
166        }
167        if let Some(earth) = self.earth {
168            inputs.latitude = Some(earth.latitude_deg);
169            inputs.shot_azimuth = earth.shot_azimuth_deg.to_radians();
170            inputs.enable_coriolis = true;
171        }
172    }
173}
174
175/// Shared solver assembly for the truing forward model (MBA-1316; extracted from
176/// [`solve_trajectory_drop`] for MBA-1405 so a second caller can read off the full
177/// [`crate::TrajectoryResult`] — e.g. its labeled Mach crossings — instead of just
178/// the drop/range pair. SI conversion, base [`BallisticInputs`], zero-angle solve,
179/// and `max_range`/`time_step` are all identical to what `solve_trajectory_drop`
180/// has always used, so both callers stay physically identical.
181///
182/// MBA-1392 threaded `env` through here (and through the second, batched assembly in
183/// [`TruingForwardModel::predict_many_in_unit`]) so wind / spin drift / Coriolis can be
184/// modelled when the caller has the data for them. `TruingEnvironment::default()` is the
185/// pre-MBA-1392 behaviour exactly.
186#[allow(
187    clippy::too_many_arguments,
188    reason = "flat arguments preserve the existing velocity-truing compatibility helper"
189)]
190fn solve_truing_trajectory(
191    velocity_fps: f64,
192    bc: f64,
193    drag_model: DragModelArg,
194    mass_gr: f64,
195    diameter_in: f64,
196    zero_distance_yd: f64,
197    range_yd: f64,
198    sight_height_in: f64,
199    temperature_f: f64,
200    pressure_inhg: f64,
201    humidity: f64,
202    altitude_ft: f64,
203    bc_segments: &Option<Vec<BCSegmentData>>,
204    env: &TruingEnvironment,
205) -> Result<crate::TrajectoryResult, Box<dyn Error>> {
206    // Convert to SI units
207    let velocity_ms = velocity_fps * 0.3048;
208    let mass_kg = mass_gr * GRAINS_TO_KG;
209    let diameter_m = diameter_in * 0.0254;
210    let zero_m = zero_distance_yd * 0.9144;
211    let range_m = range_yd * 0.9144;
212    let sight_height_m = sight_height_in * 0.0254;
213    let altitude_m = altitude_ft * 0.3048;
214    let temperature_c = (temperature_f - 32.0) * 5.0 / 9.0;
215    let pressure_hpa = pressure_inhg * 33.8639; // Convert inHg to hPa
216
217    let drag_model_enum = match drag_model {
218        DragModelArg::G1 => DragModel::G1,
219        DragModelArg::G7 => DragModel::G7,
220    };
221
222    // Create base inputs - match defaults used by trajectory command
223    let mut inputs = BallisticInputs {
224        muzzle_velocity: velocity_ms,
225        bc_value: bc,
226        bc_type: drag_model_enum,
227        bullet_mass: mass_kg,
228        bullet_diameter: diameter_m,
229        bullet_length: fallback_bullet_length_m(diameter_m, mass_kg), // MBA-1135 mass-based estimate
230        sight_height: sight_height_m,
231        target_distance: range_m + 100.0, // Overshoot to ensure we have data
232        use_bc_segments: bc_segments.is_some(),
233        bc_segments_data: bc_segments.clone(),
234        use_rk4: true,
235        muzzle_angle: 0.0,    // Will be set by zero angle calculation
236        ..Default::default()  // Uses muzzle_height: 0.0 by default
237    };
238    // MBA-1392: opt-in spin drift / Coriolis. No-op for the default environment.
239    env.apply_to(&mut inputs);
240
241    // Set up atmospheric conditions
242    // AtmosphericConditions expects: temperature in Celsius, pressure in hPa, humidity 0-100, altitude in meters
243    let atmosphere = AtmosphericConditions {
244        temperature: temperature_c,
245        pressure: pressure_hpa,
246        humidity, // Already 0-100 from input
247        altitude: altitude_m,
248    };
249
250    // MBA-1392: the observed shots' wind. `WindConditions::default()` (calm) for every
251    // drop-only caller, i.e. what this line was literally hardcoded to before.
252    let wind = env.wind.clone();
253
254    // Calculate zero angle for the zero distance
255    // Target height is sight_height because the bullet must cross the LOS at zero distance
256    // The LOS is at y = sight_height (sight is above bore by sight_height)
257    // So the bullet (starting at y = 0 = bore level) must rise to y = sight_height at zero distance
258    let zero_angle = crate::calculate_zero_angle_with_conditions(
259        inputs.clone(),
260        zero_m,
261        sight_height_m, // target height at zero distance (LOS height)
262        wind.clone(),
263        atmosphere.clone(),
264    )?;
265
266    // Set the calculated zero angle
267    inputs.muzzle_angle = zero_angle;
268
269    // Create solver and solve
270    let mut solver = TrajectorySolver::new(inputs, wind, atmosphere);
271    solver.set_max_range(range_m + 100.0);
272    solver.set_time_step(0.0001);
273
274    Ok(solver.solve()?)
275}
276
277/// One sample of the truing forward model at a requested range (MBA-1392).
278///
279/// The three axes are named after the McCoy frame the engine integrates in, because the
280/// historical code did not: `solve_trajectory_drop`'s second return value was a local
281/// called `z` that actually held `position.x` (the DOWNRANGE distance reached), while
282/// `position.z` — the real lateral axis, and the datum a wind fit reads — was never
283/// sampled at all. Naming both explicitly is what stops the two being confused again.
284#[derive(Debug, Clone, Copy, PartialEq)]
285pub(crate) struct TruingSample {
286    /// Vertical distance BELOW the line of sight, meters (positive = below LOS).
287    pub drop_m: f64,
288    /// DOWNRANGE distance of the sample (McCoy x), meters — `~range_m`.
289    pub downrange_m: f64,
290    /// LATERAL offset of the sample (McCoy z), meters, positive = RIGHT of the line of
291    /// sight. Identically zero for a calm / no-spin / no-Coriolis solve, which is why
292    /// drop-only truing never had to look at it.
293    pub lateral_m: f64,
294}
295
296/// Read a [`TruingSample`] off a solved truing trajectory at `range_m`.
297///
298/// Shared by every forward-model entry point so drop and lateral are always sampled the
299/// same way. `interpolate = false` returns the first trajectory sample at or past the
300/// target range (the historical behaviour); `true` linearly interpolates between the
301/// bracketing samples to land exactly on `range_m`, removing the ~v*dt spatial
302/// quantization that otherwise stair-steps the cost surface.
303fn sample_truing_result(
304    result: &crate::TrajectoryResult,
305    range_m: f64,
306    sight_height_m: f64,
307    interpolate: bool,
308) -> Result<TruingSample, Box<dyn Error>> {
309    // Find the first point at or past the target range.
310    let idx = result
311        .points
312        .iter()
313        .position(|p| p.position.x >= range_m)
314        .ok_or("Trajectory didn't reach target range")?;
315
316    // Determine the downrange distance, bullet height, and lateral offset at that range.
317    let (downrange_m, bullet_y, lateral_m) = if interpolate && idx > 0 {
318        // Linearly interpolate between the bracketing samples to land exactly on
319        // range_m (removes ~v*dt spatial quantization).
320        let p0 = &result.points[idx - 1];
321        let p1 = &result.points[idx];
322        let x0 = p0.position.x;
323        let x1 = p1.position.x;
324        let denom = x1 - x0;
325        if denom.abs() < f64::EPSILON {
326            (p1.position.x, p1.position.y, p1.position.z)
327        } else {
328            let frac = (range_m - x0) / denom;
329            let y = p0.position.y + frac * (p1.position.y - p0.position.y);
330            let z = p0.position.z + frac * (p1.position.z - p0.position.z);
331            (range_m, y, z)
332        }
333    } else {
334        let p = &result.points[idx];
335        (p.position.x, p.position.y, p.position.z)
336    };
337
338    // Calculate drop relative to the line of sight. The trajectory was already launched at the
339    // solved zero angle, so the LOS for this zero solve is horizontal at sight_height_m.
340    // Drop = LOS height - bullet position (positive = below LOS)
341    Ok(TruingSample {
342        drop_m: sight_height_m - bullet_y,
343        downrange_m,
344        lateral_m,
345    })
346}
347
348/// Shared forward-model core for velocity/BC truing (MBA-1316), sampled on all three
349/// axes (MBA-1392).
350///
351/// Solves the real trajectory for the given `(velocity_fps, bc)` candidate under the
352/// supplied load / atmosphere / [`TruingEnvironment`] and returns the [`TruingSample`] at
353/// the target range. This is the exact assembly the single-observation binary search has
354/// always used, factored out so the multi-observation joint fit and the MBA-1392 wind fit
355/// reuse identical physics.
356#[allow(
357    clippy::too_many_arguments,
358    reason = "flat arguments preserve the existing velocity-truing compatibility helper"
359)]
360pub(crate) fn solve_trajectory_sample(
361    velocity_fps: f64,
362    bc: f64,
363    drag_model: DragModelArg,
364    mass_gr: f64,
365    diameter_in: f64,
366    zero_distance_yd: f64,
367    range_yd: f64,
368    sight_height_in: f64,
369    temperature_f: f64,
370    pressure_inhg: f64,
371    humidity: f64,
372    altitude_ft: f64,
373    bc_segments: &Option<Vec<BCSegmentData>>,
374    env: &TruingEnvironment,
375    interpolate: bool,
376) -> Result<TruingSample, Box<dyn Error>> {
377    let range_m = range_yd * 0.9144;
378    let sight_height_m = sight_height_in * 0.0254;
379
380    let result = solve_truing_trajectory(
381        velocity_fps,
382        bc,
383        drag_model,
384        mass_gr,
385        diameter_in,
386        zero_distance_yd,
387        range_yd,
388        sight_height_in,
389        temperature_f,
390        pressure_inhg,
391        humidity,
392        altitude_ft,
393        bc_segments,
394        env,
395    )?;
396
397    sample_truing_result(&result, range_m, sight_height_m, interpolate)
398}
399
400/// Drop-only view of [`solve_trajectory_sample`]: returns `(drop_m, downrange_m)` where
401/// `drop_m` is the linear vertical distance below the line of sight (positive = below
402/// LOS) at the target range and `downrange_m` is the downrange distance actually reached
403/// (~`range_m`). Callers convert to MIL / MOA / inches as needed.
404#[allow(
405    clippy::too_many_arguments,
406    reason = "flat arguments preserve the existing velocity-truing compatibility helper"
407)]
408pub(crate) fn solve_trajectory_drop(
409    velocity_fps: f64,
410    bc: f64,
411    drag_model: DragModelArg,
412    mass_gr: f64,
413    diameter_in: f64,
414    zero_distance_yd: f64,
415    range_yd: f64,
416    sight_height_in: f64,
417    temperature_f: f64,
418    pressure_inhg: f64,
419    humidity: f64,
420    altitude_ft: f64,
421    bc_segments: &Option<Vec<BCSegmentData>>,
422    env: &TruingEnvironment,
423    interpolate: bool,
424) -> Result<(f64, f64), Box<dyn Error>> {
425    let sample = solve_trajectory_sample(
426        velocity_fps,
427        bc,
428        drag_model,
429        mass_gr,
430        diameter_in,
431        zero_distance_yd,
432        range_yd,
433        sight_height_in,
434        temperature_f,
435        pressure_inhg,
436        humidity,
437        altitude_ft,
438        bc_segments,
439        env,
440        interpolate,
441    )?;
442
443    Ok((sample.drop_m, sample.downrange_m))
444}
445
446/// Calculate drop at a given muzzle velocity using trajectory solver
447/// Returns drop in MILs at the target range
448#[allow(
449    clippy::too_many_arguments,
450    reason = "flat arguments preserve the existing velocity-truing compatibility helper"
451)]
452pub(crate) fn calculate_drop_at_velocity(
453    velocity_fps: f64,
454    bc: f64,
455    drag_model: DragModelArg,
456    mass_gr: f64,
457    diameter_in: f64,
458    zero_distance_yd: f64,
459    range_yd: f64,
460    sight_height_in: f64,
461    temperature_f: f64,
462    pressure_inhg: f64,
463    humidity: f64,
464    altitude_ft: f64,
465    bc_segments: &Option<Vec<BCSegmentData>>,
466) -> Result<f64, Box<dyn Error>> {
467    // Preserve the historical MIL contract by delegating to the shared linear-drop
468    // core (MBA-1316). `interpolate = false` reproduces the original "first point
469    // at or past the range" sampling, and the default environment (MBA-1392) is the
470    // calm/no-spin/no-Coriolis model this path has always used, so it stays
471    // byte-identical.
472    let (drop_m, downrange_m) = solve_trajectory_drop(
473        velocity_fps,
474        bc,
475        drag_model,
476        mass_gr,
477        diameter_in,
478        zero_distance_yd,
479        range_yd,
480        sight_height_in,
481        temperature_f,
482        pressure_inhg,
483        humidity,
484        altitude_ft,
485        bc_segments,
486        &TruingEnvironment::default(),
487        false,
488    )?;
489
490    // Convert to MILs: mil = (drop_inches / 36 / range_yards) * 1000
491    // Or equivalently: mil = (drop_m / range_m) * 1000
492    let drop_mil = (drop_m / downrange_m) * 1000.0;
493
494    Ok(drop_mil)
495}
496
497/// Minimum sane chronograph screen distance from the muzzle, in meters (MBA-1377). Below this
498/// the correction is negligible and a nonzero value more likely reflects a data-entry mistake
499/// than a real chronograph placement, so [`correct_chrono_velocity_fps`] rejects it outright
500/// rather than silently applying a near-zero (and therefore noise-dominated) adjustment.
501pub const MIN_CHRONO_DISTANCE_M: f64 = 0.3; // ~1 ft
502
503/// Maximum sane chronograph screen distance from the muzzle, in meters (MBA-1377).
504/// Comfortably past Lapua/JBM's 25 m reference distance -- past this a "chronograph" reading
505/// is no longer describing a screen/radar setup this correction is meant to undo.
506pub const MAX_CHRONO_DISTANCE_M: f64 = 30.0; // ~98 ft
507
508/// Result of back-solving a muzzle velocity from a chronograph reading taken downrange
509/// (MBA-1377; see [`correct_chrono_velocity_fps`]).
510#[derive(Debug, Clone, Copy, PartialEq)]
511pub struct ChronoCorrection {
512    /// The back-solved muzzle velocity, in feet per second.
513    pub muzzle_velocity_fps: f64,
514    /// Number of secant iterations the solve actually took.
515    pub iterations: u32,
516}
517
518/// Forward-model core for the screen-distance correction (MBA-1377): launches
519/// `muzzle_velocity_fps` on a flat (`muzzle_angle = 0`) shot through the SAME BC / drag model /
520/// atmosphere the rest of the command is using, and returns the velocity magnitude at
521/// `screen_distance_m` (linearly interpolated between the bracketing trajectory samples).
522///
523/// Deliberately independent of [`solve_truing_trajectory`]: that helper's zero-angle search and
524/// sight-height bookkeeping are irrelevant here. Chronograph screen distances are short enough
525/// (3-25 m; validated range [`MIN_CHRONO_DISTANCE_M`]..[`MAX_CHRONO_DISTANCE_M`]) that gravity
526/// drop is negligible for the velocity this returns, so firing along the bore line is the
527/// correct simplification for this calculation, not an approximation of convenience.
528#[allow(
529    clippy::too_many_arguments,
530    reason = "flat arguments mirror the rest of this module's forward-model helpers"
531)]
532fn velocity_at_distance_fps(
533    muzzle_velocity_fps: f64,
534    bc: f64,
535    drag_model: DragModelArg,
536    mass_gr: f64,
537    diameter_in: f64,
538    temperature_f: f64,
539    pressure_inhg: f64,
540    humidity: f64,
541    altitude_ft: f64,
542    bc_segments: &Option<Vec<BCSegmentData>>,
543    screen_distance_m: f64,
544) -> Result<f64, Box<dyn Error>> {
545    let velocity_ms = muzzle_velocity_fps * 0.3048;
546    let mass_kg = mass_gr * GRAINS_TO_KG;
547    let diameter_m = diameter_in * 0.0254;
548    let altitude_m = altitude_ft * 0.3048;
549    let temperature_c = (temperature_f - 32.0) * 5.0 / 9.0;
550    let pressure_hpa = pressure_inhg * 33.8639; // inHg to hPa
551
552    let drag_model_enum = match drag_model {
553        DragModelArg::G1 => DragModel::G1,
554        DragModelArg::G7 => DragModel::G7,
555    };
556
557    let inputs = BallisticInputs {
558        muzzle_velocity: velocity_ms,
559        bc_value: bc,
560        bc_type: drag_model_enum,
561        bullet_mass: mass_kg,
562        bullet_diameter: diameter_m,
563        bullet_length: fallback_bullet_length_m(diameter_m, mass_kg), // MBA-1135 mass-based estimate
564        target_distance: screen_distance_m + 2.0, // overshoot so the sample bracket exists
565        use_bc_segments: bc_segments.is_some(),
566        bc_segments_data: bc_segments.clone(),
567        use_rk4: true,
568        muzzle_angle: 0.0, // bore-line: drop over 3-25 m is negligible for velocity
569        ..Default::default()
570    };
571
572    let atmosphere = AtmosphericConditions {
573        temperature: temperature_c,
574        pressure: pressure_hpa,
575        humidity,
576        altitude: altitude_m,
577    };
578
579    let wind = WindConditions::default();
580
581    let mut solver = TrajectorySolver::new(inputs, wind, atmosphere);
582    solver.set_max_range(screen_distance_m + 2.0);
583    solver.set_time_step(0.0001);
584    let result = solver.solve()?;
585
586    let idx = result
587        .points
588        .iter()
589        .position(|p| p.position.x >= screen_distance_m)
590        .ok_or("trajectory did not reach the chronograph screen distance")?;
591
592    if idx == 0 {
593        return Ok(result.points[0].velocity_magnitude / 0.3048);
594    }
595
596    // Linearly interpolate to land exactly on screen_distance_m (same spatial-quantization fix
597    // as solve_trajectory_drop's `interpolate = true` path) so the secant solver in
598    // correct_chrono_velocity_fps sees a smooth function of the candidate muzzle velocity.
599    let p0 = &result.points[idx - 1];
600    let p1 = &result.points[idx];
601    let x0 = p0.position.x;
602    let x1 = p1.position.x;
603    let v_ms = if (x1 - x0).abs() < f64::EPSILON {
604        p1.velocity_magnitude
605    } else {
606        let frac = (screen_distance_m - x0) / (x1 - x0);
607        p0.velocity_magnitude + frac * (p1.velocity_magnitude - p0.velocity_magnitude)
608    };
609    Ok(v_ms / 0.3048)
610}
611
612/// Back-solve a true muzzle velocity from a chronograph reading taken `screen_distance_m` (SI
613/// meters) downrange (MBA-1377).
614///
615/// Most chronographs read 10-15 ft (or 25 m) downrange rather than at the muzzle, so the raw
616/// reading is slightly LOW; JBM ("Distance to Chronograph"), Lapua (V25m to V0), Ballistic AE,
617/// ColdBore, Remington Shoot!, and Hornady all correct for this. Flat-fire point-mass
618/// deceleration is monotone in muzzle velocity over the validated screen-distance band
619/// ([`MIN_CHRONO_DISTANCE_M`]..[`MAX_CHRONO_DISTANCE_M`]), so the secant method converges in a
620/// handful of iterations (McCoy, *Modern Exterior Ballistics*; JBM's published calculator).
621///
622/// This is a pure input-side transform: `velocity_at_distance_fps` runs the EXISTING forward
623/// trajectory model (the same BC / `drag_model` / atmosphere the rest of the command is
624/// configured with) to predict the screen-distance velocity for a candidate muzzle velocity,
625/// and the candidate is iterated until that prediction matches the measured reading. Callers
626/// apply this once, before the corrected value is used for display/comparison --
627/// `solve_truing_trajectory`'s drop-based solves never receive a screen distance.
628#[allow(
629    clippy::too_many_arguments,
630    reason = "flat arguments mirror the rest of this module's forward-model helpers"
631)]
632pub fn correct_chrono_velocity_fps(
633    measured_velocity_fps: f64,
634    screen_distance_m: f64,
635    bc: f64,
636    drag_model: DragModelArg,
637    mass_gr: f64,
638    diameter_in: f64,
639    temperature_f: f64,
640    pressure_inhg: f64,
641    humidity: f64,
642    altitude_ft: f64,
643    bc_segments: &Option<Vec<BCSegmentData>>,
644) -> Result<ChronoCorrection, Box<dyn Error>> {
645    if !measured_velocity_fps.is_finite() || measured_velocity_fps <= 0.0 {
646        return Err("--chrono-velocity must be positive and finite".into());
647    }
648    if !(MIN_CHRONO_DISTANCE_M..=MAX_CHRONO_DISTANCE_M).contains(&screen_distance_m) {
649        return Err(format!(
650            "--chrono-distance is out of range: {screen_distance_m:.3} m downrange (expected \
651             {MIN_CHRONO_DISTANCE_M}..{MAX_CHRONO_DISTANCE_M} m; most chronographs read 10-15 \
652             ft / ~3-5 m, and Lapua/JBM's reference distance is 25 m)"
653        )
654        .into());
655    }
656
657    // Residual: forward-model screen velocity for candidate muzzle velocity `v0`, minus the
658    // measured reading. `correct_chrono_velocity_fps` iterates on `v0` until this is ~0. (Not
659    // JS/Python `eval` -- a plain Rust closure over a local candidate value.)
660    let residual = |v0: f64| -> Result<f64, Box<dyn Error>> {
661        let v_screen = velocity_at_distance_fps(
662            v0,
663            bc,
664            drag_model,
665            mass_gr,
666            diameter_in,
667            temperature_f,
668            pressure_inhg,
669            humidity,
670            altitude_ft,
671            bc_segments,
672            screen_distance_m,
673        )?;
674        Ok(v_screen - measured_velocity_fps)
675    };
676
677    // Secant method. Drag only decelerates, so the true muzzle velocity always exceeds the
678    // downrange reading -- seed the second guess 1% above the first, comfortably inside the
679    // near-linear region for the sub-percent corrections this band produces.
680    let mut v_prev = measured_velocity_fps;
681    let mut f_prev = residual(v_prev)?;
682    let mut v_curr = measured_velocity_fps * 1.01;
683    let mut f_curr = residual(v_curr)?;
684
685    const TOL_FPS: f64 = 1e-4;
686    const MAX_ITERATIONS: u32 = 20;
687    let mut iterations = 0u32;
688
689    while f_curr.abs() > TOL_FPS && iterations < MAX_ITERATIONS {
690        let denom = f_curr - f_prev;
691        if denom.abs() < f64::EPSILON {
692            return Err("chronograph correction failed to converge (stalled secant step)".into());
693        }
694        let v_next = v_curr - f_curr * (v_curr - v_prev) / denom;
695        v_prev = v_curr;
696        f_prev = f_curr;
697        v_curr = v_next;
698        f_curr = residual(v_curr)?;
699        iterations += 1;
700    }
701
702    if f_curr.abs() > TOL_FPS {
703        return Err(format!(
704            "chronograph correction did not converge after {iterations} iterations"
705        )
706        .into());
707    }
708
709    Ok(ChronoCorrection {
710        muzzle_velocity_fps: v_curr,
711        iterations,
712    })
713}
714
715/// Result of the classic single-observation velocity truing
716/// ([`calculate_true_velocity_local`]).
717#[derive(Debug, Clone)]
718pub struct TrueVelocityLocalResult {
719    /// The fitted effective muzzle velocity, in feet per second.
720    pub effective_velocity_fps: f64,
721    /// Number of binary-search iterations actually run.
722    pub iterations: i32,
723    /// Signed residual at the returned velocity, in MIL:
724    /// `calculated_drop_mil - measured_drop_mil`. Positive means the model still
725    /// predicts MORE drop than was measured at the returned velocity; negative
726    /// means less.
727    pub final_error_mil: f64,
728    /// The model-predicted drop (MIL) at the returned velocity.
729    pub calculated_drop_mil: f64,
730    /// Convergence quality: `"high"` (converged, |error| < 0.005 mil),
731    /// `"medium"` (converged within the 0.01 mil tolerance, or stopped early
732    /// with |error| < 0.1 mil), or `"low"` (did not converge; |error| >= 0.1 mil).
733    pub confidence: String,
734}
735
736/// Calculate the effective muzzle velocity that reproduces `measured_drop_mil`
737/// at `range_yd`, via binary search over the real trajectory solver
738/// (the classic single-observation `true-velocity` path).
739///
740/// Returns a [`TrueVelocityLocalResult`] carrying the fitted velocity (fps),
741/// the iteration count, the signed final residual in MIL (positive = the model
742/// still predicts more drop than measured), the model-predicted drop at the
743/// returned velocity, and a `"high"`/`"medium"`/`"low"` confidence label — see
744/// the field docs on [`TrueVelocityLocalResult`] for the exact banding.
745///
746/// Errors on degenerate inputs (`range_yd` non-positive or non-finite,
747/// `measured_drop_mil` non-finite) and on any trajectory-solver failure.
748#[allow(
749    clippy::too_many_arguments,
750    reason = "flat arguments mirror the stable true-velocity CLI command shape"
751)]
752pub fn calculate_true_velocity_local(
753    measured_drop_mil: f64,
754    range_yd: f64,
755    bc: f64,
756    drag_model: DragModelArg,
757    mass_gr: f64,
758    diameter_in: f64,
759    zero_distance_yd: f64,
760    sight_height_in: f64,
761    temperature_f: f64,
762    pressure_inhg: f64,
763    humidity: f64,
764    altitude_ft: f64,
765    bc_segments: &Option<Vec<BCSegmentData>>,
766) -> Result<TrueVelocityLocalResult, Box<dyn Error>> {
767    // Reject degenerate inputs instead of letting NaN/inf flow through the
768    // solver and come back as Ok(NaN). The native CLI's clap range validators
769    // cannot produce these; direct library / WASM callers can.
770    if !range_yd.is_finite() || range_yd <= 0.0 {
771        return Err("range must be positive and finite".into());
772    }
773    if !measured_drop_mil.is_finite() {
774        return Err("measured drop must be finite".into());
775    }
776
777    // Binary search between velocity bounds
778    let mut velocity_low = 1500.0;
779    let mut velocity_high = 4500.0;
780    let tolerance_mil = 0.01; // 0.01 MIL tolerance
781    let max_iterations = 50;
782
783    let mut iterations = 0;
784    let mut last_error = 0.0;
785    let mut last_calculated_drop = 0.0;
786
787    for i in 0..max_iterations {
788        iterations = i + 1;
789        let test_velocity = (velocity_low + velocity_high) / 2.0;
790
791        // Run trajectory at test velocity
792        let calculated_drop_mil = calculate_drop_at_velocity(
793            test_velocity,
794            bc,
795            drag_model,
796            mass_gr,
797            diameter_in,
798            zero_distance_yd,
799            range_yd,
800            sight_height_in,
801            temperature_f,
802            pressure_inhg,
803            humidity,
804            altitude_ft,
805            bc_segments,
806        )?;
807
808        last_calculated_drop = calculated_drop_mil;
809        let error = calculated_drop_mil - measured_drop_mil;
810        last_error = error;
811
812        if error.abs() < tolerance_mil {
813            // Converged
814            let confidence = if error.abs() < 0.005 {
815                "high"
816            } else {
817                "medium"
818            };
819
820            return Ok(TrueVelocityLocalResult {
821                effective_velocity_fps: test_velocity,
822                iterations,
823                final_error_mil: error,
824                calculated_drop_mil,
825                confidence: confidence.to_string(),
826            });
827        }
828
829        // Higher calculated drop = bullet is slower = need higher velocity
830        // Lower calculated drop = bullet is faster = need lower velocity
831        if calculated_drop_mil > measured_drop_mil {
832            // Bullet dropping more than observed = slower than actual
833            // Need higher velocity
834            velocity_low = test_velocity;
835        } else {
836            // Bullet dropping less = faster than actual
837            // Need lower velocity
838            velocity_high = test_velocity;
839        }
840
841        // Check for convergence issues
842        if (velocity_high - velocity_low).abs() < 0.5 {
843            break;
844        }
845    }
846
847    // Did not converge within tolerance, return best estimate
848    let final_velocity = (velocity_low + velocity_high) / 2.0;
849    let confidence = if last_error.abs() < 0.1 {
850        "medium"
851    } else {
852        "low"
853    };
854
855    Ok(TrueVelocityLocalResult {
856        effective_velocity_fps: final_velocity,
857        iterations,
858        final_error_mil: last_error,
859        calculated_drop_mil: last_calculated_drop,
860        confidence: confidence.to_string(),
861    })
862}
863
864// ============================================================================
865// MBA-1316: multi-observation joint MV + BC calibration (truing v2)
866// ============================================================================
867//
868// The classic `true-velocity` path fits muzzle velocity from a single observed
869// drop while BC is held fixed. Mid-range (fully supersonic) drops are dominated
870// by time of flight and therefore constrain muzzle velocity; long-range /
871// transonic drops are where BC bites. With observations spread across those
872// regimes we can separate the two. When the spread is too narrow to tell them
873// apart we refuse the joint fit and fit muzzle velocity only, saying so.
874
875// Solver / fit bounds and identifiability gates. See each constant's doc; the
876// empirical basis for the gate values (calibrated against real .30-cal data,
877// MBA-1316):
878//   * 300/600/900 -> sens 0.29, cond 112 -> BC recovered to ~2%   (JOINT)
879//   * 300/600/900/1000 -> sens 0.33, cond 214 -> BC to <0.1%      (JOINT)
880//   * 300/400/500 -> sens 0.16, cond 282 -> BC error ~3%          (MV-ONLY)
881//   * 200/250/300 -> sens 0.12, cond 2337 -> BC error ~12%        (MV-ONLY)
882// (.308 168gr @ 2700/0.475, ~0.03 mil observation noise.) The gates sit between
883// the "recovers to ~2%" band and the "several-percent garbage" band. They are
884// heuristics: a set that just clears them still carries more BC uncertainty
885// than a set that clears them comfortably.
886
887/// Lower muzzle-velocity fit bound (fps): brackets subsonic pistol loads.
888pub(crate) const TRUING_MV_MIN_FPS: f64 = 1000.0;
889/// Upper muzzle-velocity fit bound (fps): brackets hyper-velocity varmint loads.
890pub(crate) const TRUING_MV_MAX_FPS: f64 = 5000.0;
891/// Lower BC fit bound; matches the CLI's `--bc` validator minimum.
892pub(crate) const TRUING_BC_MIN: f64 = 0.05;
893/// Upper BC fit bound; matches the CLI's `--bc` validator maximum.
894pub(crate) const TRUING_BC_MAX: f64 = 2.0;
895/// Iteration cap shared by the MV-only and joint fitters.
896pub(crate) const TRUING_MAX_ITERS: usize = 40;
897
898/// Identifiability gate: minimum
899/// `sensitivity_ratio = ||bc*d(drop)/d(bc)|| / ||mv*d(drop)/d(mv)||` — how much
900/// a fractional BC change moves the predicted drops relative to a fractional MV
901/// change. Below this, the observations barely constrain BC and the joint fit
902/// is refused (MV-only fallback).
903pub(crate) const TRUING_MIN_BC_SENSITIVITY_RATIO: f64 = 0.20;
904/// Identifiability gate: maximum `condition_number = (1+|c|)/(1-|c|)` (|c| =
905/// correlation of the raw MV/BC Jacobian columns). Above this the two columns
906/// are collinear — a BC change can be undone by an MV change — and the joint
907/// fit is refused (MV-only fallback). Both this gate and
908/// [`TRUING_MIN_BC_SENSITIVITY_RATIO`] must pass to attempt the joint fit.
909pub(crate) const TRUING_MAX_CONDITION_NUMBER: f64 = 1.0e3;
910
911/// A single observed impact used for truing: range (internal yards) and the
912/// measured drop below line of sight expressed in the caller's drop unit.
913#[derive(Debug, Clone, Copy)]
914pub struct TruingObservation {
915    pub range_yd: f64,
916    pub drop: f64,
917}
918
919/// Complete scalar-BC forward-model input used by the truing design and
920/// uncertainty APIs added in MBA-1346/MBA-1353.
921///
922/// All values use the truing core's historical imperial units.  A front end is
923/// expected to convert its display units once at the boundary.  V1 deliberately
924/// models one scalar G1/G7 coefficient: a velocity-banded BC schedule or custom
925/// Mach/Cd deck does not have a single BC parameter to identify and must not be
926/// silently reduced to one.
927#[derive(Debug, Clone, Copy, Serialize)]
928pub struct TruingModelInputsV1 {
929    /// Nominal muzzle velocity about which a plan is designed, in feet/second.
930    pub muzzle_velocity_fps: f64,
931    /// Nominal scalar ballistic coefficient about which a plan is designed.
932    pub ballistic_coefficient: f64,
933    pub drag_model: DragModelArg,
934    /// Bullet mass in grains.
935    pub mass_gr: f64,
936    /// Bullet diameter in inches.
937    pub diameter_in: f64,
938    /// Zero distance in yards.
939    pub zero_distance_yd: f64,
940    /// Sight height over bore in inches.
941    pub sight_height_in: f64,
942    /// Ambient temperature in degrees Fahrenheit.
943    pub temperature_f: f64,
944    /// Station pressure in inches of mercury.
945    pub pressure_inhg: f64,
946    /// Relative humidity in percent (0 through 100).
947    pub humidity_pct: f64,
948    /// Altitude in feet.
949    pub altitude_ft: f64,
950}
951
952impl TruingModelInputsV1 {
953    /// Validate the scalar-BC model before an expensive design or uncertainty
954    /// calculation.  These bounds mirror the existing truing solver where
955    /// applicable, while rejecting NaN/infinity at the library boundary.
956    pub fn validate(&self) -> Result<(), String> {
957        if !self.muzzle_velocity_fps.is_finite()
958            || !(TRUING_MV_MIN_FPS..=TRUING_MV_MAX_FPS).contains(&self.muzzle_velocity_fps)
959        {
960            return Err(format!(
961                "nominal muzzle velocity must be finite and within {TRUING_MV_MIN_FPS:.0}..={TRUING_MV_MAX_FPS:.0} fps"
962            ));
963        }
964        if !self.ballistic_coefficient.is_finite()
965            || !(TRUING_BC_MIN..=TRUING_BC_MAX).contains(&self.ballistic_coefficient)
966        {
967            return Err(format!(
968                "nominal ballistic coefficient must be finite and within {TRUING_BC_MIN:.2}..={TRUING_BC_MAX:.1}"
969            ));
970        }
971        for (name, value) in [
972            ("bullet mass", self.mass_gr),
973            ("bullet diameter", self.diameter_in),
974            ("zero distance", self.zero_distance_yd),
975            ("sight height", self.sight_height_in),
976            ("pressure", self.pressure_inhg),
977        ] {
978            if !value.is_finite() || value <= 0.0 {
979                return Err(format!("{name} must be positive and finite"));
980            }
981        }
982        if !self.temperature_f.is_finite() {
983            return Err("temperature must be finite".to_string());
984        }
985        if !self.humidity_pct.is_finite() || !(0.0..=100.0).contains(&self.humidity_pct) {
986            return Err("humidity must be finite and within 0..=100 percent".to_string());
987        }
988        if !self.altitude_ft.is_finite() {
989            return Err("altitude must be finite".to_string());
990        }
991        Ok(())
992    }
993
994    /// Borrow this owned public shape as the existing internal forward model.
995    /// The local `None` is intentional: V1 estimates one scalar BC and therefore
996    /// cannot accept a velocity-banded BC schedule.
997    pub(crate) fn with_forward_model<T>(
998        &self,
999        drop_unit: DropUnit,
1000        f: impl FnOnce(&TruingForwardModel<'_>) -> T,
1001    ) -> T {
1002        let no_bc_segments = None;
1003        let model = TruingForwardModel {
1004            drag_model: self.drag_model,
1005            mass_gr: self.mass_gr,
1006            diameter_in: self.diameter_in,
1007            zero_yd: self.zero_distance_yd,
1008            sight_in: self.sight_height_in,
1009            temp_f: self.temperature_f,
1010            press_inhg: self.pressure_inhg,
1011            humidity: self.humidity_pct,
1012            alt_ft: self.altitude_ft,
1013            bc_segments: &no_bc_segments,
1014            drop_unit,
1015            env: TruingEnvironment::default(),
1016        };
1017        f(&model)
1018    }
1019}
1020
1021/// Parse an `--observed RANGE:DROP` token. RANGE is in the caller's distance
1022/// units (yards imperial / meters metric) and is normalized to internal yards;
1023/// DROP stays in the caller's drop unit. Returns a user-facing error string on
1024/// malformed input so the CLI can report cleanly instead of panicking.
1025pub fn parse_truing_observation(s: &str, units: UnitSystem) -> Result<TruingObservation, String> {
1026    let parts: Vec<&str> = s.split(':').collect();
1027    if parts.len() != 2 {
1028        return Err(format!(
1029            "invalid --observed '{s}': expected RANGE:DROP (e.g. 600:5.1)"
1030        ));
1031    }
1032    let range: f64 = parts[0]
1033        .trim()
1034        .parse()
1035        .map_err(|_| format!("invalid --observed range '{}' in '{s}'", parts[0]))?;
1036    let drop: f64 = parts[1]
1037        .trim()
1038        .parse()
1039        .map_err(|_| format!("invalid --observed drop '{}' in '{s}'", parts[1]))?;
1040    if !range.is_finite() || !drop.is_finite() {
1041        return Err(format!("invalid --observed '{s}': values must be finite"));
1042    }
1043    let range_yd = match units {
1044        UnitSystem::Imperial => range,
1045        UnitSystem::Metric => range / 0.9144,
1046    };
1047    Ok(TruingObservation { range_yd, drop })
1048}
1049
1050/// Validate a truing observation set: every range finite and positive, every
1051/// drop finite and non-zero, and no two observations at (numerically) the same
1052/// range. [`run_multi_observation_truing_core`] runs this itself, so callers
1053/// don't have to — it is public so a front end can pre-validate when it wants
1054/// to sequence its own progress output strictly after validation (the native
1055/// CLI prints its "Fitting N observations..." progress line only for sets that
1056/// will actually be fitted). Error strings are the stable user-facing ones.
1057pub fn validate_truing_observations(observations: &[TruingObservation]) -> Result<(), String> {
1058    for o in observations {
1059        if !o.range_yd.is_finite() || o.range_yd <= 0.0 {
1060            return Err(format!(
1061                "observation range must be a positive finite distance (got {})",
1062                o.range_yd
1063            ));
1064        }
1065        if !o.drop.is_finite() || o.drop == 0.0 {
1066            return Err(
1067                "observation drop must be non-zero (a zero drop carries no truing information)"
1068                    .to_string(),
1069            );
1070        }
1071    }
1072    for i in 0..observations.len() {
1073        for j in (i + 1)..observations.len() {
1074            if (observations[i].range_yd - observations[j].range_yd).abs() < 1e-6 {
1075                return Err(format!(
1076                    "duplicate observation range ({:.3} yd internal): each observation must be at a distinct range",
1077                    observations[i].range_yd
1078                ));
1079            }
1080        }
1081    }
1082    Ok(())
1083}
1084
1085/// Fixed load / atmosphere for a truing fit. The two free parameters (muzzle
1086/// velocity and BC) are supplied per prediction so the fitter can vary them.
1087pub(crate) struct TruingForwardModel<'a> {
1088    pub drag_model: DragModelArg,
1089    pub mass_gr: f64,
1090    pub diameter_in: f64,
1091    pub zero_yd: f64,
1092    pub sight_in: f64,
1093    pub temp_f: f64,
1094    pub press_inhg: f64,
1095    pub humidity: f64,
1096    pub alt_ft: f64,
1097    pub bc_segments: &'a Option<Vec<BCSegmentData>>,
1098    pub drop_unit: DropUnit,
1099    /// MBA-1392: opt-in wind / spin drift / Coriolis for the forward model. Every
1100    /// drop-only fitter constructs this as [`TruingEnvironment::default()`], which is the
1101    /// calm, effects-off model truing has always used.
1102    pub env: TruingEnvironment,
1103}
1104
1105impl TruingForwardModel<'_> {
1106    /// Predicted drop (in the configured drop unit) at `range_yd` for the given
1107    /// muzzle velocity and BC, using the real trajectory solver.
1108    pub fn predict(&self, mv_fps: f64, bc: f64, range_yd: f64) -> Result<f64, Box<dyn Error>> {
1109        self.predict_in_unit(mv_fps, bc, range_yd, self.drop_unit)
1110    }
1111
1112    /// `predict` expressed in an explicit unit, independent of the configured
1113    /// `--drop-unit`. The identifiability diagnostics use this to stay in mil space
1114    /// (MBA-1337 t1): the linear `in` unit weights each Jacobian row by its range,
1115    /// which shifts the column correlation. NOTE this makes the gate DIAGNOSTICS
1116    /// unit-invariant; the MV-only operating point and the least-squares cost are
1117    /// still minimized in the display unit (deliberately out of scope — changing
1118    /// them changes fitted numbers), so extreme near-boundary sets can still differ.
1119    pub fn predict_in_unit(
1120        &self,
1121        mv_fps: f64,
1122        bc: f64,
1123        range_yd: f64,
1124        unit: DropUnit,
1125    ) -> Result<f64, Box<dyn Error>> {
1126        let (drop_m, downrange_m) = solve_trajectory_drop(
1127            mv_fps,
1128            bc,
1129            self.drag_model,
1130            self.mass_gr,
1131            self.diameter_in,
1132            self.zero_yd,
1133            range_yd,
1134            self.sight_in,
1135            self.temp_f,
1136            self.press_inhg,
1137            self.humidity,
1138            self.alt_ft,
1139            self.bc_segments,
1140            &self.env,
1141            true, // interpolate: smooth forward model for the fitter
1142        )?;
1143        Ok(unit.express_drop_m(drop_m, downrange_m))
1144    }
1145
1146    /// Predict several ranges from one zero solve and one trajectory integration.
1147    /// `None` marks a candidate the trajectory did not physically reach (or an
1148    /// invalid non-positive/non-finite candidate); other solver failures remain
1149    /// errors because they invalidate the entire nominal/perturbed model.
1150    ///
1151    /// This is the high-throughput path for experiment design and predictive
1152    /// bands.  It deliberately reproduces [`solve_trajectory_drop`]'s assembly
1153    /// and interpolation, but integrates through the farthest candidate once.
1154    pub(crate) fn predict_many_in_unit(
1155        &self,
1156        mv_fps: f64,
1157        bc: f64,
1158        ranges_yd: &[f64],
1159        unit: DropUnit,
1160    ) -> Result<Vec<Option<f64>>, Box<dyn Error>> {
1161        if ranges_yd.is_empty() {
1162            return Ok(Vec::new());
1163        }
1164        let max_range_yd = ranges_yd
1165            .iter()
1166            .copied()
1167            .filter(|r| r.is_finite() && *r > 0.0)
1168            .fold(0.0_f64, f64::max);
1169        if max_range_yd <= 0.0 {
1170            return Ok(vec![None; ranges_yd.len()]);
1171        }
1172
1173        let velocity_ms = mv_fps * 0.3048;
1174        let mass_kg = self.mass_gr * GRAINS_TO_KG;
1175        let diameter_m = self.diameter_in * 0.0254;
1176        let zero_m = self.zero_yd * 0.9144;
1177        let max_range_m = max_range_yd * 0.9144;
1178        let sight_height_m = self.sight_in * 0.0254;
1179        let altitude_m = self.alt_ft * 0.3048;
1180        let temperature_c = (self.temp_f - 32.0) * 5.0 / 9.0;
1181        let pressure_hpa = self.press_inhg * 33.8639;
1182        let drag_model = match self.drag_model {
1183            DragModelArg::G1 => DragModel::G1,
1184            DragModelArg::G7 => DragModel::G7,
1185        };
1186
1187        let mut inputs = BallisticInputs {
1188            muzzle_velocity: velocity_ms,
1189            bc_value: bc,
1190            bc_type: drag_model,
1191            bullet_mass: mass_kg,
1192            bullet_diameter: diameter_m,
1193            bullet_length: fallback_bullet_length_m(diameter_m, mass_kg),
1194            sight_height: sight_height_m,
1195            target_distance: max_range_m + 100.0,
1196            use_bc_segments: self.bc_segments.is_some(),
1197            bc_segments_data: self.bc_segments.clone(),
1198            use_rk4: true,
1199            muzzle_angle: 0.0,
1200            ..Default::default()
1201        };
1202        // MBA-1392: same opt-in physics as the single-range assembly, so the batched and
1203        // per-range predictors can never describe different trajectories. No-op for the
1204        // default (drop-only) environment.
1205        self.env.apply_to(&mut inputs);
1206        let atmosphere = AtmosphericConditions {
1207            temperature: temperature_c,
1208            pressure: pressure_hpa,
1209            humidity: self.humidity,
1210            altitude: altitude_m,
1211        };
1212        let wind = self.env.wind.clone();
1213        inputs.muzzle_angle = crate::calculate_zero_angle_with_conditions(
1214            inputs.clone(),
1215            zero_m,
1216            sight_height_m,
1217            wind.clone(),
1218            atmosphere.clone(),
1219        )?;
1220
1221        let mut solver = TrajectorySolver::new(inputs, wind, atmosphere);
1222        solver.set_max_range(max_range_m + 100.0);
1223        solver.set_time_step(0.0001);
1224        let result = solver.solve()?;
1225
1226        let predictions = ranges_yd
1227            .iter()
1228            .map(|range_yd| {
1229                if !range_yd.is_finite() || *range_yd <= 0.0 {
1230                    return None;
1231                }
1232                let range_m = *range_yd * 0.9144;
1233                // MBA-1392: one shared sampler (interpolating, exactly as this loop
1234                // always did) rather than a third hand-rolled copy of the same
1235                // interpolation. A range the trajectory never reached is a `None` row
1236                // here, not an error, so the surrounding batch survives it.
1237                let sample = sample_truing_result(&result, range_m, sight_height_m, true).ok()?;
1238                Some(unit.express_drop_m(sample.drop_m, sample.downrange_m))
1239            })
1240            .collect();
1241        Ok(predictions)
1242    }
1243
1244    /// Sum of squared residuals (predicted - observed) over all observations.
1245    pub fn cost(&self, mv: f64, bc: f64, obs: &[TruingObservation]) -> Result<f64, Box<dyn Error>> {
1246        let mut c = 0.0;
1247        for o in obs {
1248            let r = self.predict(mv, bc, o.range_yd)? - o.drop;
1249            c += r * r;
1250        }
1251        Ok(c)
1252    }
1253}
1254
1255/// One finite-difference row of the truing forward model.  The planner and
1256/// uncertainty engine deliberately share this helper with the point fitter so
1257/// that experiment-design claims and posterior diagnostics describe the exact
1258/// same physics and perturbation sizes.
1259#[derive(Debug, Clone, Copy)]
1260pub(crate) struct TruingJacobianRow {
1261    pub predicted_drop: f64,
1262    pub d_drop_d_mv: f64,
1263    pub d_drop_d_bc: f64,
1264}
1265
1266pub(crate) fn truing_jacobian_row(
1267    model: &TruingForwardModel<'_>,
1268    mv_fps: f64,
1269    bc: f64,
1270    range_yd: f64,
1271    unit: DropUnit,
1272) -> Result<TruingJacobianRow, Box<dyn Error>> {
1273    let hmv = (mv_fps * 1e-3).max(0.5);
1274    let hbc = (bc * 1e-3).max(1e-4);
1275    let predicted_drop = model.predict_in_unit(mv_fps, bc, range_yd, unit)?;
1276    let d_drop_d_mv = (model.predict_in_unit(mv_fps + hmv, bc, range_yd, unit)?
1277        - model.predict_in_unit(mv_fps - hmv, bc, range_yd, unit)?)
1278        / (2.0 * hmv);
1279    let d_drop_d_bc = (model.predict_in_unit(mv_fps, bc + hbc, range_yd, unit)?
1280        - model.predict_in_unit(mv_fps, bc - hbc, range_yd, unit)?)
1281        / (2.0 * hbc);
1282    Ok(TruingJacobianRow {
1283        predicted_drop,
1284        d_drop_d_mv,
1285        d_drop_d_bc,
1286    })
1287}
1288
1289/// Batched form of [`truing_jacobian_row`].  Five integrations (nominal,
1290/// MV+/-, BC+/-) cover every requested range.  A row is `None` if any of those
1291/// trajectories cannot reach that candidate, preventing one-sided or silently
1292/// fabricated sensitivity estimates.
1293pub(crate) fn truing_jacobian_rows(
1294    model: &TruingForwardModel<'_>,
1295    mv_fps: f64,
1296    bc: f64,
1297    ranges_yd: &[f64],
1298    unit: DropUnit,
1299) -> Result<Vec<Option<TruingJacobianRow>>, Box<dyn Error>> {
1300    let hmv = (mv_fps * 1e-3).max(0.5);
1301    let hbc = (bc * 1e-3).max(1e-4);
1302    let nominal = model.predict_many_in_unit(mv_fps, bc, ranges_yd, unit)?;
1303    let mv_plus = model.predict_many_in_unit(mv_fps + hmv, bc, ranges_yd, unit)?;
1304    let mv_minus = model.predict_many_in_unit(mv_fps - hmv, bc, ranges_yd, unit)?;
1305    let bc_plus = model.predict_many_in_unit(mv_fps, bc + hbc, ranges_yd, unit)?;
1306    let bc_minus = model.predict_many_in_unit(mv_fps, bc - hbc, ranges_yd, unit)?;
1307
1308    Ok((0..ranges_yd.len())
1309        .map(|i| {
1310            Some(TruingJacobianRow {
1311                predicted_drop: nominal[i]?,
1312                d_drop_d_mv: (mv_plus[i]? - mv_minus[i]?) / (2.0 * hmv),
1313                d_drop_d_bc: (bc_plus[i]? - bc_minus[i]?) / (2.0 * hbc),
1314            })
1315        })
1316        .collect())
1317}
1318
1319/// One-parameter (muzzle velocity) least-squares fit with BC held fixed. Damped
1320/// Gauss-Newton with a central finite-difference derivative; robust because drop
1321/// is monotonic in muzzle velocity. Returns `(mv_fps, iterations, converged)`.
1322pub(crate) fn fit_truing_mv_only(
1323    model: &TruingForwardModel<'_>,
1324    obs: &[TruingObservation],
1325    bc: f64,
1326    mv_init: f64,
1327) -> Result<(f64, usize, bool), Box<dyn Error>> {
1328    let mut mv = mv_init.clamp(TRUING_MV_MIN_FPS, TRUING_MV_MAX_FPS);
1329    let mut converged = false;
1330    let mut iters = 0;
1331    for i in 0..TRUING_MAX_ITERS {
1332        iters = i + 1;
1333        let h = (mv * 1e-3).max(0.5);
1334        let mut num = 0.0;
1335        let mut den = 0.0;
1336        for o in obs {
1337            let r = model.predict(mv, bc, o.range_yd)? - o.drop;
1338            let dp = model.predict(mv + h, bc, o.range_yd)?;
1339            let dm = model.predict(mv - h, bc, o.range_yd)?;
1340            let j = (dp - dm) / (2.0 * h);
1341            num += j * r;
1342            den += j * j;
1343        }
1344        if den < 1e-12 {
1345            break;
1346        }
1347        // Gauss-Newton step, limited to keep the solver in a sane regime.
1348        let step = (-num / den).clamp(-300.0, 300.0);
1349        let new_mv = (mv + step).clamp(TRUING_MV_MIN_FPS, TRUING_MV_MAX_FPS);
1350        if (new_mv - mv).abs() < 0.05 {
1351            mv = new_mv;
1352            converged = true;
1353            break;
1354        }
1355        mv = new_mv;
1356    }
1357    Ok((mv, iters, converged))
1358}
1359
1360/// Identifiability diagnostics for BC at the operating point `(mv, bc)`.
1361///
1362/// Returns `(sensitivity_ratio, condition_number)`:
1363///
1364/// * `sensitivity_ratio = ||bc*d(drop)/d(bc)|| / ||mv*d(drop)/d(mv)||` — the
1365///   relative influence of a fractional BC change vs a fractional MV change on
1366///   the predicted drops. Small => BC barely moves the drops (weak-signal mode).
1367///
1368/// * `condition_number = (1+|c|)/(1-|c|)` where `c` is the correlation between
1369///   the raw MV and BC Jacobian columns. Large => the two columns point the same
1370///   way, so a BC change can be undone by an MV change and the pair is not
1371///   separable (collinearity mode). This is magnitude-independent, unlike the
1372///   scaled-normal-matrix condition, so it actually tracks observation spread.
1373pub(crate) fn truing_identifiability(
1374    model: &TruingForwardModel<'_>,
1375    obs: &[TruingObservation],
1376    mv: f64,
1377    bc: f64,
1378) -> Result<(f64, f64), Box<dyn Error>> {
1379    let (mut n_mv, mut n_bc, mut cross) = (0.0, 0.0, 0.0);
1380    // Differentiate in mil space regardless of --drop-unit (MBA-1337 t1) so these
1381    // gate diagnostics do not shift with the display unit. (The operating point mv
1382    // comes from a display-unit fit, so full invariance is not guaranteed — see
1383    // predict_in_unit's note.)
1384    let unit = DropUnit::Mil;
1385    for o in obs {
1386        let row = truing_jacobian_row(model, mv, bc, o.range_yd, unit)?;
1387        n_mv += row.d_drop_d_mv * row.d_drop_d_mv;
1388        n_bc += row.d_drop_d_bc * row.d_drop_d_bc;
1389        cross += row.d_drop_d_mv * row.d_drop_d_bc;
1390    }
1391    let norm_mv = n_mv.sqrt();
1392    let norm_bc = n_bc.sqrt();
1393    let sensitivity_ratio = if mv * norm_mv > 0.0 {
1394        (bc * norm_bc) / (mv * norm_mv)
1395    } else {
1396        0.0
1397    };
1398    // Correlation between the raw Jacobian columns.
1399    let condition_number = if norm_mv > 0.0 && norm_bc > 0.0 {
1400        let c = (cross / (norm_mv * norm_bc)).clamp(-1.0, 1.0).abs();
1401        if (1.0 - c) > 1e-15 {
1402            (1.0 + c) / (1.0 - c)
1403        } else {
1404            f64::INFINITY
1405        }
1406    } else {
1407        // One column is numerically zero: the missing parameter is unconstrained.
1408        f64::INFINITY
1409    };
1410    Ok((sensitivity_ratio, condition_number))
1411}
1412
1413/// Two-parameter (muzzle velocity, BC) joint fit via Levenberg-Marquardt
1414/// (damped Gauss-Newton) with central finite-difference Jacobian. Only the
1415/// diagonal of the normal matrix is damped (classic Marquardt scaling). Returns
1416/// `(mv_fps, bc, iterations, converged)`.
1417pub(crate) fn fit_truing_joint(
1418    model: &TruingForwardModel<'_>,
1419    obs: &[TruingObservation],
1420    mv_init: f64,
1421    bc_init: f64,
1422) -> Result<(f64, f64, usize, bool), Box<dyn Error>> {
1423    let mut mv = mv_init.clamp(TRUING_MV_MIN_FPS, TRUING_MV_MAX_FPS);
1424    let mut bc = bc_init.clamp(TRUING_BC_MIN, TRUING_BC_MAX);
1425    let mut lambda = 1e-3;
1426    let mut cur_cost = model.cost(mv, bc, obs)?;
1427    let mut converged = false;
1428    let mut iters = 0;
1429
1430    for it in 0..TRUING_MAX_ITERS {
1431        iters = it + 1;
1432        // Build J^T J (a00,a01,a11) and J^T r (g0,g1).
1433        let (mut a00, mut a01, mut a11) = (0.0, 0.0, 0.0);
1434        let (mut g0, mut g1) = (0.0, 0.0);
1435        for o in obs {
1436            let row = truing_jacobian_row(model, mv, bc, o.range_yd, model.drop_unit)?;
1437            let r = row.predicted_drop - o.drop;
1438            let jmv = row.d_drop_d_mv;
1439            let jbc = row.d_drop_d_bc;
1440            a00 += jmv * jmv;
1441            a01 += jmv * jbc;
1442            a11 += jbc * jbc;
1443            g0 += jmv * r;
1444            g1 += jbc * r;
1445        }
1446
1447        // Inner loop: grow lambda until a step reduces the cost.
1448        let mut accepted = false;
1449        for _ in 0..30 {
1450            let m00 = a00 + lambda * a00.max(1e-12);
1451            let m11 = a11 + lambda * a11.max(1e-12);
1452            let det = m00 * m11 - a01 * a01;
1453            if det.abs() < 1e-20 {
1454                lambda *= 10.0;
1455                continue;
1456            }
1457            // delta = -(JtJ + lambda*diag)^-1 * Jtr
1458            let dmv = -(m11 * g0 - a01 * g1) / det;
1459            let dbc = -(-a01 * g0 + m00 * g1) / det;
1460            let nmv = (mv + dmv).clamp(TRUING_MV_MIN_FPS, TRUING_MV_MAX_FPS);
1461            let nbc = (bc + dbc).clamp(TRUING_BC_MIN, TRUING_BC_MAX);
1462            let nc = model.cost(nmv, nbc, obs)?;
1463            if nc < cur_cost {
1464                let rel_change =
1465                    (nmv - mv).abs() / mv.max(1.0) + (nbc - bc).abs() / bc.max(1e-3);
1466                mv = nmv;
1467                bc = nbc;
1468                cur_cost = nc;
1469                lambda = (lambda * 0.5).max(1e-9);
1470                accepted = true;
1471                if rel_change < 1e-6 {
1472                    converged = true;
1473                }
1474                break;
1475            }
1476            lambda *= 4.0;
1477            if lambda > 1e12 {
1478                break;
1479            }
1480        }
1481        if !accepted {
1482            // No downhill step exists within damping range: we are at (or
1483            // numerically indistinguishable from) a local optimum.
1484            converged = true;
1485            break;
1486        }
1487        if converged {
1488            break;
1489        }
1490    }
1491    Ok((mv, bc, iters, converged))
1492}
1493
1494/// Final report produced by a multi-observation truing fit.
1495#[derive(Debug, Clone)]
1496pub struct MultiTruingReport {
1497    pub fitted_mv_fps: f64,
1498    pub fitted_bc: f64,
1499    pub bc_input: f64,
1500    pub bc_fitted: bool,
1501    pub observations: Vec<TruingObservation>,
1502    pub predicted: Vec<f64>,
1503    pub residuals: Vec<f64>,
1504    pub rms: f64,
1505    pub iterations: usize,
1506    pub converged: bool,
1507    pub sensitivity_ratio: f64,
1508    pub condition_number: f64,
1509    pub quality: String,
1510    pub reason: String,
1511    /// Downward Mach-1.2 crossing distance (meters) for the finally fitted
1512    /// (`fitted_mv_fps`, `fitted_bc`) load, re-solved out to a fixed 3000 yd
1513    /// envelope independent of the observation set (the window describes the
1514    /// load, not wherever the caller happened to shoot). Feeds
1515    /// [`mv_calibration_window`] for the MV-calibration window report line
1516    /// (MBA-1405 Task 2). `None` when the trajectory never crosses within that
1517    /// envelope.
1518    pub mach_1_2_distance_m: Option<f64>,
1519    /// The downrange distance (meters) the fixed-envelope re-solve actually
1520    /// reached — names the range checked in the "no MV window" report note
1521    /// when `mach_1_2_distance_m` is `None` (MBA-1405 Task 2).
1522    pub window_solved_range_m: f64,
1523    /// Mach number at the first trajectory point of the fixed-envelope re-solve
1524    /// (`points[0].velocity_magnitude / station_speed_of_sound_mps`, the same
1525    /// convention `apply_dsf`/`truing_dsf` use for per-point Mach). Distinguishes,
1526    /// when `mach_1_2_distance_m` is `None`, a load that is still supersonic at
1527    /// the end of the window envelope (muzzle Mach >= 1.2) from one that launches
1528    /// below Mach 1.2 and never crosses downward at all (muzzle Mach < 1.2) — the
1529    /// two have opposite report text (MBA-1405 Task 2 fix pass).
1530    pub muzzle_mach: f64,
1531}
1532
1533/// Orchestrate the multi-observation joint MV+BC calibration and build the
1534/// final [`MultiTruingReport`] (MBA-1343: the compute half only — rendering
1535/// stays with the caller).
1536///
1537/// `observations` is the already-parsed observation set, primary
1538/// (`--range`/`--measured-drop`) first, then each `--observed` impact in order
1539/// (use [`parse_truing_observation`] to build them from `RANGE:DROP` tokens);
1540/// every drop is already in `drop_unit` and every range in internal yards. The
1541/// set is validated with [`validate_truing_observations`] before any fitting.
1542#[allow(
1543    clippy::too_many_arguments,
1544    reason = "flat arguments mirror the stable true-velocity CLI command shape"
1545)]
1546pub fn run_multi_observation_truing_core(
1547    observations: &[TruingObservation],
1548    drop_unit: DropUnit,
1549    bc_input: f64,
1550    drag_model: DragModelArg,
1551    mass_gr: f64,
1552    diameter_in: f64,
1553    zero_yd: f64,
1554    sight_in: f64,
1555    temp_f: f64,
1556    press_inhg: f64,
1557    humidity: f64,
1558    alt_ft: f64,
1559    bc_segments: &Option<Vec<BCSegmentData>>,
1560) -> Result<MultiTruingReport, Box<dyn Error>> {
1561    // Validate: finite, positive range, non-zero drop, no duplicate ranges.
1562    validate_truing_observations(observations)?;
1563    let observations: Vec<TruingObservation> = observations.to_vec();
1564
1565    let model = TruingForwardModel {
1566        drag_model,
1567        mass_gr,
1568        diameter_in,
1569        zero_yd,
1570        sight_in,
1571        temp_f,
1572        press_inhg,
1573        humidity,
1574        alt_ft,
1575        bc_segments,
1576        drop_unit,
1577        // MBA-1392: drop-only truing keeps the historical calm / effects-off model.
1578        env: TruingEnvironment::default(),
1579    };
1580
1581    // Step 1: MV-only fit holding BC at the supplied value. This is always
1582    // well-posed and gives a good operating point for the identifiability check
1583    // and (if BC is identifiable) the joint fit.
1584    let mv_init = (TRUING_MV_MIN_FPS + TRUING_MV_MAX_FPS) / 2.0;
1585    let (mv0, mv_iters, mv_conv) = fit_truing_mv_only(&model, &observations, bc_input, mv_init)?;
1586    let rms_mv_only = rms_at(&model, &observations, mv0, bc_input)?;
1587
1588    // Step 2: is BC identifiable from this observation set?
1589    let (sensitivity_ratio, condition_number) =
1590        truing_identifiability(&model, &observations, mv0, bc_input)?;
1591    let bc_identifiable = sensitivity_ratio >= TRUING_MIN_BC_SENSITIVITY_RATIO
1592        && condition_number <= TRUING_MAX_CONDITION_NUMBER
1593        && condition_number.is_finite();
1594
1595    // Step 3: joint fit when identifiable, with a guard against a worse or
1596    // out-of-bounds result (never report a garbage joint fit).
1597    let mut fitted_mv = mv0;
1598    let mut fitted_bc = bc_input;
1599    let mut bc_fitted = false;
1600    let mut iterations = mv_iters;
1601    // Start from the MV-only fitter's own flag (MBA-1337 t2): both MV-only outcomes
1602    // (gate-refused joint, or joint rejected as worse) report THIS fit's convergence.
1603    // The accepted-joint branch overwrites it with the joint fitter's flag.
1604    let mut converged = mv_conv;
1605    let mut reason = String::new();
1606
1607    if bc_identifiable {
1608        let (mv_j, bc_j, iters_j, conv_j) =
1609            fit_truing_joint(&model, &observations, mv0, bc_input)?;
1610        let rms_joint = rms_at(&model, &observations, mv_j, bc_j)?;
1611        let bc_at_bound = bc_j <= TRUING_BC_MIN * 1.001 || bc_j >= TRUING_BC_MAX * 0.999;
1612        if !bc_at_bound && rms_joint <= rms_mv_only + 1e-9 {
1613            fitted_mv = mv_j;
1614            fitted_bc = bc_j;
1615            bc_fitted = true;
1616            iterations = iters_j;
1617            converged = conv_j;
1618        } else {
1619            // Joint fit did not help (or ran to a bound): keep the honest
1620            // MV-only answer rather than a false-precision BC.
1621            reason = if bc_at_bound {
1622                format!(
1623                    "joint fit drove BC to a bound ({bc_j:.3}); BC held at input {bc_input:.3}"
1624                )
1625            } else {
1626                format!(
1627                    "joint fit did not improve on the MV-only solution; BC held at input {bc_input:.3}"
1628                )
1629            };
1630        }
1631    } else {
1632        reason = if !condition_number.is_finite() || condition_number > TRUING_MAX_CONDITION_NUMBER
1633        {
1634            format!(
1635                "observation ranges are too similar to separate MV from BC (condition {condition_number:.3e} > {TRUING_MAX_CONDITION_NUMBER:.0e}); BC held at input {bc_input:.3}"
1636            )
1637        } else {
1638            format!(
1639                "observations do not constrain BC (BC sensitivity ratio {sensitivity_ratio:.4} < {TRUING_MIN_BC_SENSITIVITY_RATIO:.2} threshold); BC held at input {bc_input:.3}. Add a longer-range / transonic observation to fit BC."
1640            )
1641        };
1642    }
1643
1644    // Final residuals at the reported parameters. Alongside the display-unit RMS,
1645    // accumulate a mil-equivalent RMS: the quality bands were calibrated in mil
1646    // (~0.03 mil observation noise), so banding must not shift with --drop-unit
1647    // (MBA-1337 t1). Reported numbers stay in the user's unit.
1648    let mut predicted = Vec::with_capacity(observations.len());
1649    let mut residuals = Vec::with_capacity(observations.len());
1650    let mut sse = 0.0;
1651    let mut sse_mil = 0.0;
1652    for o in &observations {
1653        let p = model.predict(fitted_mv, fitted_bc, o.range_yd)?;
1654        let r = p - o.drop;
1655        let r_mil = match drop_unit {
1656            DropUnit::Mil => r,
1657            // moa -> mil: divide by (180/pi)*60/1000 moa-per-mil.
1658            DropUnit::Moa => r / ((180.0 / std::f64::consts::PI) * 60.0 / 1000.0),
1659            // inches -> meters -> small-angle mil at this observation's range.
1660            DropUnit::In => r * 0.0254 / (o.range_yd * 0.9144) * 1000.0,
1661        };
1662        predicted.push(p);
1663        residuals.push(r);
1664        sse += r * r;
1665        sse_mil += r_mil * r_mil;
1666    }
1667    let rms = (sse / observations.len() as f64).sqrt();
1668    let rms_mil = (sse_mil / observations.len() as f64).sqrt();
1669
1670    let quality = truing_quality_line(
1671        bc_fitted,
1672        rms,
1673        rms_mil,
1674        drop_unit,
1675        condition_number,
1676        converged,
1677        observations.len(),
1678    );
1679
1680    // MBA-1405 Task 2: one extra solve at the finally fitted (mv, bc), out to a
1681    // fixed generous envelope independent of the observation set, purely to read
1682    // off the downward Mach-1.2 crossing for the MV-calibration window report line.
1683    let (mach_1_2_distance_m, window_solved_range_m, muzzle_mach) =
1684        truing_mv_window_mach_1_2_crossing(&model, fitted_mv, fitted_bc)?;
1685
1686    let report = MultiTruingReport {
1687        fitted_mv_fps: fitted_mv,
1688        fitted_bc,
1689        bc_input,
1690        bc_fitted,
1691        observations,
1692        predicted,
1693        residuals,
1694        rms,
1695        iterations,
1696        converged,
1697        sensitivity_ratio,
1698        condition_number,
1699        quality,
1700        reason,
1701        mach_1_2_distance_m,
1702        window_solved_range_m,
1703        muzzle_mach,
1704    };
1705
1706    Ok(report)
1707}
1708
1709/// Downrange envelope (yards) for the extra solve [`truing_mv_window_mach_1_2_crossing`]
1710/// runs to locate the downward Mach-1.2 crossing for the MV-calibration window report
1711/// line (MBA-1405 Task 2): generous enough to cover essentially every practical
1712/// small-arms transonic transition, independent of wherever the caller's own
1713/// observations happen to sit — the window describes the load, not the observation set.
1714const MV_WINDOW_SOLVE_MAX_RANGE_YD: f64 = 3000.0;
1715
1716/// Re-solve the finally fitted `(mv, bc)` load out to [`MV_WINDOW_SOLVE_MAX_RANGE_YD`]
1717/// to read off the downward Mach-1.2 crossing distance (meters) for the MV-calibration
1718/// window (MBA-1405 Task 2). Returns `(mach_1_2_distance_m, solved_max_range_m,
1719/// muzzle_mach)`; the second element lets the report's "no MV window" note name the
1720/// range actually checked rather than the nominal envelope (relevant if the solve
1721/// terminates early, e.g. ground impact). The third element is the Mach number at the
1722/// first trajectory point (`points[0].velocity_magnitude / station_speed_of_sound_mps`,
1723/// the same convention `truing_dsf::apply_dsf` uses) — it lets the caller distinguish,
1724/// when `mach_1_2_distance_m` is `None`, "still supersonic through the whole envelope"
1725/// (muzzle Mach >= 1.2) from "never reaches Mach 1.2 at all" (muzzle Mach < 1.2), which
1726/// need opposite report text (MBA-1405 Task 2 fix pass).
1727fn truing_mv_window_mach_1_2_crossing(
1728    model: &TruingForwardModel<'_>,
1729    mv_fps: f64,
1730    bc: f64,
1731) -> Result<(Option<f64>, f64, f64), Box<dyn Error>> {
1732    let result = solve_truing_trajectory(
1733        mv_fps,
1734        bc,
1735        model.drag_model,
1736        model.mass_gr,
1737        model.diameter_in,
1738        model.zero_yd,
1739        MV_WINDOW_SOLVE_MAX_RANGE_YD,
1740        model.sight_in,
1741        model.temp_f,
1742        model.press_inhg,
1743        model.humidity,
1744        model.alt_ft,
1745        model.bc_segments,
1746        &model.env,
1747    )?;
1748    let muzzle_mach = result.points.first().map_or(0.0, |p| {
1749        if result.station_speed_of_sound_mps > 0.0 {
1750            p.velocity_magnitude / result.station_speed_of_sound_mps
1751        } else {
1752            0.0
1753        }
1754    });
1755    Ok((result.mach_1_2_distance_m, result.max_range, muzzle_mach))
1756}
1757
1758/// RMS of residuals at a candidate `(mv, bc)`.
1759pub(crate) fn rms_at(
1760    model: &TruingForwardModel<'_>,
1761    obs: &[TruingObservation],
1762    mv: f64,
1763    bc: f64,
1764) -> Result<f64, Box<dyn Error>> {
1765    let mut sse = 0.0;
1766    for o in obs {
1767        let r = model.predict(mv, bc, o.range_yd)? - o.drop;
1768        sse += r * r;
1769    }
1770    Ok((sse / obs.len() as f64).sqrt())
1771}
1772
1773/// Plain-language quality assessment for the fit. `rms` is in the user's drop unit
1774/// (displayed); `rms_mil` is the mil-equivalent the bands were calibrated against
1775/// (~0.03 mil observation noise), so the quality word is unit-invariant (MBA-1337 t1).
1776pub(crate) fn truing_quality_line(
1777    bc_fitted: bool,
1778    rms: f64,
1779    rms_mil: f64,
1780    drop_unit: DropUnit,
1781    condition_number: f64,
1782    converged: bool,
1783    n_obs: usize,
1784) -> String {
1785    let unit = drop_unit.label();
1786    let n_params = if bc_fitted { 2 } else { 1 };
1787    // Exactly-determined fit (MBA-1337 t3): zero degrees of freedom drive the
1788    // residuals to ~0 by construction — an "excellent" RMS validates nothing.
1789    if n_obs == n_params {
1790        return format!(
1791            "{} fit is exactly determined ({n_obs} observations, {n_params} fitted \
1792             parameters): residuals are zero by construction and do not validate the \
1793             fit; add an observation to assess quality",
1794            if bc_fitted { "Joint MV+BC" } else { "MV-only" }
1795        );
1796    }
1797    let quality = if rms_mil < 0.05 {
1798        "excellent"
1799    } else if rms_mil < 0.15 {
1800        "good"
1801    } else if rms_mil < 0.4 {
1802        "fair"
1803    } else {
1804        "poor (observations may be inconsistent)"
1805    };
1806    let nonconv = if converged { "" } else { " (did not fully converge)" };
1807    if bc_fitted {
1808        let cond = if condition_number.is_finite() {
1809            format!("{condition_number:.0}")
1810        } else {
1811            "inf".to_string()
1812        };
1813        format!(
1814            "Joint MV+BC fit, {quality}: RMS residual {rms:.3} {unit}, conditioning {cond}{nonconv}"
1815        )
1816    } else {
1817        format!("MV-only fit, {quality}: RMS residual {rms:.3} {unit} (BC held fixed){nonconv}")
1818    }
1819}
1820
1821/// Fraction of the downward Mach 1.2 crossing distance that bounds the near edge of the MV
1822/// (muzzle-velocity) calibration window (MBA-1405): observations closer than this fraction of
1823/// the crossing are considered too far from the transonic region to usefully calibrate MV.
1824const MV_CALIBRATION_WINDOW_START_FRACTION: f64 = 0.90;
1825
1826/// Fraction of the downward Mach 0.9 crossing distance at which the DSF (drag-scale-factor)
1827/// window is considered to start (MBA-1405): DSF observations nearer the muzzle than this are
1828/// still comfortably supersonic/transonic and not representative of the DSF-correction regime.
1829const DSF_WINDOW_START_FRACTION: f64 = 0.90;
1830
1831/// The MV (muzzle-velocity) calibration validity window: `(start_m, end_m)`, where `end_m` is
1832/// the downward Mach 1.2 crossing distance and `start_m` is 90% of it. `None` when the
1833/// trajectory never crosses Mach 1.2 (nothing to bound the window with) — mirrors
1834/// `TrajectoryResult::mach_1_2_distance_m`'s own `None` case, MBA-1405. `pub` (not
1835/// `pub(crate)`): consumed by the native CLI (`main.rs`) and the WASM terminal
1836/// (`wasm.rs`), both separate crates from this lib (MBA-1405 Task 2).
1837pub fn mv_calibration_window(mach_1_2_distance_m: Option<f64>) -> Option<(f64, f64)> {
1838    let d = mach_1_2_distance_m?;
1839    Some((MV_CALIBRATION_WINDOW_START_FRACTION * d, d))
1840}
1841
1842/// The downrange distance (m) at which the DSF (drag-scale-factor) window starts: 90% of the
1843/// downward Mach 0.9 crossing distance. `None` when the trajectory never crosses Mach 0.9 —
1844/// mirrors `TrajectoryResult::mach_0_9_distance_m`'s own `None` case, MBA-1405. `pub`: consumed
1845/// by the native CLI's `dsf` verb (MBA-1405 Task 2).
1846pub fn dsf_window_start(mach_0_9_distance_m: Option<f64>) -> Option<f64> {
1847    Some(DSF_WINDOW_START_FRACTION * mach_0_9_distance_m?)
1848}
1849
1850/// MBA-1358: express a multi-observation truing report's dial-unit values back in SCOPE
1851/// units for display. The stored CF is the published tall-target ratio `actual / dialed`
1852/// (0.95 = under-tracks 5%), so a dialed reading of `D` scope units delivers `D * CF`
1853/// true units: the fit consumes observations MULTIPLIED by the CF (true angular units),
1854/// and this converts the report's observed/predicted/residual drops and the RMS back to
1855/// scope units by DIVIDING by `dial_cf` — the rendered tables echo what the shooter
1856/// actually dials. Fit results (MV/BC), iteration diagnostics, and the meter-denominated
1857/// window fields are deliberately untouched (they are not dial-unit outputs).
1858/// `dial_cf == 1.0` (no CF, or `--drop-unit in` — linear tape measurements never scale)
1859/// divides by exactly 1.0, leaving every downstream byte identical. `pub` (not
1860/// `pub(crate)`): shared by the native CLI (`main.rs`) and the WASM terminal (`wasm.rs`)
1861/// so the two surfaces cannot drift, and host-testable (wasm.rs itself is wasm32-gated).
1862pub fn scale_report_dial_values(report: &MultiTruingReport, dial_cf: f64) -> MultiTruingReport {
1863    let mut scaled = report.clone();
1864    for observation in &mut scaled.observations {
1865        observation.drop /= dial_cf;
1866    }
1867    for predicted in &mut scaled.predicted {
1868        *predicted /= dial_cf;
1869    }
1870    for residual in &mut scaled.residuals {
1871        *residual /= dial_cf;
1872    }
1873    scaled.rms /= dial_cf;
1874    scaled
1875}
1876
1877#[cfg(test)]
1878mod window_helper_tests {
1879    use super::*;
1880
1881    // MBA-1358: the dial-unit report rescale shared by the native and WASM truing
1882    // renderers, host-tested here because wasm.rs is wasm32-gated.
1883    #[test]
1884    fn scale_report_dial_values_scales_drops_and_rms_only() {
1885        let report = MultiTruingReport {
1886            fitted_mv_fps: 2700.0,
1887            fitted_bc: 0.47,
1888            bc_input: 0.475,
1889            bc_fitted: true,
1890            observations: vec![TruingObservation {
1891                range_yd: 600.0,
1892                drop: 10.0,
1893            }],
1894            predicted: vec![9.8],
1895            residuals: vec![0.2],
1896            rms: 0.2,
1897            iterations: 7,
1898            converged: true,
1899            sensitivity_ratio: 1.4,
1900            condition_number: 3.0,
1901            quality: "good".to_string(),
1902            reason: "fit".to_string(),
1903            mach_1_2_distance_m: Some(600.0),
1904            window_solved_range_m: 2743.2,
1905            muzzle_mach: 2.4,
1906        };
1907        // Under-tracking scope (CF < 1): scope-unit dial values are LARGER than true.
1908        let scaled = scale_report_dial_values(&report, 0.95);
1909        assert_eq!(scaled.observations[0].drop.to_bits(), (10.0_f64 / 0.95).to_bits());
1910        assert_eq!(scaled.predicted[0].to_bits(), (9.8_f64 / 0.95).to_bits());
1911        assert_eq!(scaled.residuals[0].to_bits(), (0.2_f64 / 0.95).to_bits());
1912        assert_eq!(scaled.rms.to_bits(), (0.2_f64 / 0.95).to_bits());
1913        // fit results and the meter-denominated window fields are untouched
1914        assert_eq!(scaled.fitted_mv_fps, report.fitted_mv_fps);
1915        assert_eq!(scaled.fitted_bc, report.fitted_bc);
1916        assert_eq!(scaled.mach_1_2_distance_m, report.mach_1_2_distance_m);
1917        assert_eq!(scaled.window_solved_range_m, report.window_solved_range_m);
1918        // and ×1.0 is a bit-exact no-op
1919        let unscaled = scale_report_dial_values(&report, 1.0);
1920        assert_eq!(unscaled.observations[0].drop.to_bits(), report.observations[0].drop.to_bits());
1921        assert_eq!(unscaled.rms.to_bits(), report.rms.to_bits());
1922    }
1923
1924    #[test]
1925    fn mv_calibration_window_is_90_to_100_percent_of_the_1_2_crossing() {
1926        assert_eq!(mv_calibration_window(Some(1000.0)), Some((900.0, 1000.0)));
1927        assert_eq!(mv_calibration_window(Some(671.7257336844475)), Some((0.9 * 671.7257336844475, 671.7257336844475)));
1928    }
1929
1930    #[test]
1931    fn mv_calibration_window_passes_through_none() {
1932        assert_eq!(mv_calibration_window(None), None);
1933    }
1934
1935    #[test]
1936    fn dsf_window_start_is_90_percent_of_the_0_9_crossing() {
1937        assert_eq!(dsf_window_start(Some(1000.0)), Some(900.0));
1938        assert_eq!(dsf_window_start(Some(806.5709746782849)), Some(0.9 * 806.5709746782849));
1939    }
1940
1941    #[test]
1942    fn dsf_window_start_passes_through_none() {
1943        assert_eq!(dsf_window_start(None), None);
1944    }
1945
1946    #[test]
1947    fn mv_calibration_window_exact_arithmetic_at_a_representative_distance() {
1948        // Exact f64 arithmetic check (not just structural equality): 0.90 * 500.0 = 450.0
1949        // has an exact binary representation, so this pins the literal formula, not just
1950        // its shape.
1951        let (start, end) = mv_calibration_window(Some(500.0)).expect("Some");
1952        assert_eq!(start.to_bits(), 450.0_f64.to_bits());
1953        assert_eq!(end.to_bits(), 500.0_f64.to_bits());
1954    }
1955
1956    #[test]
1957    fn dsf_window_start_exact_arithmetic_at_a_representative_distance() {
1958        let start = dsf_window_start(Some(500.0)).expect("Some");
1959        assert_eq!(start.to_bits(), 450.0_f64.to_bits());
1960    }
1961}
1962
1963/// MBA-1377: `correct_chrono_velocity_fps` (screen-distance chronograph correction).
1964#[cfg(test)]
1965mod chrono_correction_tests {
1966    use super::*;
1967
1968    /// Hand-computed pin, matching a real chronograph shot: 168 gr, 0.308", BC 0.475 G7, a
1969    /// reading taken 15 ft downrange, dry standard atmosphere (59 F / 29.92124356615747 inHg
1970    /// == 1013.25 hPa exactly / 0% humidity / sea level).
1971    ///
1972    /// Arithmetic (all from published/documented physical constants, not this function).
1973    /// Speed of sound (dry air, ICAO): `a = sqrt(gamma * R_air * T_K)`, gamma = 1.4, R_air =
1974    /// 287.0531 J/(kg K) (see `atmosphere.rs`), T_K = 288.15 (59 F == 15 C exactly), giving
1975    /// `a = sqrt(1.4 * 287.0531 * 288.15) = 340.294124 m/s = 1116.450575 fps`.
1976    ///
1977    /// The measured (screen) velocity is chosen so its Mach number lands exactly on a G7
1978    /// table grid point -- Mach 2.40, Cd = 0.2752 (data/g7.csv row "2.40,0.2752") -- which
1979    /// sidesteps interpolation uncertainty entirely (cubic Hermite interpolation is exact at
1980    /// its own knots): `v_screen = 2.40 * 1116.450575 = 2679.481380 fps`.
1981    ///
1982    /// Air density ratio vs the 1.225 kg/m^3 CD_TO_RETARD reference, via the crate's own
1983    /// published CIPM-2007 formula (`calculate_air_density_cimp`, dry air, sea level, 1013.25
1984    /// hPa, 15 C): `rho = 1.225521 kg/m^3`, `ratio = rho / 1.225 = 1.00042559`.
1985    ///
1986    /// Deceleration (McCoy retardation form, see the `constants::CD_TO_RETARD` doc comment):
1987    /// `a_ft_s2 = Cd * v_fps^2 * (rho/1.225) * CD_TO_RETARD / BC`, CD_TO_RETARD = 2.08551e-4.
1988    /// Over a flight this short, Cd and the density ratio are effectively constant, so
1989    /// `dv/dx = a/v = -(CD_TO_RETARD * Cd * ratio / BC) * v` (linear in v), giving the
1990    /// classic short-range exponential-decay solution `v(x) = v0 * exp(-C*x)`, x in FEET:
1991    /// `C = CD_TO_RETARD * Cd * ratio / BC = 2.08551e-4 * 0.2752 * 1.00042559 / 0.475
1992    /// = 1.2087929e-4` (1/ft), so `v0 = v_screen * exp(C * 15)
1993    /// = 2679.481380 * exp(1.2087929e-4 * 15) = 2684.344194 fps`.
1994    ///
1995    /// The real solver differs only by the (second-order, and here tiny) fact that its Cd is
1996    /// evaluated continuously at the LOCAL velocity along the RK45 path rather than held fixed
1997    /// at v_screen's value; the measured residual is ~0.0024 fps, so 0.05 fps is a safe,
1998    /// still-tight pin tolerance (0.001% of muzzle velocity).
1999    #[test]
2000    fn hand_computed_pin_168gr_308_bc475_g7_15ft() {
2001        let measured_screen_velocity_fps = 2679.481379882625;
2002        let correction = correct_chrono_velocity_fps(
2003            measured_screen_velocity_fps,
2004            15.0 * 0.3048, // 15 ft screen distance, in meters
2005            0.475,         // BC
2006            DragModelArg::G7,
2007            168.0,               // mass, grains
2008            0.308,               // diameter, inches
2009            59.0,                // temperature, F (== 15 C exactly)
2010            1013.25 / 33.8639,   // pressure, inHg (== 1013.25 hPa exactly)
2011            0.0,                 // humidity, % (dry air keeps the arithmetic exact)
2012            0.0,                 // altitude, ft
2013            &None,
2014        )
2015        .expect("hand-computed case must converge");
2016
2017        let expected_v0 = 2684.344194108996;
2018        assert!(
2019            (correction.muzzle_velocity_fps - expected_v0).abs() < 0.05,
2020            "got {}, expected {} +/- 0.05",
2021            correction.muzzle_velocity_fps,
2022            expected_v0
2023        );
2024        // The corrected muzzle velocity must exceed the raw downrange reading -- drag only
2025        // decelerates, so a chronograph reading downrange always understates true MV.
2026        assert!(correction.muzzle_velocity_fps > measured_screen_velocity_fps);
2027        // McCoy/JBM: this band converges in a handful of secant iterations, not dozens.
2028        assert!(
2029            correction.iterations <= 6,
2030            "expected fast convergence, took {} iterations",
2031            correction.iterations
2032        );
2033    }
2034
2035    /// Realistic smoke case from the task brief: 168 gr .308, BC 0.475 G7, 2680 fps measured
2036    /// at 15 ft, default atmosphere. The corrected muzzle velocity must be higher than the
2037    /// measured value by a small, physically plausible margin (a few fps, not tens).
2038    #[test]
2039    fn smoke_2680fps_at_15ft_corrects_upward_by_a_plausible_margin() {
2040        let correction = correct_chrono_velocity_fps(
2041            2680.0,
2042            15.0 * 0.3048,
2043            0.475,
2044            DragModelArg::G7,
2045            168.0,
2046            0.308,
2047            59.0,
2048            29.92,
2049            50.0,
2050            0.0,
2051            &None,
2052        )
2053        .expect("realistic case must converge");
2054
2055        let margin = correction.muzzle_velocity_fps - 2680.0;
2056        assert!(
2057            (0.5..15.0).contains(&margin),
2058            "expected a plausible few-fps correction, got {margin} fps (V0={})",
2059            correction.muzzle_velocity_fps
2060        );
2061    }
2062
2063    /// Convergence across the full stated screen-distance band (3-25 m): every distance in
2064    /// range solves without error, in a small number of secant iterations, and the correction
2065    /// grows monotonically with distance (more travel -> more velocity lost -> a bigger
2066    /// muzzle-velocity correction to explain the same downrange reading).
2067    #[test]
2068    fn convergence_across_the_3_to_25_m_band() {
2069        let mut last_correction = 0.0;
2070        for distance_m in [3.0, 5.0, 10.0, 15.0, 20.0, 25.0] {
2071            let correction = correct_chrono_velocity_fps(
2072                2680.0,
2073                distance_m,
2074                0.475,
2075                DragModelArg::G7,
2076                168.0,
2077                0.308,
2078                59.0,
2079                29.92,
2080                50.0,
2081                0.0,
2082                &None,
2083            )
2084            .unwrap_or_else(|e| panic!("distance {distance_m} m must converge: {e}"));
2085            assert!(
2086                correction.iterations <= 6,
2087                "distance {distance_m} m took {} iterations",
2088                correction.iterations
2089            );
2090            let delta = correction.muzzle_velocity_fps - 2680.0;
2091            assert!(
2092                delta > last_correction,
2093                "correction must grow with distance: {delta} <= {last_correction} at {distance_m} m"
2094            );
2095            last_correction = delta;
2096        }
2097    }
2098
2099    /// Absent distance is handled entirely by the CLI/WASM callers (they special-case
2100    /// `None`/`0.0` as a byte-identical no-op and never call into this solver at all); at this
2101    /// layer, an explicit zero screen distance is simply out of the validated band and must be
2102    /// rejected rather than silently treated as "at the muzzle" by a solver that has no
2103    /// special-case for it.
2104    #[test]
2105    fn zero_distance_is_rejected_by_the_low_level_solver() {
2106        let err = correct_chrono_velocity_fps(
2107            2680.0, 0.0, 0.475, DragModelArg::G7, 168.0, 0.308, 59.0, 29.92, 50.0, 0.0, &None,
2108        )
2109        .unwrap_err();
2110        assert!(err.to_string().contains("out of range"), "{err}");
2111    }
2112
2113    /// Out-of-band screen distances (negative, too-short, too-long) are rejected outright
2114    /// rather than silently clamped or extrapolated into a garbage muzzle velocity.
2115    #[test]
2116    fn out_of_band_distances_are_rejected() {
2117        for bad_distance_m in [-5.0, -0.001, 0.05, 30.001, 100.0, f64::NAN, f64::INFINITY] {
2118            let err = correct_chrono_velocity_fps(
2119                2680.0,
2120                bad_distance_m,
2121                0.475,
2122                DragModelArg::G7,
2123                168.0,
2124                0.308,
2125                59.0,
2126                29.92,
2127                50.0,
2128                0.0,
2129                &None,
2130            )
2131            .unwrap_err();
2132            assert!(
2133                err.to_string().contains("out of range"),
2134                "distance {bad_distance_m}: {err}"
2135            );
2136        }
2137    }
2138
2139    /// A non-positive or non-finite measured velocity is rejected outright.
2140    #[test]
2141    fn invalid_measured_velocity_is_rejected() {
2142        for bad_v in [0.0, -100.0, f64::NAN, f64::INFINITY] {
2143            let err = correct_chrono_velocity_fps(
2144                bad_v, 4.572, 0.475, DragModelArg::G7, 168.0, 0.308, 59.0, 29.92, 50.0, 0.0, &None,
2145            )
2146            .unwrap_err();
2147            assert!(err.to_string().contains("must be positive and finite"), "{err}");
2148        }
2149    }
2150
2151    /// The correction must actually consult the configured drag model, not a hardcoded G1: the
2152    /// SAME (measured velocity, distance, BC) triple fed through G1 and G7 must back-solve to
2153    /// DIFFERENT muzzle velocities, since the two standard drag curves are numerically distinct
2154    /// at this Mach range.
2155    #[test]
2156    fn drag_model_is_actually_consulted_g1_vs_g7_diverge() {
2157        let g1 = correct_chrono_velocity_fps(
2158            2680.0,
2159            15.0 * 0.3048,
2160            0.475,
2161            DragModelArg::G1,
2162            168.0,
2163            0.308,
2164            59.0,
2165            29.92,
2166            50.0,
2167            0.0,
2168            &None,
2169        )
2170        .expect("G1 must converge");
2171        let g7 = correct_chrono_velocity_fps(
2172            2680.0,
2173            15.0 * 0.3048,
2174            0.475,
2175            DragModelArg::G7,
2176            168.0,
2177            0.308,
2178            59.0,
2179            29.92,
2180            50.0,
2181            0.0,
2182            &None,
2183        )
2184        .expect("G7 must converge");
2185
2186        assert!(
2187            (g1.muzzle_velocity_fps - g7.muzzle_velocity_fps).abs() > 0.05,
2188            "G1 ({}) and G7 ({}) corrections should differ measurably for the same inputs",
2189            g1.muzzle_velocity_fps,
2190            g7.muzzle_velocity_fps
2191        );
2192    }
2193}