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