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/// Shared forward-model core for velocity/BC truing (MBA-1316).
105///
106/// Solves the real trajectory for the given `(velocity_fps, bc)` candidate under
107/// the supplied load/atmosphere and returns `(drop_m, z_m)` where `drop_m` is the
108/// linear vertical distance below the line of sight (positive = below LOS) at the
109/// target range and `z_m` is the horizontal distance actually reached (~range_m).
110/// Callers convert to MIL / MOA / inches as needed. This is the exact assembly
111/// the single-observation binary search has always used, factored out so the
112/// multi-observation joint fit reuses identical physics.
113///
114/// When `interpolate` is false the returned point is the first trajectory sample
115/// at or past the target range (the historical behaviour). When true, the drop
116/// and horizontal distance are linearly interpolated to land exactly on the
117/// target range, removing the ~v*dt spatial quantization that otherwise
118/// stair-steps the cost surface — essential for the multi-observation joint fit,
119/// whose finite-difference Jacobian would otherwise be dominated by that noise.
120#[allow(
121    clippy::too_many_arguments,
122    reason = "flat arguments preserve the existing velocity-truing compatibility helper"
123)]
124pub(crate) fn solve_trajectory_drop(
125    velocity_fps: f64,
126    bc: f64,
127    drag_model: DragModelArg,
128    mass_gr: f64,
129    diameter_in: f64,
130    zero_distance_yd: f64,
131    range_yd: f64,
132    sight_height_in: f64,
133    temperature_f: f64,
134    pressure_inhg: f64,
135    humidity: f64,
136    altitude_ft: f64,
137    bc_segments: &Option<Vec<BCSegmentData>>,
138    interpolate: bool,
139) -> Result<(f64, f64), Box<dyn Error>> {
140    // Convert to SI units
141    let velocity_ms = velocity_fps * 0.3048;
142    let mass_kg = mass_gr * GRAINS_TO_KG;
143    let diameter_m = diameter_in * 0.0254;
144    let zero_m = zero_distance_yd * 0.9144;
145    let range_m = range_yd * 0.9144;
146    let sight_height_m = sight_height_in * 0.0254;
147    let altitude_m = altitude_ft * 0.3048;
148    let temperature_c = (temperature_f - 32.0) * 5.0 / 9.0;
149    let pressure_hpa = pressure_inhg * 33.8639; // Convert inHg to hPa
150
151    let drag_model_enum = match drag_model {
152        DragModelArg::G1 => DragModel::G1,
153        DragModelArg::G7 => DragModel::G7,
154    };
155
156    // Create base inputs - match defaults used by trajectory command
157    let mut inputs = BallisticInputs {
158        muzzle_velocity: velocity_ms,
159        bc_value: bc,
160        bc_type: drag_model_enum,
161        bullet_mass: mass_kg,
162        bullet_diameter: diameter_m,
163        bullet_length: fallback_bullet_length_m(diameter_m, mass_kg), // MBA-1135 mass-based estimate
164        sight_height: sight_height_m,
165        target_distance: range_m + 100.0, // Overshoot to ensure we have data
166        use_bc_segments: bc_segments.is_some(),
167        bc_segments_data: bc_segments.clone(),
168        use_rk4: true,
169        muzzle_angle: 0.0,    // Will be set by zero angle calculation
170        ..Default::default()  // Uses muzzle_height: 0.0 by default
171    };
172
173    // Set up atmospheric conditions
174    // AtmosphericConditions expects: temperature in Celsius, pressure in hPa, humidity 0-100, altitude in meters
175    let atmosphere = AtmosphericConditions {
176        temperature: temperature_c,
177        pressure: pressure_hpa,
178        humidity, // Already 0-100 from input
179        altitude: altitude_m,
180    };
181
182    let wind = WindConditions::default();
183
184    // Calculate zero angle for the zero distance
185    // Target height is sight_height because the bullet must cross the LOS at zero distance
186    // The LOS is at y = sight_height (sight is above bore by sight_height)
187    // So the bullet (starting at y = 0 = bore level) must rise to y = sight_height at zero distance
188    let zero_angle = crate::calculate_zero_angle_with_conditions(
189        inputs.clone(),
190        zero_m,
191        sight_height_m, // target height at zero distance (LOS height)
192        wind.clone(),
193        atmosphere.clone(),
194    )?;
195
196    // Set the calculated zero angle
197    inputs.muzzle_angle = zero_angle;
198
199    // Create solver and solve
200    let mut solver = TrajectorySolver::new(inputs, wind, atmosphere);
201    solver.set_max_range(range_m + 100.0);
202    solver.set_time_step(0.0001);
203
204    let result = solver.solve()?;
205
206    // Find the first point at or past the target range.
207    let idx = result
208        .points
209        .iter()
210        .position(|p| p.position.x >= range_m)
211        .ok_or("Trajectory didn't reach target range")?;
212
213    // Determine the horizontal distance z and bullet height at the target range.
214    let (z, bullet_y) = if interpolate && idx > 0 {
215        // Linearly interpolate between the bracketing samples to land exactly on
216        // range_m (removes ~v*dt spatial quantization).
217        let p0 = &result.points[idx - 1];
218        let p1 = &result.points[idx];
219        let x0 = p0.position.x;
220        let x1 = p1.position.x;
221        let denom = x1 - x0;
222        if denom.abs() < f64::EPSILON {
223            (p1.position.x, p1.position.y)
224        } else {
225            let frac = (range_m - x0) / denom;
226            let y = p0.position.y + frac * (p1.position.y - p0.position.y);
227            (range_m, y)
228        }
229    } else {
230        let p = &result.points[idx];
231        (p.position.x, p.position.y)
232    };
233
234    // Calculate drop relative to the line of sight. The trajectory was already launched at the
235    // solved zero angle, so the LOS for this zero solve is horizontal at sight_height_m.
236    // Drop = LOS height - bullet position (positive = below LOS)
237    let drop_m = sight_height_m - bullet_y;
238
239    Ok((drop_m, z))
240}
241
242/// Calculate drop at a given muzzle velocity using trajectory solver
243/// Returns drop in MILs at the target range
244#[allow(
245    clippy::too_many_arguments,
246    reason = "flat arguments preserve the existing velocity-truing compatibility helper"
247)]
248pub(crate) fn calculate_drop_at_velocity(
249    velocity_fps: f64,
250    bc: f64,
251    drag_model: DragModelArg,
252    mass_gr: f64,
253    diameter_in: f64,
254    zero_distance_yd: f64,
255    range_yd: f64,
256    sight_height_in: f64,
257    temperature_f: f64,
258    pressure_inhg: f64,
259    humidity: f64,
260    altitude_ft: f64,
261    bc_segments: &Option<Vec<BCSegmentData>>,
262) -> Result<f64, Box<dyn Error>> {
263    // Preserve the historical MIL contract by delegating to the shared linear-drop
264    // core (MBA-1316). `interpolate = false` reproduces the original "first point
265    // at or past the range" sampling, so this path stays byte-identical.
266    let (drop_m, z) = solve_trajectory_drop(
267        velocity_fps,
268        bc,
269        drag_model,
270        mass_gr,
271        diameter_in,
272        zero_distance_yd,
273        range_yd,
274        sight_height_in,
275        temperature_f,
276        pressure_inhg,
277        humidity,
278        altitude_ft,
279        bc_segments,
280        false,
281    )?;
282
283    // Convert to MILs: mil = (drop_inches / 36 / range_yards) * 1000
284    // Or equivalently: mil = (drop_m / range_m) * 1000
285    let drop_mil = (drop_m / z) * 1000.0;
286
287    Ok(drop_mil)
288}
289
290/// Result of the classic single-observation velocity truing
291/// ([`calculate_true_velocity_local`]).
292#[derive(Debug, Clone)]
293pub struct TrueVelocityLocalResult {
294    /// The fitted effective muzzle velocity, in feet per second.
295    pub effective_velocity_fps: f64,
296    /// Number of binary-search iterations actually run.
297    pub iterations: i32,
298    /// Signed residual at the returned velocity, in MIL:
299    /// `calculated_drop_mil - measured_drop_mil`. Positive means the model still
300    /// predicts MORE drop than was measured at the returned velocity; negative
301    /// means less.
302    pub final_error_mil: f64,
303    /// The model-predicted drop (MIL) at the returned velocity.
304    pub calculated_drop_mil: f64,
305    /// Convergence quality: `"high"` (converged, |error| < 0.005 mil),
306    /// `"medium"` (converged within the 0.01 mil tolerance, or stopped early
307    /// with |error| < 0.1 mil), or `"low"` (did not converge; |error| >= 0.1 mil).
308    pub confidence: String,
309}
310
311/// Calculate the effective muzzle velocity that reproduces `measured_drop_mil`
312/// at `range_yd`, via binary search over the real trajectory solver
313/// (the classic single-observation `true-velocity` path).
314///
315/// Returns a [`TrueVelocityLocalResult`] carrying the fitted velocity (fps),
316/// the iteration count, the signed final residual in MIL (positive = the model
317/// still predicts more drop than measured), the model-predicted drop at the
318/// returned velocity, and a `"high"`/`"medium"`/`"low"` confidence label — see
319/// the field docs on [`TrueVelocityLocalResult`] for the exact banding.
320///
321/// Errors on degenerate inputs (`range_yd` non-positive or non-finite,
322/// `measured_drop_mil` non-finite) and on any trajectory-solver failure.
323#[allow(
324    clippy::too_many_arguments,
325    reason = "flat arguments mirror the stable true-velocity CLI command shape"
326)]
327pub fn calculate_true_velocity_local(
328    measured_drop_mil: f64,
329    range_yd: f64,
330    bc: f64,
331    drag_model: DragModelArg,
332    mass_gr: f64,
333    diameter_in: f64,
334    zero_distance_yd: f64,
335    sight_height_in: f64,
336    temperature_f: f64,
337    pressure_inhg: f64,
338    humidity: f64,
339    altitude_ft: f64,
340    bc_segments: &Option<Vec<BCSegmentData>>,
341) -> Result<TrueVelocityLocalResult, Box<dyn Error>> {
342    // Reject degenerate inputs instead of letting NaN/inf flow through the
343    // solver and come back as Ok(NaN). The native CLI's clap range validators
344    // cannot produce these; direct library / WASM callers can.
345    if !range_yd.is_finite() || range_yd <= 0.0 {
346        return Err("range must be positive and finite".into());
347    }
348    if !measured_drop_mil.is_finite() {
349        return Err("measured drop must be finite".into());
350    }
351
352    // Binary search between velocity bounds
353    let mut velocity_low = 1500.0;
354    let mut velocity_high = 4500.0;
355    let tolerance_mil = 0.01; // 0.01 MIL tolerance
356    let max_iterations = 50;
357
358    let mut iterations = 0;
359    let mut last_error = 0.0;
360    let mut last_calculated_drop = 0.0;
361
362    for i in 0..max_iterations {
363        iterations = i + 1;
364        let test_velocity = (velocity_low + velocity_high) / 2.0;
365
366        // Run trajectory at test velocity
367        let calculated_drop_mil = calculate_drop_at_velocity(
368            test_velocity,
369            bc,
370            drag_model,
371            mass_gr,
372            diameter_in,
373            zero_distance_yd,
374            range_yd,
375            sight_height_in,
376            temperature_f,
377            pressure_inhg,
378            humidity,
379            altitude_ft,
380            bc_segments,
381        )?;
382
383        last_calculated_drop = calculated_drop_mil;
384        let error = calculated_drop_mil - measured_drop_mil;
385        last_error = error;
386
387        if error.abs() < tolerance_mil {
388            // Converged
389            let confidence = if error.abs() < 0.005 {
390                "high"
391            } else {
392                "medium"
393            };
394
395            return Ok(TrueVelocityLocalResult {
396                effective_velocity_fps: test_velocity,
397                iterations,
398                final_error_mil: error,
399                calculated_drop_mil,
400                confidence: confidence.to_string(),
401            });
402        }
403
404        // Higher calculated drop = bullet is slower = need higher velocity
405        // Lower calculated drop = bullet is faster = need lower velocity
406        if calculated_drop_mil > measured_drop_mil {
407            // Bullet dropping more than observed = slower than actual
408            // Need higher velocity
409            velocity_low = test_velocity;
410        } else {
411            // Bullet dropping less = faster than actual
412            // Need lower velocity
413            velocity_high = test_velocity;
414        }
415
416        // Check for convergence issues
417        if (velocity_high - velocity_low).abs() < 0.5 {
418            break;
419        }
420    }
421
422    // Did not converge within tolerance, return best estimate
423    let final_velocity = (velocity_low + velocity_high) / 2.0;
424    let confidence = if last_error.abs() < 0.1 {
425        "medium"
426    } else {
427        "low"
428    };
429
430    Ok(TrueVelocityLocalResult {
431        effective_velocity_fps: final_velocity,
432        iterations,
433        final_error_mil: last_error,
434        calculated_drop_mil: last_calculated_drop,
435        confidence: confidence.to_string(),
436    })
437}
438
439// ============================================================================
440// MBA-1316: multi-observation joint MV + BC calibration (truing v2)
441// ============================================================================
442//
443// The classic `true-velocity` path fits muzzle velocity from a single observed
444// drop while BC is held fixed. Mid-range (fully supersonic) drops are dominated
445// by time of flight and therefore constrain muzzle velocity; long-range /
446// transonic drops are where BC bites. With observations spread across those
447// regimes we can separate the two. When the spread is too narrow to tell them
448// apart we refuse the joint fit and fit muzzle velocity only, saying so.
449
450// Solver / fit bounds and identifiability gates. See each constant's doc; the
451// empirical basis for the gate values (calibrated against real .30-cal data,
452// MBA-1316):
453//   * 300/600/900 -> sens 0.29, cond 112 -> BC recovered to ~2%   (JOINT)
454//   * 300/600/900/1000 -> sens 0.33, cond 214 -> BC to <0.1%      (JOINT)
455//   * 300/400/500 -> sens 0.16, cond 282 -> BC error ~3%          (MV-ONLY)
456//   * 200/250/300 -> sens 0.12, cond 2337 -> BC error ~12%        (MV-ONLY)
457// (.308 168gr @ 2700/0.475, ~0.03 mil observation noise.) The gates sit between
458// the "recovers to ~2%" band and the "several-percent garbage" band. They are
459// heuristics: a set that just clears them still carries more BC uncertainty
460// than a set that clears them comfortably.
461
462/// Lower muzzle-velocity fit bound (fps): brackets subsonic pistol loads.
463pub(crate) const TRUING_MV_MIN_FPS: f64 = 1000.0;
464/// Upper muzzle-velocity fit bound (fps): brackets hyper-velocity varmint loads.
465pub(crate) const TRUING_MV_MAX_FPS: f64 = 5000.0;
466/// Lower BC fit bound; matches the CLI's `--bc` validator minimum.
467pub(crate) const TRUING_BC_MIN: f64 = 0.05;
468/// Upper BC fit bound; matches the CLI's `--bc` validator maximum.
469pub(crate) const TRUING_BC_MAX: f64 = 2.0;
470/// Iteration cap shared by the MV-only and joint fitters.
471pub(crate) const TRUING_MAX_ITERS: usize = 40;
472
473/// Identifiability gate: minimum
474/// `sensitivity_ratio = ||bc*d(drop)/d(bc)|| / ||mv*d(drop)/d(mv)||` — how much
475/// a fractional BC change moves the predicted drops relative to a fractional MV
476/// change. Below this, the observations barely constrain BC and the joint fit
477/// is refused (MV-only fallback).
478pub(crate) const TRUING_MIN_BC_SENSITIVITY_RATIO: f64 = 0.20;
479/// Identifiability gate: maximum `condition_number = (1+|c|)/(1-|c|)` (|c| =
480/// correlation of the raw MV/BC Jacobian columns). Above this the two columns
481/// are collinear — a BC change can be undone by an MV change — and the joint
482/// fit is refused (MV-only fallback). Both this gate and
483/// [`TRUING_MIN_BC_SENSITIVITY_RATIO`] must pass to attempt the joint fit.
484pub(crate) const TRUING_MAX_CONDITION_NUMBER: f64 = 1.0e3;
485
486/// A single observed impact used for truing: range (internal yards) and the
487/// measured drop below line of sight expressed in the caller's drop unit.
488#[derive(Debug, Clone, Copy)]
489pub struct TruingObservation {
490    pub range_yd: f64,
491    pub drop: f64,
492}
493
494/// Complete scalar-BC forward-model input used by the truing design and
495/// uncertainty APIs added in MBA-1346/MBA-1353.
496///
497/// All values use the truing core's historical imperial units.  A front end is
498/// expected to convert its display units once at the boundary.  V1 deliberately
499/// models one scalar G1/G7 coefficient: a velocity-banded BC schedule or custom
500/// Mach/Cd deck does not have a single BC parameter to identify and must not be
501/// silently reduced to one.
502#[derive(Debug, Clone, Copy, Serialize)]
503pub struct TruingModelInputsV1 {
504    /// Nominal muzzle velocity about which a plan is designed, in feet/second.
505    pub muzzle_velocity_fps: f64,
506    /// Nominal scalar ballistic coefficient about which a plan is designed.
507    pub ballistic_coefficient: f64,
508    pub drag_model: DragModelArg,
509    /// Bullet mass in grains.
510    pub mass_gr: f64,
511    /// Bullet diameter in inches.
512    pub diameter_in: f64,
513    /// Zero distance in yards.
514    pub zero_distance_yd: f64,
515    /// Sight height over bore in inches.
516    pub sight_height_in: f64,
517    /// Ambient temperature in degrees Fahrenheit.
518    pub temperature_f: f64,
519    /// Station pressure in inches of mercury.
520    pub pressure_inhg: f64,
521    /// Relative humidity in percent (0 through 100).
522    pub humidity_pct: f64,
523    /// Altitude in feet.
524    pub altitude_ft: f64,
525}
526
527impl TruingModelInputsV1 {
528    /// Validate the scalar-BC model before an expensive design or uncertainty
529    /// calculation.  These bounds mirror the existing truing solver where
530    /// applicable, while rejecting NaN/infinity at the library boundary.
531    pub fn validate(&self) -> Result<(), String> {
532        if !self.muzzle_velocity_fps.is_finite()
533            || !(TRUING_MV_MIN_FPS..=TRUING_MV_MAX_FPS).contains(&self.muzzle_velocity_fps)
534        {
535            return Err(format!(
536                "nominal muzzle velocity must be finite and within {TRUING_MV_MIN_FPS:.0}..={TRUING_MV_MAX_FPS:.0} fps"
537            ));
538        }
539        if !self.ballistic_coefficient.is_finite()
540            || !(TRUING_BC_MIN..=TRUING_BC_MAX).contains(&self.ballistic_coefficient)
541        {
542            return Err(format!(
543                "nominal ballistic coefficient must be finite and within {TRUING_BC_MIN:.2}..={TRUING_BC_MAX:.1}"
544            ));
545        }
546        for (name, value) in [
547            ("bullet mass", self.mass_gr),
548            ("bullet diameter", self.diameter_in),
549            ("zero distance", self.zero_distance_yd),
550            ("sight height", self.sight_height_in),
551            ("pressure", self.pressure_inhg),
552        ] {
553            if !value.is_finite() || value <= 0.0 {
554                return Err(format!("{name} must be positive and finite"));
555            }
556        }
557        if !self.temperature_f.is_finite() {
558            return Err("temperature must be finite".to_string());
559        }
560        if !self.humidity_pct.is_finite() || !(0.0..=100.0).contains(&self.humidity_pct) {
561            return Err("humidity must be finite and within 0..=100 percent".to_string());
562        }
563        if !self.altitude_ft.is_finite() {
564            return Err("altitude must be finite".to_string());
565        }
566        Ok(())
567    }
568
569    /// Borrow this owned public shape as the existing internal forward model.
570    /// The local `None` is intentional: V1 estimates one scalar BC and therefore
571    /// cannot accept a velocity-banded BC schedule.
572    pub(crate) fn with_forward_model<T>(
573        &self,
574        drop_unit: DropUnit,
575        f: impl FnOnce(&TruingForwardModel<'_>) -> T,
576    ) -> T {
577        let no_bc_segments = None;
578        let model = TruingForwardModel {
579            drag_model: self.drag_model,
580            mass_gr: self.mass_gr,
581            diameter_in: self.diameter_in,
582            zero_yd: self.zero_distance_yd,
583            sight_in: self.sight_height_in,
584            temp_f: self.temperature_f,
585            press_inhg: self.pressure_inhg,
586            humidity: self.humidity_pct,
587            alt_ft: self.altitude_ft,
588            bc_segments: &no_bc_segments,
589            drop_unit,
590        };
591        f(&model)
592    }
593}
594
595/// Parse an `--observed RANGE:DROP` token. RANGE is in the caller's distance
596/// units (yards imperial / meters metric) and is normalized to internal yards;
597/// DROP stays in the caller's drop unit. Returns a user-facing error string on
598/// malformed input so the CLI can report cleanly instead of panicking.
599pub fn parse_truing_observation(s: &str, units: UnitSystem) -> Result<TruingObservation, String> {
600    let parts: Vec<&str> = s.split(':').collect();
601    if parts.len() != 2 {
602        return Err(format!(
603            "invalid --observed '{s}': expected RANGE:DROP (e.g. 600:5.1)"
604        ));
605    }
606    let range: f64 = parts[0]
607        .trim()
608        .parse()
609        .map_err(|_| format!("invalid --observed range '{}' in '{s}'", parts[0]))?;
610    let drop: f64 = parts[1]
611        .trim()
612        .parse()
613        .map_err(|_| format!("invalid --observed drop '{}' in '{s}'", parts[1]))?;
614    if !range.is_finite() || !drop.is_finite() {
615        return Err(format!("invalid --observed '{s}': values must be finite"));
616    }
617    let range_yd = match units {
618        UnitSystem::Imperial => range,
619        UnitSystem::Metric => range / 0.9144,
620    };
621    Ok(TruingObservation { range_yd, drop })
622}
623
624/// Validate a truing observation set: every range finite and positive, every
625/// drop finite and non-zero, and no two observations at (numerically) the same
626/// range. [`run_multi_observation_truing_core`] runs this itself, so callers
627/// don't have to — it is public so a front end can pre-validate when it wants
628/// to sequence its own progress output strictly after validation (the native
629/// CLI prints its "Fitting N observations..." progress line only for sets that
630/// will actually be fitted). Error strings are the stable user-facing ones.
631pub fn validate_truing_observations(observations: &[TruingObservation]) -> Result<(), String> {
632    for o in observations {
633        if !o.range_yd.is_finite() || o.range_yd <= 0.0 {
634            return Err(format!(
635                "observation range must be a positive finite distance (got {})",
636                o.range_yd
637            ));
638        }
639        if !o.drop.is_finite() || o.drop == 0.0 {
640            return Err(
641                "observation drop must be non-zero (a zero drop carries no truing information)"
642                    .to_string(),
643            );
644        }
645    }
646    for i in 0..observations.len() {
647        for j in (i + 1)..observations.len() {
648            if (observations[i].range_yd - observations[j].range_yd).abs() < 1e-6 {
649                return Err(format!(
650                    "duplicate observation range ({:.3} yd internal): each observation must be at a distinct range",
651                    observations[i].range_yd
652                ));
653            }
654        }
655    }
656    Ok(())
657}
658
659/// Fixed load / atmosphere for a truing fit. The two free parameters (muzzle
660/// velocity and BC) are supplied per prediction so the fitter can vary them.
661pub(crate) struct TruingForwardModel<'a> {
662    pub drag_model: DragModelArg,
663    pub mass_gr: f64,
664    pub diameter_in: f64,
665    pub zero_yd: f64,
666    pub sight_in: f64,
667    pub temp_f: f64,
668    pub press_inhg: f64,
669    pub humidity: f64,
670    pub alt_ft: f64,
671    pub bc_segments: &'a Option<Vec<BCSegmentData>>,
672    pub drop_unit: DropUnit,
673}
674
675impl TruingForwardModel<'_> {
676    /// Predicted drop (in the configured drop unit) at `range_yd` for the given
677    /// muzzle velocity and BC, using the real trajectory solver.
678    pub fn predict(&self, mv_fps: f64, bc: f64, range_yd: f64) -> Result<f64, Box<dyn Error>> {
679        self.predict_in_unit(mv_fps, bc, range_yd, self.drop_unit)
680    }
681
682    /// `predict` expressed in an explicit unit, independent of the configured
683    /// `--drop-unit`. The identifiability diagnostics use this to stay in mil space
684    /// (MBA-1337 t1): the linear `in` unit weights each Jacobian row by its range,
685    /// which shifts the column correlation. NOTE this makes the gate DIAGNOSTICS
686    /// unit-invariant; the MV-only operating point and the least-squares cost are
687    /// still minimized in the display unit (deliberately out of scope — changing
688    /// them changes fitted numbers), so extreme near-boundary sets can still differ.
689    pub fn predict_in_unit(
690        &self,
691        mv_fps: f64,
692        bc: f64,
693        range_yd: f64,
694        unit: DropUnit,
695    ) -> Result<f64, Box<dyn Error>> {
696        let (drop_m, z_m) = solve_trajectory_drop(
697            mv_fps,
698            bc,
699            self.drag_model,
700            self.mass_gr,
701            self.diameter_in,
702            self.zero_yd,
703            range_yd,
704            self.sight_in,
705            self.temp_f,
706            self.press_inhg,
707            self.humidity,
708            self.alt_ft,
709            self.bc_segments,
710            true, // interpolate: smooth forward model for the fitter
711        )?;
712        Ok(unit.express_drop_m(drop_m, z_m))
713    }
714
715    /// Predict several ranges from one zero solve and one trajectory integration.
716    /// `None` marks a candidate the trajectory did not physically reach (or an
717    /// invalid non-positive/non-finite candidate); other solver failures remain
718    /// errors because they invalidate the entire nominal/perturbed model.
719    ///
720    /// This is the high-throughput path for experiment design and predictive
721    /// bands.  It deliberately reproduces [`solve_trajectory_drop`]'s assembly
722    /// and interpolation, but integrates through the farthest candidate once.
723    pub(crate) fn predict_many_in_unit(
724        &self,
725        mv_fps: f64,
726        bc: f64,
727        ranges_yd: &[f64],
728        unit: DropUnit,
729    ) -> Result<Vec<Option<f64>>, Box<dyn Error>> {
730        if ranges_yd.is_empty() {
731            return Ok(Vec::new());
732        }
733        let max_range_yd = ranges_yd
734            .iter()
735            .copied()
736            .filter(|r| r.is_finite() && *r > 0.0)
737            .fold(0.0_f64, f64::max);
738        if max_range_yd <= 0.0 {
739            return Ok(vec![None; ranges_yd.len()]);
740        }
741
742        let velocity_ms = mv_fps * 0.3048;
743        let mass_kg = self.mass_gr * GRAINS_TO_KG;
744        let diameter_m = self.diameter_in * 0.0254;
745        let zero_m = self.zero_yd * 0.9144;
746        let max_range_m = max_range_yd * 0.9144;
747        let sight_height_m = self.sight_in * 0.0254;
748        let altitude_m = self.alt_ft * 0.3048;
749        let temperature_c = (self.temp_f - 32.0) * 5.0 / 9.0;
750        let pressure_hpa = self.press_inhg * 33.8639;
751        let drag_model = match self.drag_model {
752            DragModelArg::G1 => DragModel::G1,
753            DragModelArg::G7 => DragModel::G7,
754        };
755
756        let mut inputs = BallisticInputs {
757            muzzle_velocity: velocity_ms,
758            bc_value: bc,
759            bc_type: drag_model,
760            bullet_mass: mass_kg,
761            bullet_diameter: diameter_m,
762            bullet_length: fallback_bullet_length_m(diameter_m, mass_kg),
763            sight_height: sight_height_m,
764            target_distance: max_range_m + 100.0,
765            use_bc_segments: self.bc_segments.is_some(),
766            bc_segments_data: self.bc_segments.clone(),
767            use_rk4: true,
768            muzzle_angle: 0.0,
769            ..Default::default()
770        };
771        let atmosphere = AtmosphericConditions {
772            temperature: temperature_c,
773            pressure: pressure_hpa,
774            humidity: self.humidity,
775            altitude: altitude_m,
776        };
777        let wind = WindConditions::default();
778        inputs.muzzle_angle = crate::calculate_zero_angle_with_conditions(
779            inputs.clone(),
780            zero_m,
781            sight_height_m,
782            wind.clone(),
783            atmosphere.clone(),
784        )?;
785
786        let mut solver = TrajectorySolver::new(inputs, wind, atmosphere);
787        solver.set_max_range(max_range_m + 100.0);
788        solver.set_time_step(0.0001);
789        let result = solver.solve()?;
790
791        let predictions = ranges_yd
792            .iter()
793            .map(|range_yd| {
794                if !range_yd.is_finite() || *range_yd <= 0.0 {
795                    return None;
796                }
797                let range_m = *range_yd * 0.9144;
798                let idx = result.points.iter().position(|p| p.position.x >= range_m)?;
799                let (z_m, bullet_y) = if idx > 0 {
800                    let p0 = &result.points[idx - 1];
801                    let p1 = &result.points[idx];
802                    let span = p1.position.x - p0.position.x;
803                    if span.abs() < f64::EPSILON {
804                        (p1.position.x, p1.position.y)
805                    } else {
806                        let fraction = (range_m - p0.position.x) / span;
807                        (
808                            range_m,
809                            p0.position.y + fraction * (p1.position.y - p0.position.y),
810                        )
811                    }
812                } else {
813                    let p = &result.points[idx];
814                    (p.position.x, p.position.y)
815                };
816                let drop_m = sight_height_m - bullet_y;
817                Some(unit.express_drop_m(drop_m, z_m))
818            })
819            .collect();
820        Ok(predictions)
821    }
822
823    /// Sum of squared residuals (predicted - observed) over all observations.
824    pub fn cost(&self, mv: f64, bc: f64, obs: &[TruingObservation]) -> Result<f64, Box<dyn Error>> {
825        let mut c = 0.0;
826        for o in obs {
827            let r = self.predict(mv, bc, o.range_yd)? - o.drop;
828            c += r * r;
829        }
830        Ok(c)
831    }
832}
833
834/// One finite-difference row of the truing forward model.  The planner and
835/// uncertainty engine deliberately share this helper with the point fitter so
836/// that experiment-design claims and posterior diagnostics describe the exact
837/// same physics and perturbation sizes.
838#[derive(Debug, Clone, Copy)]
839pub(crate) struct TruingJacobianRow {
840    pub predicted_drop: f64,
841    pub d_drop_d_mv: f64,
842    pub d_drop_d_bc: f64,
843}
844
845pub(crate) fn truing_jacobian_row(
846    model: &TruingForwardModel<'_>,
847    mv_fps: f64,
848    bc: f64,
849    range_yd: f64,
850    unit: DropUnit,
851) -> Result<TruingJacobianRow, Box<dyn Error>> {
852    let hmv = (mv_fps * 1e-3).max(0.5);
853    let hbc = (bc * 1e-3).max(1e-4);
854    let predicted_drop = model.predict_in_unit(mv_fps, bc, range_yd, unit)?;
855    let d_drop_d_mv = (model.predict_in_unit(mv_fps + hmv, bc, range_yd, unit)?
856        - model.predict_in_unit(mv_fps - hmv, bc, range_yd, unit)?)
857        / (2.0 * hmv);
858    let d_drop_d_bc = (model.predict_in_unit(mv_fps, bc + hbc, range_yd, unit)?
859        - model.predict_in_unit(mv_fps, bc - hbc, range_yd, unit)?)
860        / (2.0 * hbc);
861    Ok(TruingJacobianRow {
862        predicted_drop,
863        d_drop_d_mv,
864        d_drop_d_bc,
865    })
866}
867
868/// Batched form of [`truing_jacobian_row`].  Five integrations (nominal,
869/// MV+/-, BC+/-) cover every requested range.  A row is `None` if any of those
870/// trajectories cannot reach that candidate, preventing one-sided or silently
871/// fabricated sensitivity estimates.
872pub(crate) fn truing_jacobian_rows(
873    model: &TruingForwardModel<'_>,
874    mv_fps: f64,
875    bc: f64,
876    ranges_yd: &[f64],
877    unit: DropUnit,
878) -> Result<Vec<Option<TruingJacobianRow>>, Box<dyn Error>> {
879    let hmv = (mv_fps * 1e-3).max(0.5);
880    let hbc = (bc * 1e-3).max(1e-4);
881    let nominal = model.predict_many_in_unit(mv_fps, bc, ranges_yd, unit)?;
882    let mv_plus = model.predict_many_in_unit(mv_fps + hmv, bc, ranges_yd, unit)?;
883    let mv_minus = model.predict_many_in_unit(mv_fps - hmv, bc, ranges_yd, unit)?;
884    let bc_plus = model.predict_many_in_unit(mv_fps, bc + hbc, ranges_yd, unit)?;
885    let bc_minus = model.predict_many_in_unit(mv_fps, bc - hbc, ranges_yd, unit)?;
886
887    Ok((0..ranges_yd.len())
888        .map(|i| {
889            Some(TruingJacobianRow {
890                predicted_drop: nominal[i]?,
891                d_drop_d_mv: (mv_plus[i]? - mv_minus[i]?) / (2.0 * hmv),
892                d_drop_d_bc: (bc_plus[i]? - bc_minus[i]?) / (2.0 * hbc),
893            })
894        })
895        .collect())
896}
897
898/// One-parameter (muzzle velocity) least-squares fit with BC held fixed. Damped
899/// Gauss-Newton with a central finite-difference derivative; robust because drop
900/// is monotonic in muzzle velocity. Returns `(mv_fps, iterations, converged)`.
901pub(crate) fn fit_truing_mv_only(
902    model: &TruingForwardModel<'_>,
903    obs: &[TruingObservation],
904    bc: f64,
905    mv_init: f64,
906) -> Result<(f64, usize, bool), Box<dyn Error>> {
907    let mut mv = mv_init.clamp(TRUING_MV_MIN_FPS, TRUING_MV_MAX_FPS);
908    let mut converged = false;
909    let mut iters = 0;
910    for i in 0..TRUING_MAX_ITERS {
911        iters = i + 1;
912        let h = (mv * 1e-3).max(0.5);
913        let mut num = 0.0;
914        let mut den = 0.0;
915        for o in obs {
916            let r = model.predict(mv, bc, o.range_yd)? - o.drop;
917            let dp = model.predict(mv + h, bc, o.range_yd)?;
918            let dm = model.predict(mv - h, bc, o.range_yd)?;
919            let j = (dp - dm) / (2.0 * h);
920            num += j * r;
921            den += j * j;
922        }
923        if den < 1e-12 {
924            break;
925        }
926        // Gauss-Newton step, limited to keep the solver in a sane regime.
927        let step = (-num / den).clamp(-300.0, 300.0);
928        let new_mv = (mv + step).clamp(TRUING_MV_MIN_FPS, TRUING_MV_MAX_FPS);
929        if (new_mv - mv).abs() < 0.05 {
930            mv = new_mv;
931            converged = true;
932            break;
933        }
934        mv = new_mv;
935    }
936    Ok((mv, iters, converged))
937}
938
939/// Identifiability diagnostics for BC at the operating point `(mv, bc)`.
940///
941/// Returns `(sensitivity_ratio, condition_number)`:
942///
943/// * `sensitivity_ratio = ||bc*d(drop)/d(bc)|| / ||mv*d(drop)/d(mv)||` — the
944///   relative influence of a fractional BC change vs a fractional MV change on
945///   the predicted drops. Small => BC barely moves the drops (weak-signal mode).
946///
947/// * `condition_number = (1+|c|)/(1-|c|)` where `c` is the correlation between
948///   the raw MV and BC Jacobian columns. Large => the two columns point the same
949///   way, so a BC change can be undone by an MV change and the pair is not
950///   separable (collinearity mode). This is magnitude-independent, unlike the
951///   scaled-normal-matrix condition, so it actually tracks observation spread.
952pub(crate) fn truing_identifiability(
953    model: &TruingForwardModel<'_>,
954    obs: &[TruingObservation],
955    mv: f64,
956    bc: f64,
957) -> Result<(f64, f64), Box<dyn Error>> {
958    let (mut n_mv, mut n_bc, mut cross) = (0.0, 0.0, 0.0);
959    // Differentiate in mil space regardless of --drop-unit (MBA-1337 t1) so these
960    // gate diagnostics do not shift with the display unit. (The operating point mv
961    // comes from a display-unit fit, so full invariance is not guaranteed — see
962    // predict_in_unit's note.)
963    let unit = DropUnit::Mil;
964    for o in obs {
965        let row = truing_jacobian_row(model, mv, bc, o.range_yd, unit)?;
966        n_mv += row.d_drop_d_mv * row.d_drop_d_mv;
967        n_bc += row.d_drop_d_bc * row.d_drop_d_bc;
968        cross += row.d_drop_d_mv * row.d_drop_d_bc;
969    }
970    let norm_mv = n_mv.sqrt();
971    let norm_bc = n_bc.sqrt();
972    let sensitivity_ratio = if mv * norm_mv > 0.0 {
973        (bc * norm_bc) / (mv * norm_mv)
974    } else {
975        0.0
976    };
977    // Correlation between the raw Jacobian columns.
978    let condition_number = if norm_mv > 0.0 && norm_bc > 0.0 {
979        let c = (cross / (norm_mv * norm_bc)).clamp(-1.0, 1.0).abs();
980        if (1.0 - c) > 1e-15 {
981            (1.0 + c) / (1.0 - c)
982        } else {
983            f64::INFINITY
984        }
985    } else {
986        // One column is numerically zero: the missing parameter is unconstrained.
987        f64::INFINITY
988    };
989    Ok((sensitivity_ratio, condition_number))
990}
991
992/// Two-parameter (muzzle velocity, BC) joint fit via Levenberg-Marquardt
993/// (damped Gauss-Newton) with central finite-difference Jacobian. Only the
994/// diagonal of the normal matrix is damped (classic Marquardt scaling). Returns
995/// `(mv_fps, bc, iterations, converged)`.
996pub(crate) fn fit_truing_joint(
997    model: &TruingForwardModel<'_>,
998    obs: &[TruingObservation],
999    mv_init: f64,
1000    bc_init: f64,
1001) -> Result<(f64, f64, usize, bool), Box<dyn Error>> {
1002    let mut mv = mv_init.clamp(TRUING_MV_MIN_FPS, TRUING_MV_MAX_FPS);
1003    let mut bc = bc_init.clamp(TRUING_BC_MIN, TRUING_BC_MAX);
1004    let mut lambda = 1e-3;
1005    let mut cur_cost = model.cost(mv, bc, obs)?;
1006    let mut converged = false;
1007    let mut iters = 0;
1008
1009    for it in 0..TRUING_MAX_ITERS {
1010        iters = it + 1;
1011        // Build J^T J (a00,a01,a11) and J^T r (g0,g1).
1012        let (mut a00, mut a01, mut a11) = (0.0, 0.0, 0.0);
1013        let (mut g0, mut g1) = (0.0, 0.0);
1014        for o in obs {
1015            let row = truing_jacobian_row(model, mv, bc, o.range_yd, model.drop_unit)?;
1016            let r = row.predicted_drop - o.drop;
1017            let jmv = row.d_drop_d_mv;
1018            let jbc = row.d_drop_d_bc;
1019            a00 += jmv * jmv;
1020            a01 += jmv * jbc;
1021            a11 += jbc * jbc;
1022            g0 += jmv * r;
1023            g1 += jbc * r;
1024        }
1025
1026        // Inner loop: grow lambda until a step reduces the cost.
1027        let mut accepted = false;
1028        for _ in 0..30 {
1029            let m00 = a00 + lambda * a00.max(1e-12);
1030            let m11 = a11 + lambda * a11.max(1e-12);
1031            let det = m00 * m11 - a01 * a01;
1032            if det.abs() < 1e-20 {
1033                lambda *= 10.0;
1034                continue;
1035            }
1036            // delta = -(JtJ + lambda*diag)^-1 * Jtr
1037            let dmv = -(m11 * g0 - a01 * g1) / det;
1038            let dbc = -(-a01 * g0 + m00 * g1) / det;
1039            let nmv = (mv + dmv).clamp(TRUING_MV_MIN_FPS, TRUING_MV_MAX_FPS);
1040            let nbc = (bc + dbc).clamp(TRUING_BC_MIN, TRUING_BC_MAX);
1041            let nc = model.cost(nmv, nbc, obs)?;
1042            if nc < cur_cost {
1043                let rel_change =
1044                    (nmv - mv).abs() / mv.max(1.0) + (nbc - bc).abs() / bc.max(1e-3);
1045                mv = nmv;
1046                bc = nbc;
1047                cur_cost = nc;
1048                lambda = (lambda * 0.5).max(1e-9);
1049                accepted = true;
1050                if rel_change < 1e-6 {
1051                    converged = true;
1052                }
1053                break;
1054            }
1055            lambda *= 4.0;
1056            if lambda > 1e12 {
1057                break;
1058            }
1059        }
1060        if !accepted {
1061            // No downhill step exists within damping range: we are at (or
1062            // numerically indistinguishable from) a local optimum.
1063            converged = true;
1064            break;
1065        }
1066        if converged {
1067            break;
1068        }
1069    }
1070    Ok((mv, bc, iters, converged))
1071}
1072
1073/// Final report produced by a multi-observation truing fit.
1074#[derive(Debug, Clone)]
1075pub struct MultiTruingReport {
1076    pub fitted_mv_fps: f64,
1077    pub fitted_bc: f64,
1078    pub bc_input: f64,
1079    pub bc_fitted: bool,
1080    pub observations: Vec<TruingObservation>,
1081    pub predicted: Vec<f64>,
1082    pub residuals: Vec<f64>,
1083    pub rms: f64,
1084    pub iterations: usize,
1085    pub converged: bool,
1086    pub sensitivity_ratio: f64,
1087    pub condition_number: f64,
1088    pub quality: String,
1089    pub reason: String,
1090}
1091
1092/// Orchestrate the multi-observation joint MV+BC calibration and build the
1093/// final [`MultiTruingReport`] (MBA-1343: the compute half only — rendering
1094/// stays with the caller).
1095///
1096/// `observations` is the already-parsed observation set, primary
1097/// (`--range`/`--measured-drop`) first, then each `--observed` impact in order
1098/// (use [`parse_truing_observation`] to build them from `RANGE:DROP` tokens);
1099/// every drop is already in `drop_unit` and every range in internal yards. The
1100/// set is validated with [`validate_truing_observations`] before any fitting.
1101#[allow(
1102    clippy::too_many_arguments,
1103    reason = "flat arguments mirror the stable true-velocity CLI command shape"
1104)]
1105pub fn run_multi_observation_truing_core(
1106    observations: &[TruingObservation],
1107    drop_unit: DropUnit,
1108    bc_input: f64,
1109    drag_model: DragModelArg,
1110    mass_gr: f64,
1111    diameter_in: f64,
1112    zero_yd: f64,
1113    sight_in: f64,
1114    temp_f: f64,
1115    press_inhg: f64,
1116    humidity: f64,
1117    alt_ft: f64,
1118    bc_segments: &Option<Vec<BCSegmentData>>,
1119) -> Result<MultiTruingReport, Box<dyn Error>> {
1120    // Validate: finite, positive range, non-zero drop, no duplicate ranges.
1121    validate_truing_observations(observations)?;
1122    let observations: Vec<TruingObservation> = observations.to_vec();
1123
1124    let model = TruingForwardModel {
1125        drag_model,
1126        mass_gr,
1127        diameter_in,
1128        zero_yd,
1129        sight_in,
1130        temp_f,
1131        press_inhg,
1132        humidity,
1133        alt_ft,
1134        bc_segments,
1135        drop_unit,
1136    };
1137
1138    // Step 1: MV-only fit holding BC at the supplied value. This is always
1139    // well-posed and gives a good operating point for the identifiability check
1140    // and (if BC is identifiable) the joint fit.
1141    let mv_init = (TRUING_MV_MIN_FPS + TRUING_MV_MAX_FPS) / 2.0;
1142    let (mv0, mv_iters, mv_conv) = fit_truing_mv_only(&model, &observations, bc_input, mv_init)?;
1143    let rms_mv_only = rms_at(&model, &observations, mv0, bc_input)?;
1144
1145    // Step 2: is BC identifiable from this observation set?
1146    let (sensitivity_ratio, condition_number) =
1147        truing_identifiability(&model, &observations, mv0, bc_input)?;
1148    let bc_identifiable = sensitivity_ratio >= TRUING_MIN_BC_SENSITIVITY_RATIO
1149        && condition_number <= TRUING_MAX_CONDITION_NUMBER
1150        && condition_number.is_finite();
1151
1152    // Step 3: joint fit when identifiable, with a guard against a worse or
1153    // out-of-bounds result (never report a garbage joint fit).
1154    let mut fitted_mv = mv0;
1155    let mut fitted_bc = bc_input;
1156    let mut bc_fitted = false;
1157    let mut iterations = mv_iters;
1158    // Start from the MV-only fitter's own flag (MBA-1337 t2): both MV-only outcomes
1159    // (gate-refused joint, or joint rejected as worse) report THIS fit's convergence.
1160    // The accepted-joint branch overwrites it with the joint fitter's flag.
1161    let mut converged = mv_conv;
1162    let mut reason = String::new();
1163
1164    if bc_identifiable {
1165        let (mv_j, bc_j, iters_j, conv_j) =
1166            fit_truing_joint(&model, &observations, mv0, bc_input)?;
1167        let rms_joint = rms_at(&model, &observations, mv_j, bc_j)?;
1168        let bc_at_bound = bc_j <= TRUING_BC_MIN * 1.001 || bc_j >= TRUING_BC_MAX * 0.999;
1169        if !bc_at_bound && rms_joint <= rms_mv_only + 1e-9 {
1170            fitted_mv = mv_j;
1171            fitted_bc = bc_j;
1172            bc_fitted = true;
1173            iterations = iters_j;
1174            converged = conv_j;
1175        } else {
1176            // Joint fit did not help (or ran to a bound): keep the honest
1177            // MV-only answer rather than a false-precision BC.
1178            reason = if bc_at_bound {
1179                format!(
1180                    "joint fit drove BC to a bound ({bc_j:.3}); BC held at input {bc_input:.3}"
1181                )
1182            } else {
1183                format!(
1184                    "joint fit did not improve on the MV-only solution; BC held at input {bc_input:.3}"
1185                )
1186            };
1187        }
1188    } else {
1189        reason = if !condition_number.is_finite() || condition_number > TRUING_MAX_CONDITION_NUMBER
1190        {
1191            format!(
1192                "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}"
1193            )
1194        } else {
1195            format!(
1196                "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."
1197            )
1198        };
1199    }
1200
1201    // Final residuals at the reported parameters. Alongside the display-unit RMS,
1202    // accumulate a mil-equivalent RMS: the quality bands were calibrated in mil
1203    // (~0.03 mil observation noise), so banding must not shift with --drop-unit
1204    // (MBA-1337 t1). Reported numbers stay in the user's unit.
1205    let mut predicted = Vec::with_capacity(observations.len());
1206    let mut residuals = Vec::with_capacity(observations.len());
1207    let mut sse = 0.0;
1208    let mut sse_mil = 0.0;
1209    for o in &observations {
1210        let p = model.predict(fitted_mv, fitted_bc, o.range_yd)?;
1211        let r = p - o.drop;
1212        let r_mil = match drop_unit {
1213            DropUnit::Mil => r,
1214            // moa -> mil: divide by (180/pi)*60/1000 moa-per-mil.
1215            DropUnit::Moa => r / ((180.0 / std::f64::consts::PI) * 60.0 / 1000.0),
1216            // inches -> meters -> small-angle mil at this observation's range.
1217            DropUnit::In => r * 0.0254 / (o.range_yd * 0.9144) * 1000.0,
1218        };
1219        predicted.push(p);
1220        residuals.push(r);
1221        sse += r * r;
1222        sse_mil += r_mil * r_mil;
1223    }
1224    let rms = (sse / observations.len() as f64).sqrt();
1225    let rms_mil = (sse_mil / observations.len() as f64).sqrt();
1226
1227    let quality = truing_quality_line(
1228        bc_fitted,
1229        rms,
1230        rms_mil,
1231        drop_unit,
1232        condition_number,
1233        converged,
1234        observations.len(),
1235    );
1236
1237    let report = MultiTruingReport {
1238        fitted_mv_fps: fitted_mv,
1239        fitted_bc,
1240        bc_input,
1241        bc_fitted,
1242        observations,
1243        predicted,
1244        residuals,
1245        rms,
1246        iterations,
1247        converged,
1248        sensitivity_ratio,
1249        condition_number,
1250        quality,
1251        reason,
1252    };
1253
1254    Ok(report)
1255}
1256
1257/// RMS of residuals at a candidate `(mv, bc)`.
1258pub(crate) fn rms_at(
1259    model: &TruingForwardModel<'_>,
1260    obs: &[TruingObservation],
1261    mv: f64,
1262    bc: f64,
1263) -> Result<f64, Box<dyn Error>> {
1264    let mut sse = 0.0;
1265    for o in obs {
1266        let r = model.predict(mv, bc, o.range_yd)? - o.drop;
1267        sse += r * r;
1268    }
1269    Ok((sse / obs.len() as f64).sqrt())
1270}
1271
1272/// Plain-language quality assessment for the fit. `rms` is in the user's drop unit
1273/// (displayed); `rms_mil` is the mil-equivalent the bands were calibrated against
1274/// (~0.03 mil observation noise), so the quality word is unit-invariant (MBA-1337 t1).
1275pub(crate) fn truing_quality_line(
1276    bc_fitted: bool,
1277    rms: f64,
1278    rms_mil: f64,
1279    drop_unit: DropUnit,
1280    condition_number: f64,
1281    converged: bool,
1282    n_obs: usize,
1283) -> String {
1284    let unit = drop_unit.label();
1285    let n_params = if bc_fitted { 2 } else { 1 };
1286    // Exactly-determined fit (MBA-1337 t3): zero degrees of freedom drive the
1287    // residuals to ~0 by construction — an "excellent" RMS validates nothing.
1288    if n_obs == n_params {
1289        return format!(
1290            "{} fit is exactly determined ({n_obs} observations, {n_params} fitted \
1291             parameters): residuals are zero by construction and do not validate the \
1292             fit; add an observation to assess quality",
1293            if bc_fitted { "Joint MV+BC" } else { "MV-only" }
1294        );
1295    }
1296    let quality = if rms_mil < 0.05 {
1297        "excellent"
1298    } else if rms_mil < 0.15 {
1299        "good"
1300    } else if rms_mil < 0.4 {
1301        "fair"
1302    } else {
1303        "poor (observations may be inconsistent)"
1304    };
1305    let nonconv = if converged { "" } else { " (did not fully converge)" };
1306    if bc_fitted {
1307        let cond = if condition_number.is_finite() {
1308            format!("{condition_number:.0}")
1309        } else {
1310            "inf".to_string()
1311        };
1312        format!(
1313            "Joint MV+BC fit, {quality}: RMS residual {rms:.3} {unit}, conditioning {cond}{nonconv}"
1314        )
1315    } else {
1316        format!("MV-only fit, {quality}: RMS residual {rms:.3} {unit} (BC held fixed){nonconv}")
1317    }
1318}