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 crate::cli_api::UnitSystem;
14use crate::{
15    AtmosphericConditions, BCSegmentData, BallisticInputs, DragModel, TrajectorySolver,
16    WindConditions,
17};
18
19/// Drag-model selector for the truing commands, in the shape the CLI exposes
20/// (a plain G1/G7 choice, lowercase-parsed by clap/the WASM terminal). It maps
21/// onto the engine's [`DragModel`] inside the solvers; unlike [`DragModel`] it
22/// deliberately offers only the two standard models the truing forward model
23/// supports.
24#[derive(Debug, Clone, Copy, PartialEq)]
25#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
26pub enum DragModelArg {
27    G1,
28    G7,
29}
30
31/// Unit in which observed drops are supplied to (and residuals reported from)
32/// `true-velocity`'s multi-observation joint calibration (MBA-1316). The
33/// historical single-observation path is always MIL, so `mil` is the default and
34/// leaves that path byte-identical.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36#[cfg_attr(feature = "cli", derive(clap::ValueEnum))]
37pub enum DropUnit {
38    /// Milliradians (angular)
39    Mil,
40    /// Minutes of angle (angular, true 1/60°)
41    Moa,
42    /// Inches below line of sight (linear drop at the target)
43    In,
44}
45
46impl DropUnit {
47    /// Short label used in tables/JSON/CSV.
48    pub fn label(self) -> &'static str {
49        match self {
50            DropUnit::Mil => "mil",
51            DropUnit::Moa => "moa",
52            DropUnit::In => "in",
53        }
54    }
55
56    /// Parse a drop-unit token (`"mil"` | `"moa"` | `"in"`, case-insensitive)
57    /// without going through clap, for non-CLI front ends (e.g. the WASM
58    /// terminal parser).
59    pub fn parse(s: &str) -> Result<Self, String> {
60        match s.to_ascii_lowercase().as_str() {
61            "mil" => Ok(DropUnit::Mil),
62            "moa" => Ok(DropUnit::Moa),
63            "in" => Ok(DropUnit::In),
64            _ => Err(format!("invalid drop unit '{s}': expected mil, moa, or in")),
65        }
66    }
67
68    /// Express a linear vertical drop below the line of sight (`drop_m`, meters)
69    /// at horizontal distance `z_m` (meters) in this unit. Angular units use the
70    /// same small-angle convention the engine's MIL output has always used
71    /// (`drop/range`), so `mil` matches the legacy single-observation contract
72    /// exactly; `moa` is true minutes of angle and `in` is the linear drop itself.
73    pub fn express_drop_m(self, drop_m: f64, z_m: f64) -> f64 {
74        match self {
75            // (drop_m / z_m) radians * 1000 mrad/rad
76            DropUnit::Mil => (drop_m / z_m) * 1000.0,
77            // (drop_m / z_m) radians * (180/pi) deg/rad * 60 min/deg
78            DropUnit::Moa => (drop_m / z_m) * (180.0 / std::f64::consts::PI) * 60.0,
79            // linear drop, meters -> inches
80            DropUnit::In => drop_m / 0.0254,
81        }
82    }
83}
84
85/// Resolve a bullet length (meters) for a bullet whose length the shooter did not supply (MBA-1135).
86///
87/// Prefers the mass-based physical estimate ([`crate::stability::estimate_bullet_length_m`]),
88/// falling back to the historical 4.5-caliber heuristic only when mass is unavailable so the caller
89/// always gets a positive length. `diameter_m` and `mass_kg` are SI.
90pub fn fallback_bullet_length_m(diameter_m: f64, mass_kg: f64) -> f64 {
91    let est = crate::stability::estimate_bullet_length_m(diameter_m, mass_kg);
92    if est > 0.0 {
93        est
94    } else {
95        diameter_m * 4.5
96    }
97}
98
99/// Shared forward-model core for velocity/BC truing (MBA-1316).
100///
101/// Solves the real trajectory for the given `(velocity_fps, bc)` candidate under
102/// the supplied load/atmosphere and returns `(drop_m, z_m)` where `drop_m` is the
103/// linear vertical distance below the line of sight (positive = below LOS) at the
104/// target range and `z_m` is the horizontal distance actually reached (~range_m).
105/// Callers convert to MIL / MOA / inches as needed. This is the exact assembly
106/// the single-observation binary search has always used, factored out so the
107/// multi-observation joint fit reuses identical physics.
108///
109/// When `interpolate` is false the returned point is the first trajectory sample
110/// at or past the target range (the historical behaviour). When true, the drop
111/// and horizontal distance are linearly interpolated to land exactly on the
112/// target range, removing the ~v*dt spatial quantization that otherwise
113/// stair-steps the cost surface — essential for the multi-observation joint fit,
114/// whose finite-difference Jacobian would otherwise be dominated by that noise.
115#[allow(
116    clippy::too_many_arguments,
117    reason = "flat arguments preserve the existing velocity-truing compatibility helper"
118)]
119pub(crate) fn solve_trajectory_drop(
120    velocity_fps: f64,
121    bc: f64,
122    drag_model: DragModelArg,
123    mass_gr: f64,
124    diameter_in: f64,
125    zero_distance_yd: f64,
126    range_yd: f64,
127    sight_height_in: f64,
128    temperature_f: f64,
129    pressure_inhg: f64,
130    humidity: f64,
131    altitude_ft: f64,
132    bc_segments: &Option<Vec<BCSegmentData>>,
133    interpolate: bool,
134) -> Result<(f64, f64), Box<dyn Error>> {
135    // Convert to SI units
136    let velocity_ms = velocity_fps * 0.3048;
137    let mass_kg = mass_gr * 0.0000647989;
138    let diameter_m = diameter_in * 0.0254;
139    let zero_m = zero_distance_yd * 0.9144;
140    let range_m = range_yd * 0.9144;
141    let sight_height_m = sight_height_in * 0.0254;
142    let altitude_m = altitude_ft * 0.3048;
143    let temperature_c = (temperature_f - 32.0) * 5.0 / 9.0;
144    let pressure_hpa = pressure_inhg * 33.8639; // Convert inHg to hPa
145
146    let drag_model_enum = match drag_model {
147        DragModelArg::G1 => DragModel::G1,
148        DragModelArg::G7 => DragModel::G7,
149    };
150
151    // Create base inputs - match defaults used by trajectory command
152    let mut inputs = BallisticInputs {
153        muzzle_velocity: velocity_ms,
154        bc_value: bc,
155        bc_type: drag_model_enum,
156        bullet_mass: mass_kg,
157        bullet_diameter: diameter_m,
158        bullet_length: fallback_bullet_length_m(diameter_m, mass_kg), // MBA-1135 mass-based estimate
159        sight_height: sight_height_m,
160        target_distance: range_m + 100.0, // Overshoot to ensure we have data
161        use_bc_segments: bc_segments.is_some(),
162        bc_segments_data: bc_segments.clone(),
163        use_rk4: true,
164        muzzle_angle: 0.0,    // Will be set by zero angle calculation
165        ..Default::default()  // Uses muzzle_height: 0.0 by default
166    };
167
168    // Set up atmospheric conditions
169    // AtmosphericConditions expects: temperature in Celsius, pressure in hPa, humidity 0-100, altitude in meters
170    let atmosphere = AtmosphericConditions {
171        temperature: temperature_c,
172        pressure: pressure_hpa,
173        humidity, // Already 0-100 from input
174        altitude: altitude_m,
175    };
176
177    let wind = WindConditions::default();
178
179    // Calculate zero angle for the zero distance
180    // Target height is sight_height because the bullet must cross the LOS at zero distance
181    // The LOS is at y = sight_height (sight is above bore by sight_height)
182    // So the bullet (starting at y = 0 = bore level) must rise to y = sight_height at zero distance
183    let zero_angle = crate::calculate_zero_angle_with_conditions(
184        inputs.clone(),
185        zero_m,
186        sight_height_m, // target height at zero distance (LOS height)
187        wind.clone(),
188        atmosphere.clone(),
189    )?;
190
191    // Set the calculated zero angle
192    inputs.muzzle_angle = zero_angle;
193
194    // Create solver and solve
195    let mut solver = TrajectorySolver::new(inputs, wind, atmosphere);
196    solver.set_max_range(range_m + 100.0);
197    solver.set_time_step(0.0001);
198
199    let result = solver.solve()?;
200
201    // Find the first point at or past the target range.
202    let idx = result
203        .points
204        .iter()
205        .position(|p| p.position.x >= range_m)
206        .ok_or("Trajectory didn't reach target range")?;
207
208    // Determine the horizontal distance z and bullet height at the target range.
209    let (z, bullet_y) = if interpolate && idx > 0 {
210        // Linearly interpolate between the bracketing samples to land exactly on
211        // range_m (removes ~v*dt spatial quantization).
212        let p0 = &result.points[idx - 1];
213        let p1 = &result.points[idx];
214        let x0 = p0.position.x;
215        let x1 = p1.position.x;
216        let denom = x1 - x0;
217        if denom.abs() < f64::EPSILON {
218            (p1.position.x, p1.position.y)
219        } else {
220            let frac = (range_m - x0) / denom;
221            let y = p0.position.y + frac * (p1.position.y - p0.position.y);
222            (range_m, y)
223        }
224    } else {
225        let p = &result.points[idx];
226        (p.position.x, p.position.y)
227    };
228
229    // Calculate drop relative to the line of sight. The trajectory was already launched at the
230    // solved zero angle, so the LOS for this zero solve is horizontal at sight_height_m.
231    // Drop = LOS height - bullet position (positive = below LOS)
232    let drop_m = sight_height_m - bullet_y;
233
234    Ok((drop_m, z))
235}
236
237/// Calculate drop at a given muzzle velocity using trajectory solver
238/// Returns drop in MILs at the target range
239#[allow(
240    clippy::too_many_arguments,
241    reason = "flat arguments preserve the existing velocity-truing compatibility helper"
242)]
243pub(crate) fn calculate_drop_at_velocity(
244    velocity_fps: f64,
245    bc: f64,
246    drag_model: DragModelArg,
247    mass_gr: f64,
248    diameter_in: f64,
249    zero_distance_yd: f64,
250    range_yd: f64,
251    sight_height_in: f64,
252    temperature_f: f64,
253    pressure_inhg: f64,
254    humidity: f64,
255    altitude_ft: f64,
256    bc_segments: &Option<Vec<BCSegmentData>>,
257) -> Result<f64, Box<dyn Error>> {
258    // Preserve the historical MIL contract by delegating to the shared linear-drop
259    // core (MBA-1316). `interpolate = false` reproduces the original "first point
260    // at or past the range" sampling, so this path stays byte-identical.
261    let (drop_m, z) = solve_trajectory_drop(
262        velocity_fps,
263        bc,
264        drag_model,
265        mass_gr,
266        diameter_in,
267        zero_distance_yd,
268        range_yd,
269        sight_height_in,
270        temperature_f,
271        pressure_inhg,
272        humidity,
273        altitude_ft,
274        bc_segments,
275        false,
276    )?;
277
278    // Convert to MILs: mil = (drop_inches / 36 / range_yards) * 1000
279    // Or equivalently: mil = (drop_m / range_m) * 1000
280    let drop_mil = (drop_m / z) * 1000.0;
281
282    Ok(drop_mil)
283}
284
285/// Result of the classic single-observation velocity truing
286/// ([`calculate_true_velocity_local`]).
287#[derive(Debug, Clone)]
288pub struct TrueVelocityLocalResult {
289    /// The fitted effective muzzle velocity, in feet per second.
290    pub effective_velocity_fps: f64,
291    /// Number of binary-search iterations actually run.
292    pub iterations: i32,
293    /// Signed residual at the returned velocity, in MIL:
294    /// `calculated_drop_mil - measured_drop_mil`. Positive means the model still
295    /// predicts MORE drop than was measured at the returned velocity; negative
296    /// means less.
297    pub final_error_mil: f64,
298    /// The model-predicted drop (MIL) at the returned velocity.
299    pub calculated_drop_mil: f64,
300    /// Convergence quality: `"high"` (converged, |error| < 0.005 mil),
301    /// `"medium"` (converged within the 0.01 mil tolerance, or stopped early
302    /// with |error| < 0.1 mil), or `"low"` (did not converge; |error| >= 0.1 mil).
303    pub confidence: String,
304}
305
306/// Calculate the effective muzzle velocity that reproduces `measured_drop_mil`
307/// at `range_yd`, via binary search over the real trajectory solver
308/// (the classic single-observation `true-velocity` path).
309///
310/// Returns a [`TrueVelocityLocalResult`] carrying the fitted velocity (fps),
311/// the iteration count, the signed final residual in MIL (positive = the model
312/// still predicts more drop than measured), the model-predicted drop at the
313/// returned velocity, and a `"high"`/`"medium"`/`"low"` confidence label — see
314/// the field docs on [`TrueVelocityLocalResult`] for the exact banding.
315///
316/// Errors on degenerate inputs (`range_yd` non-positive or non-finite,
317/// `measured_drop_mil` non-finite) and on any trajectory-solver failure.
318#[allow(
319    clippy::too_many_arguments,
320    reason = "flat arguments mirror the stable true-velocity CLI command shape"
321)]
322pub fn calculate_true_velocity_local(
323    measured_drop_mil: f64,
324    range_yd: f64,
325    bc: f64,
326    drag_model: DragModelArg,
327    mass_gr: f64,
328    diameter_in: f64,
329    zero_distance_yd: f64,
330    sight_height_in: f64,
331    temperature_f: f64,
332    pressure_inhg: f64,
333    humidity: f64,
334    altitude_ft: f64,
335    bc_segments: &Option<Vec<BCSegmentData>>,
336) -> Result<TrueVelocityLocalResult, Box<dyn Error>> {
337    // Reject degenerate inputs instead of letting NaN/inf flow through the
338    // solver and come back as Ok(NaN). The native CLI's clap range validators
339    // cannot produce these; direct library / WASM callers can.
340    if !range_yd.is_finite() || range_yd <= 0.0 {
341        return Err("range must be positive and finite".into());
342    }
343    if !measured_drop_mil.is_finite() {
344        return Err("measured drop must be finite".into());
345    }
346
347    // Binary search between velocity bounds
348    let mut velocity_low = 1500.0;
349    let mut velocity_high = 4500.0;
350    let tolerance_mil = 0.01; // 0.01 MIL tolerance
351    let max_iterations = 50;
352
353    let mut iterations = 0;
354    let mut last_error = 0.0;
355    let mut last_calculated_drop = 0.0;
356
357    for i in 0..max_iterations {
358        iterations = i + 1;
359        let test_velocity = (velocity_low + velocity_high) / 2.0;
360
361        // Run trajectory at test velocity
362        let calculated_drop_mil = calculate_drop_at_velocity(
363            test_velocity,
364            bc,
365            drag_model,
366            mass_gr,
367            diameter_in,
368            zero_distance_yd,
369            range_yd,
370            sight_height_in,
371            temperature_f,
372            pressure_inhg,
373            humidity,
374            altitude_ft,
375            bc_segments,
376        )?;
377
378        last_calculated_drop = calculated_drop_mil;
379        let error = calculated_drop_mil - measured_drop_mil;
380        last_error = error;
381
382        if error.abs() < tolerance_mil {
383            // Converged
384            let confidence = if error.abs() < 0.005 {
385                "high"
386            } else {
387                "medium"
388            };
389
390            return Ok(TrueVelocityLocalResult {
391                effective_velocity_fps: test_velocity,
392                iterations,
393                final_error_mil: error,
394                calculated_drop_mil,
395                confidence: confidence.to_string(),
396            });
397        }
398
399        // Higher calculated drop = bullet is slower = need higher velocity
400        // Lower calculated drop = bullet is faster = need lower velocity
401        if calculated_drop_mil > measured_drop_mil {
402            // Bullet dropping more than observed = slower than actual
403            // Need higher velocity
404            velocity_low = test_velocity;
405        } else {
406            // Bullet dropping less = faster than actual
407            // Need lower velocity
408            velocity_high = test_velocity;
409        }
410
411        // Check for convergence issues
412        if (velocity_high - velocity_low).abs() < 0.5 {
413            break;
414        }
415    }
416
417    // Did not converge within tolerance, return best estimate
418    let final_velocity = (velocity_low + velocity_high) / 2.0;
419    let confidence = if last_error.abs() < 0.1 {
420        "medium"
421    } else {
422        "low"
423    };
424
425    Ok(TrueVelocityLocalResult {
426        effective_velocity_fps: final_velocity,
427        iterations,
428        final_error_mil: last_error,
429        calculated_drop_mil: last_calculated_drop,
430        confidence: confidence.to_string(),
431    })
432}
433
434// ============================================================================
435// MBA-1316: multi-observation joint MV + BC calibration (truing v2)
436// ============================================================================
437//
438// The classic `true-velocity` path fits muzzle velocity from a single observed
439// drop while BC is held fixed. Mid-range (fully supersonic) drops are dominated
440// by time of flight and therefore constrain muzzle velocity; long-range /
441// transonic drops are where BC bites. With observations spread across those
442// regimes we can separate the two. When the spread is too narrow to tell them
443// apart we refuse the joint fit and fit muzzle velocity only, saying so.
444
445// Solver / fit bounds and identifiability gates. See each constant's doc; the
446// empirical basis for the gate values (calibrated against real .30-cal data,
447// MBA-1316):
448//   * 300/600/900 -> sens 0.29, cond 112 -> BC recovered to ~2%   (JOINT)
449//   * 300/600/900/1000 -> sens 0.33, cond 214 -> BC to <0.1%      (JOINT)
450//   * 300/400/500 -> sens 0.16, cond 282 -> BC error ~3%          (MV-ONLY)
451//   * 200/250/300 -> sens 0.12, cond 2337 -> BC error ~12%        (MV-ONLY)
452// (.308 168gr @ 2700/0.475, ~0.03 mil observation noise.) The gates sit between
453// the "recovers to ~2%" band and the "several-percent garbage" band. They are
454// heuristics: a set that just clears them still carries more BC uncertainty
455// than a set that clears them comfortably.
456
457/// Lower muzzle-velocity fit bound (fps): brackets subsonic pistol loads.
458pub(crate) const TRUING_MV_MIN_FPS: f64 = 1000.0;
459/// Upper muzzle-velocity fit bound (fps): brackets hyper-velocity varmint loads.
460pub(crate) const TRUING_MV_MAX_FPS: f64 = 5000.0;
461/// Lower BC fit bound; matches the CLI's `--bc` validator minimum.
462pub(crate) const TRUING_BC_MIN: f64 = 0.05;
463/// Upper BC fit bound; matches the CLI's `--bc` validator maximum.
464pub(crate) const TRUING_BC_MAX: f64 = 2.0;
465/// Iteration cap shared by the MV-only and joint fitters.
466pub(crate) const TRUING_MAX_ITERS: usize = 40;
467
468/// Identifiability gate: minimum
469/// `sensitivity_ratio = ||bc*d(drop)/d(bc)|| / ||mv*d(drop)/d(mv)||` — how much
470/// a fractional BC change moves the predicted drops relative to a fractional MV
471/// change. Below this, the observations barely constrain BC and the joint fit
472/// is refused (MV-only fallback).
473pub(crate) const TRUING_MIN_BC_SENSITIVITY_RATIO: f64 = 0.20;
474/// Identifiability gate: maximum `condition_number = (1+|c|)/(1-|c|)` (|c| =
475/// correlation of the raw MV/BC Jacobian columns). Above this the two columns
476/// are collinear — a BC change can be undone by an MV change — and the joint
477/// fit is refused (MV-only fallback). Both this gate and
478/// [`TRUING_MIN_BC_SENSITIVITY_RATIO`] must pass to attempt the joint fit.
479pub(crate) const TRUING_MAX_CONDITION_NUMBER: f64 = 1.0e3;
480
481/// A single observed impact used for truing: range (internal yards) and the
482/// measured drop below line of sight expressed in the caller's drop unit.
483#[derive(Debug, Clone, Copy)]
484pub struct TruingObservation {
485    pub range_yd: f64,
486    pub drop: f64,
487}
488
489/// Parse an `--observed RANGE:DROP` token. RANGE is in the caller's distance
490/// units (yards imperial / meters metric) and is normalized to internal yards;
491/// DROP stays in the caller's drop unit. Returns a user-facing error string on
492/// malformed input so the CLI can report cleanly instead of panicking.
493pub fn parse_truing_observation(s: &str, units: UnitSystem) -> Result<TruingObservation, String> {
494    let parts: Vec<&str> = s.split(':').collect();
495    if parts.len() != 2 {
496        return Err(format!(
497            "invalid --observed '{s}': expected RANGE:DROP (e.g. 600:5.1)"
498        ));
499    }
500    let range: f64 = parts[0]
501        .trim()
502        .parse()
503        .map_err(|_| format!("invalid --observed range '{}' in '{s}'", parts[0]))?;
504    let drop: f64 = parts[1]
505        .trim()
506        .parse()
507        .map_err(|_| format!("invalid --observed drop '{}' in '{s}'", parts[1]))?;
508    if !range.is_finite() || !drop.is_finite() {
509        return Err(format!("invalid --observed '{s}': values must be finite"));
510    }
511    let range_yd = match units {
512        UnitSystem::Imperial => range,
513        UnitSystem::Metric => range / 0.9144,
514    };
515    Ok(TruingObservation { range_yd, drop })
516}
517
518/// Validate a truing observation set: every range finite and positive, every
519/// drop finite and non-zero, and no two observations at (numerically) the same
520/// range. [`run_multi_observation_truing_core`] runs this itself, so callers
521/// don't have to — it is public so a front end can pre-validate when it wants
522/// to sequence its own progress output strictly after validation (the native
523/// CLI prints its "Fitting N observations..." progress line only for sets that
524/// will actually be fitted). Error strings are the stable user-facing ones.
525pub fn validate_truing_observations(observations: &[TruingObservation]) -> Result<(), String> {
526    for o in observations {
527        if !o.range_yd.is_finite() || o.range_yd <= 0.0 {
528            return Err(format!(
529                "observation range must be a positive finite distance (got {})",
530                o.range_yd
531            ));
532        }
533        if !o.drop.is_finite() || o.drop == 0.0 {
534            return Err(
535                "observation drop must be non-zero (a zero drop carries no truing information)"
536                    .to_string(),
537            );
538        }
539    }
540    for i in 0..observations.len() {
541        for j in (i + 1)..observations.len() {
542            if (observations[i].range_yd - observations[j].range_yd).abs() < 1e-6 {
543                return Err(format!(
544                    "duplicate observation range ({:.3} yd internal): each observation must be at a distinct range",
545                    observations[i].range_yd
546                ));
547            }
548        }
549    }
550    Ok(())
551}
552
553/// Fixed load / atmosphere for a truing fit. The two free parameters (muzzle
554/// velocity and BC) are supplied per prediction so the fitter can vary them.
555pub(crate) struct TruingForwardModel<'a> {
556    pub drag_model: DragModelArg,
557    pub mass_gr: f64,
558    pub diameter_in: f64,
559    pub zero_yd: f64,
560    pub sight_in: f64,
561    pub temp_f: f64,
562    pub press_inhg: f64,
563    pub humidity: f64,
564    pub alt_ft: f64,
565    pub bc_segments: &'a Option<Vec<BCSegmentData>>,
566    pub drop_unit: DropUnit,
567}
568
569impl TruingForwardModel<'_> {
570    /// Predicted drop (in the configured drop unit) at `range_yd` for the given
571    /// muzzle velocity and BC, using the real trajectory solver.
572    pub fn predict(&self, mv_fps: f64, bc: f64, range_yd: f64) -> Result<f64, Box<dyn Error>> {
573        self.predict_in_unit(mv_fps, bc, range_yd, self.drop_unit)
574    }
575
576    /// `predict` expressed in an explicit unit, independent of the configured
577    /// `--drop-unit`. The identifiability diagnostics use this to stay in mil space
578    /// (MBA-1337 t1): the linear `in` unit weights each Jacobian row by its range,
579    /// which shifts the column correlation. NOTE this makes the gate DIAGNOSTICS
580    /// unit-invariant; the MV-only operating point and the least-squares cost are
581    /// still minimized in the display unit (deliberately out of scope — changing
582    /// them changes fitted numbers), so extreme near-boundary sets can still differ.
583    pub fn predict_in_unit(
584        &self,
585        mv_fps: f64,
586        bc: f64,
587        range_yd: f64,
588        unit: DropUnit,
589    ) -> Result<f64, Box<dyn Error>> {
590        let (drop_m, z_m) = solve_trajectory_drop(
591            mv_fps,
592            bc,
593            self.drag_model,
594            self.mass_gr,
595            self.diameter_in,
596            self.zero_yd,
597            range_yd,
598            self.sight_in,
599            self.temp_f,
600            self.press_inhg,
601            self.humidity,
602            self.alt_ft,
603            self.bc_segments,
604            true, // interpolate: smooth forward model for the fitter
605        )?;
606        Ok(unit.express_drop_m(drop_m, z_m))
607    }
608
609    /// Sum of squared residuals (predicted - observed) over all observations.
610    pub fn cost(&self, mv: f64, bc: f64, obs: &[TruingObservation]) -> Result<f64, Box<dyn Error>> {
611        let mut c = 0.0;
612        for o in obs {
613            let r = self.predict(mv, bc, o.range_yd)? - o.drop;
614            c += r * r;
615        }
616        Ok(c)
617    }
618}
619
620/// One-parameter (muzzle velocity) least-squares fit with BC held fixed. Damped
621/// Gauss-Newton with a central finite-difference derivative; robust because drop
622/// is monotonic in muzzle velocity. Returns `(mv_fps, iterations, converged)`.
623pub(crate) fn fit_truing_mv_only(
624    model: &TruingForwardModel<'_>,
625    obs: &[TruingObservation],
626    bc: f64,
627    mv_init: f64,
628) -> Result<(f64, usize, bool), Box<dyn Error>> {
629    let mut mv = mv_init.clamp(TRUING_MV_MIN_FPS, TRUING_MV_MAX_FPS);
630    let mut converged = false;
631    let mut iters = 0;
632    for i in 0..TRUING_MAX_ITERS {
633        iters = i + 1;
634        let h = (mv * 1e-3).max(0.5);
635        let mut num = 0.0;
636        let mut den = 0.0;
637        for o in obs {
638            let r = model.predict(mv, bc, o.range_yd)? - o.drop;
639            let dp = model.predict(mv + h, bc, o.range_yd)?;
640            let dm = model.predict(mv - h, bc, o.range_yd)?;
641            let j = (dp - dm) / (2.0 * h);
642            num += j * r;
643            den += j * j;
644        }
645        if den < 1e-12 {
646            break;
647        }
648        // Gauss-Newton step, limited to keep the solver in a sane regime.
649        let step = (-num / den).clamp(-300.0, 300.0);
650        let new_mv = (mv + step).clamp(TRUING_MV_MIN_FPS, TRUING_MV_MAX_FPS);
651        if (new_mv - mv).abs() < 0.05 {
652            mv = new_mv;
653            converged = true;
654            break;
655        }
656        mv = new_mv;
657    }
658    Ok((mv, iters, converged))
659}
660
661/// Identifiability diagnostics for BC at the operating point `(mv, bc)`.
662///
663/// Returns `(sensitivity_ratio, condition_number)`:
664///
665/// * `sensitivity_ratio = ||bc*d(drop)/d(bc)|| / ||mv*d(drop)/d(mv)||` — the
666///   relative influence of a fractional BC change vs a fractional MV change on
667///   the predicted drops. Small => BC barely moves the drops (weak-signal mode).
668///
669/// * `condition_number = (1+|c|)/(1-|c|)` where `c` is the correlation between
670///   the raw MV and BC Jacobian columns. Large => the two columns point the same
671///   way, so a BC change can be undone by an MV change and the pair is not
672///   separable (collinearity mode). This is magnitude-independent, unlike the
673///   scaled-normal-matrix condition, so it actually tracks observation spread.
674pub(crate) fn truing_identifiability(
675    model: &TruingForwardModel<'_>,
676    obs: &[TruingObservation],
677    mv: f64,
678    bc: f64,
679) -> Result<(f64, f64), Box<dyn Error>> {
680    let hmv = (mv * 1e-3).max(0.5);
681    let hbc = (bc * 1e-3).max(1e-4);
682    let (mut n_mv, mut n_bc, mut cross) = (0.0, 0.0, 0.0);
683    // Differentiate in mil space regardless of --drop-unit (MBA-1337 t1) so these
684    // gate diagnostics do not shift with the display unit. (The operating point mv
685    // comes from a display-unit fit, so full invariance is not guaranteed — see
686    // predict_in_unit's note.)
687    let unit = DropUnit::Mil;
688    for o in obs {
689        let jmv = (model.predict_in_unit(mv + hmv, bc, o.range_yd, unit)?
690            - model.predict_in_unit(mv - hmv, bc, o.range_yd, unit)?)
691            / (2.0 * hmv);
692        let jbc = (model.predict_in_unit(mv, bc + hbc, o.range_yd, unit)?
693            - model.predict_in_unit(mv, bc - hbc, o.range_yd, unit)?)
694            / (2.0 * hbc);
695        n_mv += jmv * jmv;
696        n_bc += jbc * jbc;
697        cross += jmv * jbc;
698    }
699    let norm_mv = n_mv.sqrt();
700    let norm_bc = n_bc.sqrt();
701    let sensitivity_ratio = if mv * norm_mv > 0.0 {
702        (bc * norm_bc) / (mv * norm_mv)
703    } else {
704        0.0
705    };
706    // Correlation between the raw Jacobian columns.
707    let condition_number = if norm_mv > 0.0 && norm_bc > 0.0 {
708        let c = (cross / (norm_mv * norm_bc)).clamp(-1.0, 1.0).abs();
709        if (1.0 - c) > 1e-15 {
710            (1.0 + c) / (1.0 - c)
711        } else {
712            f64::INFINITY
713        }
714    } else {
715        // One column is numerically zero: the missing parameter is unconstrained.
716        f64::INFINITY
717    };
718    Ok((sensitivity_ratio, condition_number))
719}
720
721/// Two-parameter (muzzle velocity, BC) joint fit via Levenberg-Marquardt
722/// (damped Gauss-Newton) with central finite-difference Jacobian. Only the
723/// diagonal of the normal matrix is damped (classic Marquardt scaling). Returns
724/// `(mv_fps, bc, iterations, converged)`.
725pub(crate) fn fit_truing_joint(
726    model: &TruingForwardModel<'_>,
727    obs: &[TruingObservation],
728    mv_init: f64,
729    bc_init: f64,
730) -> Result<(f64, f64, usize, bool), Box<dyn Error>> {
731    let mut mv = mv_init.clamp(TRUING_MV_MIN_FPS, TRUING_MV_MAX_FPS);
732    let mut bc = bc_init.clamp(TRUING_BC_MIN, TRUING_BC_MAX);
733    let mut lambda = 1e-3;
734    let mut cur_cost = model.cost(mv, bc, obs)?;
735    let mut converged = false;
736    let mut iters = 0;
737
738    for it in 0..TRUING_MAX_ITERS {
739        iters = it + 1;
740        let hmv = (mv * 1e-3).max(0.5);
741        let hbc = (bc * 1e-3).max(1e-4);
742        // Build J^T J (a00,a01,a11) and J^T r (g0,g1).
743        let (mut a00, mut a01, mut a11) = (0.0, 0.0, 0.0);
744        let (mut g0, mut g1) = (0.0, 0.0);
745        for o in obs {
746            let r = model.predict(mv, bc, o.range_yd)? - o.drop;
747            let jmv = (model.predict(mv + hmv, bc, o.range_yd)?
748                - model.predict(mv - hmv, bc, o.range_yd)?)
749                / (2.0 * hmv);
750            let jbc = (model.predict(mv, bc + hbc, o.range_yd)?
751                - model.predict(mv, bc - hbc, o.range_yd)?)
752                / (2.0 * hbc);
753            a00 += jmv * jmv;
754            a01 += jmv * jbc;
755            a11 += jbc * jbc;
756            g0 += jmv * r;
757            g1 += jbc * r;
758        }
759
760        // Inner loop: grow lambda until a step reduces the cost.
761        let mut accepted = false;
762        for _ in 0..30 {
763            let m00 = a00 + lambda * a00.max(1e-12);
764            let m11 = a11 + lambda * a11.max(1e-12);
765            let det = m00 * m11 - a01 * a01;
766            if det.abs() < 1e-20 {
767                lambda *= 10.0;
768                continue;
769            }
770            // delta = -(JtJ + lambda*diag)^-1 * Jtr
771            let dmv = -(m11 * g0 - a01 * g1) / det;
772            let dbc = -(-a01 * g0 + m00 * g1) / det;
773            let nmv = (mv + dmv).clamp(TRUING_MV_MIN_FPS, TRUING_MV_MAX_FPS);
774            let nbc = (bc + dbc).clamp(TRUING_BC_MIN, TRUING_BC_MAX);
775            let nc = model.cost(nmv, nbc, obs)?;
776            if nc < cur_cost {
777                let rel_change =
778                    (nmv - mv).abs() / mv.max(1.0) + (nbc - bc).abs() / bc.max(1e-3);
779                mv = nmv;
780                bc = nbc;
781                cur_cost = nc;
782                lambda = (lambda * 0.5).max(1e-9);
783                accepted = true;
784                if rel_change < 1e-6 {
785                    converged = true;
786                }
787                break;
788            }
789            lambda *= 4.0;
790            if lambda > 1e12 {
791                break;
792            }
793        }
794        if !accepted {
795            // No downhill step exists within damping range: we are at (or
796            // numerically indistinguishable from) a local optimum.
797            converged = true;
798            break;
799        }
800        if converged {
801            break;
802        }
803    }
804    Ok((mv, bc, iters, converged))
805}
806
807/// Final report produced by a multi-observation truing fit.
808#[derive(Debug, Clone)]
809pub struct MultiTruingReport {
810    pub fitted_mv_fps: f64,
811    pub fitted_bc: f64,
812    pub bc_input: f64,
813    pub bc_fitted: bool,
814    pub observations: Vec<TruingObservation>,
815    pub predicted: Vec<f64>,
816    pub residuals: Vec<f64>,
817    pub rms: f64,
818    pub iterations: usize,
819    pub converged: bool,
820    pub sensitivity_ratio: f64,
821    pub condition_number: f64,
822    pub quality: String,
823    pub reason: String,
824}
825
826/// Orchestrate the multi-observation joint MV+BC calibration and build the
827/// final [`MultiTruingReport`] (MBA-1343: the compute half only — rendering
828/// stays with the caller).
829///
830/// `observations` is the already-parsed observation set, primary
831/// (`--range`/`--measured-drop`) first, then each `--observed` impact in order
832/// (use [`parse_truing_observation`] to build them from `RANGE:DROP` tokens);
833/// every drop is already in `drop_unit` and every range in internal yards. The
834/// set is validated with [`validate_truing_observations`] before any fitting.
835#[allow(
836    clippy::too_many_arguments,
837    reason = "flat arguments mirror the stable true-velocity CLI command shape"
838)]
839pub fn run_multi_observation_truing_core(
840    observations: &[TruingObservation],
841    drop_unit: DropUnit,
842    bc_input: f64,
843    drag_model: DragModelArg,
844    mass_gr: f64,
845    diameter_in: f64,
846    zero_yd: f64,
847    sight_in: f64,
848    temp_f: f64,
849    press_inhg: f64,
850    humidity: f64,
851    alt_ft: f64,
852    bc_segments: &Option<Vec<BCSegmentData>>,
853) -> Result<MultiTruingReport, Box<dyn Error>> {
854    // Validate: finite, positive range, non-zero drop, no duplicate ranges.
855    validate_truing_observations(observations)?;
856    let observations: Vec<TruingObservation> = observations.to_vec();
857
858    let model = TruingForwardModel {
859        drag_model,
860        mass_gr,
861        diameter_in,
862        zero_yd,
863        sight_in,
864        temp_f,
865        press_inhg,
866        humidity,
867        alt_ft,
868        bc_segments,
869        drop_unit,
870    };
871
872    // Step 1: MV-only fit holding BC at the supplied value. This is always
873    // well-posed and gives a good operating point for the identifiability check
874    // and (if BC is identifiable) the joint fit.
875    let mv_init = (TRUING_MV_MIN_FPS + TRUING_MV_MAX_FPS) / 2.0;
876    let (mv0, mv_iters, mv_conv) = fit_truing_mv_only(&model, &observations, bc_input, mv_init)?;
877    let rms_mv_only = rms_at(&model, &observations, mv0, bc_input)?;
878
879    // Step 2: is BC identifiable from this observation set?
880    let (sensitivity_ratio, condition_number) =
881        truing_identifiability(&model, &observations, mv0, bc_input)?;
882    let bc_identifiable = sensitivity_ratio >= TRUING_MIN_BC_SENSITIVITY_RATIO
883        && condition_number <= TRUING_MAX_CONDITION_NUMBER
884        && condition_number.is_finite();
885
886    // Step 3: joint fit when identifiable, with a guard against a worse or
887    // out-of-bounds result (never report a garbage joint fit).
888    let mut fitted_mv = mv0;
889    let mut fitted_bc = bc_input;
890    let mut bc_fitted = false;
891    let mut iterations = mv_iters;
892    // Start from the MV-only fitter's own flag (MBA-1337 t2): both MV-only outcomes
893    // (gate-refused joint, or joint rejected as worse) report THIS fit's convergence.
894    // The accepted-joint branch overwrites it with the joint fitter's flag.
895    let mut converged = mv_conv;
896    let mut reason = String::new();
897
898    if bc_identifiable {
899        let (mv_j, bc_j, iters_j, conv_j) =
900            fit_truing_joint(&model, &observations, mv0, bc_input)?;
901        let rms_joint = rms_at(&model, &observations, mv_j, bc_j)?;
902        let bc_at_bound = bc_j <= TRUING_BC_MIN * 1.001 || bc_j >= TRUING_BC_MAX * 0.999;
903        if !bc_at_bound && rms_joint <= rms_mv_only + 1e-9 {
904            fitted_mv = mv_j;
905            fitted_bc = bc_j;
906            bc_fitted = true;
907            iterations = iters_j;
908            converged = conv_j;
909        } else {
910            // Joint fit did not help (or ran to a bound): keep the honest
911            // MV-only answer rather than a false-precision BC.
912            reason = if bc_at_bound {
913                format!(
914                    "joint fit drove BC to a bound ({bc_j:.3}); BC held at input {bc_input:.3}"
915                )
916            } else {
917                format!(
918                    "joint fit did not improve on the MV-only solution; BC held at input {bc_input:.3}"
919                )
920            };
921        }
922    } else {
923        reason = if !condition_number.is_finite() || condition_number > TRUING_MAX_CONDITION_NUMBER
924        {
925            format!(
926                "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}"
927            )
928        } else {
929            format!(
930                "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."
931            )
932        };
933    }
934
935    // Final residuals at the reported parameters. Alongside the display-unit RMS,
936    // accumulate a mil-equivalent RMS: the quality bands were calibrated in mil
937    // (~0.03 mil observation noise), so banding must not shift with --drop-unit
938    // (MBA-1337 t1). Reported numbers stay in the user's unit.
939    let mut predicted = Vec::with_capacity(observations.len());
940    let mut residuals = Vec::with_capacity(observations.len());
941    let mut sse = 0.0;
942    let mut sse_mil = 0.0;
943    for o in &observations {
944        let p = model.predict(fitted_mv, fitted_bc, o.range_yd)?;
945        let r = p - o.drop;
946        let r_mil = match drop_unit {
947            DropUnit::Mil => r,
948            // moa -> mil: divide by (180/pi)*60/1000 moa-per-mil.
949            DropUnit::Moa => r / ((180.0 / std::f64::consts::PI) * 60.0 / 1000.0),
950            // inches -> meters -> small-angle mil at this observation's range.
951            DropUnit::In => r * 0.0254 / (o.range_yd * 0.9144) * 1000.0,
952        };
953        predicted.push(p);
954        residuals.push(r);
955        sse += r * r;
956        sse_mil += r_mil * r_mil;
957    }
958    let rms = (sse / observations.len() as f64).sqrt();
959    let rms_mil = (sse_mil / observations.len() as f64).sqrt();
960
961    let quality = truing_quality_line(
962        bc_fitted,
963        rms,
964        rms_mil,
965        drop_unit,
966        condition_number,
967        converged,
968        observations.len(),
969    );
970
971    let report = MultiTruingReport {
972        fitted_mv_fps: fitted_mv,
973        fitted_bc,
974        bc_input,
975        bc_fitted,
976        observations,
977        predicted,
978        residuals,
979        rms,
980        iterations,
981        converged,
982        sensitivity_ratio,
983        condition_number,
984        quality,
985        reason,
986    };
987
988    Ok(report)
989}
990
991/// RMS of residuals at a candidate `(mv, bc)`.
992pub(crate) fn rms_at(
993    model: &TruingForwardModel<'_>,
994    obs: &[TruingObservation],
995    mv: f64,
996    bc: f64,
997) -> Result<f64, Box<dyn Error>> {
998    let mut sse = 0.0;
999    for o in obs {
1000        let r = model.predict(mv, bc, o.range_yd)? - o.drop;
1001        sse += r * r;
1002    }
1003    Ok((sse / obs.len() as f64).sqrt())
1004}
1005
1006/// Plain-language quality assessment for the fit. `rms` is in the user's drop unit
1007/// (displayed); `rms_mil` is the mil-equivalent the bands were calibrated against
1008/// (~0.03 mil observation noise), so the quality word is unit-invariant (MBA-1337 t1).
1009pub(crate) fn truing_quality_line(
1010    bc_fitted: bool,
1011    rms: f64,
1012    rms_mil: f64,
1013    drop_unit: DropUnit,
1014    condition_number: f64,
1015    converged: bool,
1016    n_obs: usize,
1017) -> String {
1018    let unit = drop_unit.label();
1019    let n_params = if bc_fitted { 2 } else { 1 };
1020    // Exactly-determined fit (MBA-1337 t3): zero degrees of freedom drive the
1021    // residuals to ~0 by construction — an "excellent" RMS validates nothing.
1022    if n_obs == n_params {
1023        return format!(
1024            "{} fit is exactly determined ({n_obs} observations, {n_params} fitted \
1025             parameters): residuals are zero by construction and do not validate the \
1026             fit; add an observation to assess quality",
1027            if bc_fitted { "Joint MV+BC" } else { "MV-only" }
1028        );
1029    }
1030    let quality = if rms_mil < 0.05 {
1031        "excellent"
1032    } else if rms_mil < 0.15 {
1033        "good"
1034    } else if rms_mil < 0.4 {
1035        "fair"
1036    } else {
1037        "poor (observations may be inconsistent)"
1038    };
1039    let nonconv = if converged { "" } else { " (did not fully converge)" };
1040    if bc_fitted {
1041        let cond = if condition_number.is_finite() {
1042            format!("{condition_number:.0}")
1043        } else {
1044            "inf".to_string()
1045        };
1046        format!(
1047            "Joint MV+BC fit, {quality}: RMS residual {rms:.3} {unit}, conditioning {cond}{nonconv}"
1048        )
1049    } else {
1050        format!("MV-only fit, {quality}: RMS residual {rms:.3} {unit} (BC held fixed){nonconv}")
1051    }
1052}