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/// Minimum sane chronograph screen distance from the muzzle, in meters (MBA-1377). Below this
336/// the correction is negligible and a nonzero value more likely reflects a data-entry mistake
337/// than a real chronograph placement, so [`correct_chrono_velocity_fps`] rejects it outright
338/// rather than silently applying a near-zero (and therefore noise-dominated) adjustment.
339pub const MIN_CHRONO_DISTANCE_M: f64 = 0.3; // ~1 ft
340
341/// Maximum sane chronograph screen distance from the muzzle, in meters (MBA-1377).
342/// Comfortably past Lapua/JBM's 25 m reference distance -- past this a "chronograph" reading
343/// is no longer describing a screen/radar setup this correction is meant to undo.
344pub const MAX_CHRONO_DISTANCE_M: f64 = 30.0; // ~98 ft
345
346/// Result of back-solving a muzzle velocity from a chronograph reading taken downrange
347/// (MBA-1377; see [`correct_chrono_velocity_fps`]).
348#[derive(Debug, Clone, Copy, PartialEq)]
349pub struct ChronoCorrection {
350    /// The back-solved muzzle velocity, in feet per second.
351    pub muzzle_velocity_fps: f64,
352    /// Number of secant iterations the solve actually took.
353    pub iterations: u32,
354}
355
356/// Forward-model core for the screen-distance correction (MBA-1377): launches
357/// `muzzle_velocity_fps` on a flat (`muzzle_angle = 0`) shot through the SAME BC / drag model /
358/// atmosphere the rest of the command is using, and returns the velocity magnitude at
359/// `screen_distance_m` (linearly interpolated between the bracketing trajectory samples).
360///
361/// Deliberately independent of [`solve_truing_trajectory`]: that helper's zero-angle search and
362/// sight-height bookkeeping are irrelevant here. Chronograph screen distances are short enough
363/// (3-25 m; validated range [`MIN_CHRONO_DISTANCE_M`]..[`MAX_CHRONO_DISTANCE_M`]) that gravity
364/// drop is negligible for the velocity this returns, so firing along the bore line is the
365/// correct simplification for this calculation, not an approximation of convenience.
366#[allow(
367    clippy::too_many_arguments,
368    reason = "flat arguments mirror the rest of this module's forward-model helpers"
369)]
370fn velocity_at_distance_fps(
371    muzzle_velocity_fps: f64,
372    bc: f64,
373    drag_model: DragModelArg,
374    mass_gr: f64,
375    diameter_in: f64,
376    temperature_f: f64,
377    pressure_inhg: f64,
378    humidity: f64,
379    altitude_ft: f64,
380    bc_segments: &Option<Vec<BCSegmentData>>,
381    screen_distance_m: f64,
382) -> Result<f64, Box<dyn Error>> {
383    let velocity_ms = muzzle_velocity_fps * 0.3048;
384    let mass_kg = mass_gr * GRAINS_TO_KG;
385    let diameter_m = diameter_in * 0.0254;
386    let altitude_m = altitude_ft * 0.3048;
387    let temperature_c = (temperature_f - 32.0) * 5.0 / 9.0;
388    let pressure_hpa = pressure_inhg * 33.8639; // inHg to hPa
389
390    let drag_model_enum = match drag_model {
391        DragModelArg::G1 => DragModel::G1,
392        DragModelArg::G7 => DragModel::G7,
393    };
394
395    let inputs = BallisticInputs {
396        muzzle_velocity: velocity_ms,
397        bc_value: bc,
398        bc_type: drag_model_enum,
399        bullet_mass: mass_kg,
400        bullet_diameter: diameter_m,
401        bullet_length: fallback_bullet_length_m(diameter_m, mass_kg), // MBA-1135 mass-based estimate
402        target_distance: screen_distance_m + 2.0, // overshoot so the sample bracket exists
403        use_bc_segments: bc_segments.is_some(),
404        bc_segments_data: bc_segments.clone(),
405        use_rk4: true,
406        muzzle_angle: 0.0, // bore-line: drop over 3-25 m is negligible for velocity
407        ..Default::default()
408    };
409
410    let atmosphere = AtmosphericConditions {
411        temperature: temperature_c,
412        pressure: pressure_hpa,
413        humidity,
414        altitude: altitude_m,
415    };
416
417    let wind = WindConditions::default();
418
419    let mut solver = TrajectorySolver::new(inputs, wind, atmosphere);
420    solver.set_max_range(screen_distance_m + 2.0);
421    solver.set_time_step(0.0001);
422    let result = solver.solve()?;
423
424    let idx = result
425        .points
426        .iter()
427        .position(|p| p.position.x >= screen_distance_m)
428        .ok_or("trajectory did not reach the chronograph screen distance")?;
429
430    if idx == 0 {
431        return Ok(result.points[0].velocity_magnitude / 0.3048);
432    }
433
434    // Linearly interpolate to land exactly on screen_distance_m (same spatial-quantization fix
435    // as solve_trajectory_drop's `interpolate = true` path) so the secant solver in
436    // correct_chrono_velocity_fps sees a smooth function of the candidate muzzle velocity.
437    let p0 = &result.points[idx - 1];
438    let p1 = &result.points[idx];
439    let x0 = p0.position.x;
440    let x1 = p1.position.x;
441    let v_ms = if (x1 - x0).abs() < f64::EPSILON {
442        p1.velocity_magnitude
443    } else {
444        let frac = (screen_distance_m - x0) / (x1 - x0);
445        p0.velocity_magnitude + frac * (p1.velocity_magnitude - p0.velocity_magnitude)
446    };
447    Ok(v_ms / 0.3048)
448}
449
450/// Back-solve a true muzzle velocity from a chronograph reading taken `screen_distance_m` (SI
451/// meters) downrange (MBA-1377).
452///
453/// Most chronographs read 10-15 ft (or 25 m) downrange rather than at the muzzle, so the raw
454/// reading is slightly LOW; JBM ("Distance to Chronograph"), Lapua (V25m to V0), Ballistic AE,
455/// ColdBore, Remington Shoot!, and Hornady all correct for this. Flat-fire point-mass
456/// deceleration is monotone in muzzle velocity over the validated screen-distance band
457/// ([`MIN_CHRONO_DISTANCE_M`]..[`MAX_CHRONO_DISTANCE_M`]), so the secant method converges in a
458/// handful of iterations (McCoy, *Modern Exterior Ballistics*; JBM's published calculator).
459///
460/// This is a pure input-side transform: `velocity_at_distance_fps` runs the EXISTING forward
461/// trajectory model (the same BC / `drag_model` / atmosphere the rest of the command is
462/// configured with) to predict the screen-distance velocity for a candidate muzzle velocity,
463/// and the candidate is iterated until that prediction matches the measured reading. Callers
464/// apply this once, before the corrected value is used for display/comparison --
465/// `solve_truing_trajectory`'s drop-based solves never receive a screen distance.
466#[allow(
467    clippy::too_many_arguments,
468    reason = "flat arguments mirror the rest of this module's forward-model helpers"
469)]
470pub fn correct_chrono_velocity_fps(
471    measured_velocity_fps: f64,
472    screen_distance_m: f64,
473    bc: f64,
474    drag_model: DragModelArg,
475    mass_gr: f64,
476    diameter_in: f64,
477    temperature_f: f64,
478    pressure_inhg: f64,
479    humidity: f64,
480    altitude_ft: f64,
481    bc_segments: &Option<Vec<BCSegmentData>>,
482) -> Result<ChronoCorrection, Box<dyn Error>> {
483    if !measured_velocity_fps.is_finite() || measured_velocity_fps <= 0.0 {
484        return Err("--chrono-velocity must be positive and finite".into());
485    }
486    if !(MIN_CHRONO_DISTANCE_M..=MAX_CHRONO_DISTANCE_M).contains(&screen_distance_m) {
487        return Err(format!(
488            "--chrono-distance is out of range: {screen_distance_m:.3} m downrange (expected \
489             {MIN_CHRONO_DISTANCE_M}..{MAX_CHRONO_DISTANCE_M} m; most chronographs read 10-15 \
490             ft / ~3-5 m, and Lapua/JBM's reference distance is 25 m)"
491        )
492        .into());
493    }
494
495    // Residual: forward-model screen velocity for candidate muzzle velocity `v0`, minus the
496    // measured reading. `correct_chrono_velocity_fps` iterates on `v0` until this is ~0. (Not
497    // JS/Python `eval` -- a plain Rust closure over a local candidate value.)
498    let residual = |v0: f64| -> Result<f64, Box<dyn Error>> {
499        let v_screen = velocity_at_distance_fps(
500            v0,
501            bc,
502            drag_model,
503            mass_gr,
504            diameter_in,
505            temperature_f,
506            pressure_inhg,
507            humidity,
508            altitude_ft,
509            bc_segments,
510            screen_distance_m,
511        )?;
512        Ok(v_screen - measured_velocity_fps)
513    };
514
515    // Secant method. Drag only decelerates, so the true muzzle velocity always exceeds the
516    // downrange reading -- seed the second guess 1% above the first, comfortably inside the
517    // near-linear region for the sub-percent corrections this band produces.
518    let mut v_prev = measured_velocity_fps;
519    let mut f_prev = residual(v_prev)?;
520    let mut v_curr = measured_velocity_fps * 1.01;
521    let mut f_curr = residual(v_curr)?;
522
523    const TOL_FPS: f64 = 1e-4;
524    const MAX_ITERATIONS: u32 = 20;
525    let mut iterations = 0u32;
526
527    while f_curr.abs() > TOL_FPS && iterations < MAX_ITERATIONS {
528        let denom = f_curr - f_prev;
529        if denom.abs() < f64::EPSILON {
530            return Err("chronograph correction failed to converge (stalled secant step)".into());
531        }
532        let v_next = v_curr - f_curr * (v_curr - v_prev) / denom;
533        v_prev = v_curr;
534        f_prev = f_curr;
535        v_curr = v_next;
536        f_curr = residual(v_curr)?;
537        iterations += 1;
538    }
539
540    if f_curr.abs() > TOL_FPS {
541        return Err(format!(
542            "chronograph correction did not converge after {iterations} iterations"
543        )
544        .into());
545    }
546
547    Ok(ChronoCorrection {
548        muzzle_velocity_fps: v_curr,
549        iterations,
550    })
551}
552
553/// Result of the classic single-observation velocity truing
554/// ([`calculate_true_velocity_local`]).
555#[derive(Debug, Clone)]
556pub struct TrueVelocityLocalResult {
557    /// The fitted effective muzzle velocity, in feet per second.
558    pub effective_velocity_fps: f64,
559    /// Number of binary-search iterations actually run.
560    pub iterations: i32,
561    /// Signed residual at the returned velocity, in MIL:
562    /// `calculated_drop_mil - measured_drop_mil`. Positive means the model still
563    /// predicts MORE drop than was measured at the returned velocity; negative
564    /// means less.
565    pub final_error_mil: f64,
566    /// The model-predicted drop (MIL) at the returned velocity.
567    pub calculated_drop_mil: f64,
568    /// Convergence quality: `"high"` (converged, |error| < 0.005 mil),
569    /// `"medium"` (converged within the 0.01 mil tolerance, or stopped early
570    /// with |error| < 0.1 mil), or `"low"` (did not converge; |error| >= 0.1 mil).
571    pub confidence: String,
572}
573
574/// Calculate the effective muzzle velocity that reproduces `measured_drop_mil`
575/// at `range_yd`, via binary search over the real trajectory solver
576/// (the classic single-observation `true-velocity` path).
577///
578/// Returns a [`TrueVelocityLocalResult`] carrying the fitted velocity (fps),
579/// the iteration count, the signed final residual in MIL (positive = the model
580/// still predicts more drop than measured), the model-predicted drop at the
581/// returned velocity, and a `"high"`/`"medium"`/`"low"` confidence label — see
582/// the field docs on [`TrueVelocityLocalResult`] for the exact banding.
583///
584/// Errors on degenerate inputs (`range_yd` non-positive or non-finite,
585/// `measured_drop_mil` non-finite) and on any trajectory-solver failure.
586#[allow(
587    clippy::too_many_arguments,
588    reason = "flat arguments mirror the stable true-velocity CLI command shape"
589)]
590pub fn calculate_true_velocity_local(
591    measured_drop_mil: f64,
592    range_yd: f64,
593    bc: f64,
594    drag_model: DragModelArg,
595    mass_gr: f64,
596    diameter_in: f64,
597    zero_distance_yd: f64,
598    sight_height_in: f64,
599    temperature_f: f64,
600    pressure_inhg: f64,
601    humidity: f64,
602    altitude_ft: f64,
603    bc_segments: &Option<Vec<BCSegmentData>>,
604) -> Result<TrueVelocityLocalResult, Box<dyn Error>> {
605    // Reject degenerate inputs instead of letting NaN/inf flow through the
606    // solver and come back as Ok(NaN). The native CLI's clap range validators
607    // cannot produce these; direct library / WASM callers can.
608    if !range_yd.is_finite() || range_yd <= 0.0 {
609        return Err("range must be positive and finite".into());
610    }
611    if !measured_drop_mil.is_finite() {
612        return Err("measured drop must be finite".into());
613    }
614
615    // Binary search between velocity bounds
616    let mut velocity_low = 1500.0;
617    let mut velocity_high = 4500.0;
618    let tolerance_mil = 0.01; // 0.01 MIL tolerance
619    let max_iterations = 50;
620
621    let mut iterations = 0;
622    let mut last_error = 0.0;
623    let mut last_calculated_drop = 0.0;
624
625    for i in 0..max_iterations {
626        iterations = i + 1;
627        let test_velocity = (velocity_low + velocity_high) / 2.0;
628
629        // Run trajectory at test velocity
630        let calculated_drop_mil = calculate_drop_at_velocity(
631            test_velocity,
632            bc,
633            drag_model,
634            mass_gr,
635            diameter_in,
636            zero_distance_yd,
637            range_yd,
638            sight_height_in,
639            temperature_f,
640            pressure_inhg,
641            humidity,
642            altitude_ft,
643            bc_segments,
644        )?;
645
646        last_calculated_drop = calculated_drop_mil;
647        let error = calculated_drop_mil - measured_drop_mil;
648        last_error = error;
649
650        if error.abs() < tolerance_mil {
651            // Converged
652            let confidence = if error.abs() < 0.005 {
653                "high"
654            } else {
655                "medium"
656            };
657
658            return Ok(TrueVelocityLocalResult {
659                effective_velocity_fps: test_velocity,
660                iterations,
661                final_error_mil: error,
662                calculated_drop_mil,
663                confidence: confidence.to_string(),
664            });
665        }
666
667        // Higher calculated drop = bullet is slower = need higher velocity
668        // Lower calculated drop = bullet is faster = need lower velocity
669        if calculated_drop_mil > measured_drop_mil {
670            // Bullet dropping more than observed = slower than actual
671            // Need higher velocity
672            velocity_low = test_velocity;
673        } else {
674            // Bullet dropping less = faster than actual
675            // Need lower velocity
676            velocity_high = test_velocity;
677        }
678
679        // Check for convergence issues
680        if (velocity_high - velocity_low).abs() < 0.5 {
681            break;
682        }
683    }
684
685    // Did not converge within tolerance, return best estimate
686    let final_velocity = (velocity_low + velocity_high) / 2.0;
687    let confidence = if last_error.abs() < 0.1 {
688        "medium"
689    } else {
690        "low"
691    };
692
693    Ok(TrueVelocityLocalResult {
694        effective_velocity_fps: final_velocity,
695        iterations,
696        final_error_mil: last_error,
697        calculated_drop_mil: last_calculated_drop,
698        confidence: confidence.to_string(),
699    })
700}
701
702// ============================================================================
703// MBA-1316: multi-observation joint MV + BC calibration (truing v2)
704// ============================================================================
705//
706// The classic `true-velocity` path fits muzzle velocity from a single observed
707// drop while BC is held fixed. Mid-range (fully supersonic) drops are dominated
708// by time of flight and therefore constrain muzzle velocity; long-range /
709// transonic drops are where BC bites. With observations spread across those
710// regimes we can separate the two. When the spread is too narrow to tell them
711// apart we refuse the joint fit and fit muzzle velocity only, saying so.
712
713// Solver / fit bounds and identifiability gates. See each constant's doc; the
714// empirical basis for the gate values (calibrated against real .30-cal data,
715// MBA-1316):
716//   * 300/600/900 -> sens 0.29, cond 112 -> BC recovered to ~2%   (JOINT)
717//   * 300/600/900/1000 -> sens 0.33, cond 214 -> BC to <0.1%      (JOINT)
718//   * 300/400/500 -> sens 0.16, cond 282 -> BC error ~3%          (MV-ONLY)
719//   * 200/250/300 -> sens 0.12, cond 2337 -> BC error ~12%        (MV-ONLY)
720// (.308 168gr @ 2700/0.475, ~0.03 mil observation noise.) The gates sit between
721// the "recovers to ~2%" band and the "several-percent garbage" band. They are
722// heuristics: a set that just clears them still carries more BC uncertainty
723// than a set that clears them comfortably.
724
725/// Lower muzzle-velocity fit bound (fps): brackets subsonic pistol loads.
726pub(crate) const TRUING_MV_MIN_FPS: f64 = 1000.0;
727/// Upper muzzle-velocity fit bound (fps): brackets hyper-velocity varmint loads.
728pub(crate) const TRUING_MV_MAX_FPS: f64 = 5000.0;
729/// Lower BC fit bound; matches the CLI's `--bc` validator minimum.
730pub(crate) const TRUING_BC_MIN: f64 = 0.05;
731/// Upper BC fit bound; matches the CLI's `--bc` validator maximum.
732pub(crate) const TRUING_BC_MAX: f64 = 2.0;
733/// Iteration cap shared by the MV-only and joint fitters.
734pub(crate) const TRUING_MAX_ITERS: usize = 40;
735
736/// Identifiability gate: minimum
737/// `sensitivity_ratio = ||bc*d(drop)/d(bc)|| / ||mv*d(drop)/d(mv)||` — how much
738/// a fractional BC change moves the predicted drops relative to a fractional MV
739/// change. Below this, the observations barely constrain BC and the joint fit
740/// is refused (MV-only fallback).
741pub(crate) const TRUING_MIN_BC_SENSITIVITY_RATIO: f64 = 0.20;
742/// Identifiability gate: maximum `condition_number = (1+|c|)/(1-|c|)` (|c| =
743/// correlation of the raw MV/BC Jacobian columns). Above this the two columns
744/// are collinear — a BC change can be undone by an MV change — and the joint
745/// fit is refused (MV-only fallback). Both this gate and
746/// [`TRUING_MIN_BC_SENSITIVITY_RATIO`] must pass to attempt the joint fit.
747pub(crate) const TRUING_MAX_CONDITION_NUMBER: f64 = 1.0e3;
748
749/// A single observed impact used for truing: range (internal yards) and the
750/// measured drop below line of sight expressed in the caller's drop unit.
751#[derive(Debug, Clone, Copy)]
752pub struct TruingObservation {
753    pub range_yd: f64,
754    pub drop: f64,
755}
756
757/// Complete scalar-BC forward-model input used by the truing design and
758/// uncertainty APIs added in MBA-1346/MBA-1353.
759///
760/// All values use the truing core's historical imperial units.  A front end is
761/// expected to convert its display units once at the boundary.  V1 deliberately
762/// models one scalar G1/G7 coefficient: a velocity-banded BC schedule or custom
763/// Mach/Cd deck does not have a single BC parameter to identify and must not be
764/// silently reduced to one.
765#[derive(Debug, Clone, Copy, Serialize)]
766pub struct TruingModelInputsV1 {
767    /// Nominal muzzle velocity about which a plan is designed, in feet/second.
768    pub muzzle_velocity_fps: f64,
769    /// Nominal scalar ballistic coefficient about which a plan is designed.
770    pub ballistic_coefficient: f64,
771    pub drag_model: DragModelArg,
772    /// Bullet mass in grains.
773    pub mass_gr: f64,
774    /// Bullet diameter in inches.
775    pub diameter_in: f64,
776    /// Zero distance in yards.
777    pub zero_distance_yd: f64,
778    /// Sight height over bore in inches.
779    pub sight_height_in: f64,
780    /// Ambient temperature in degrees Fahrenheit.
781    pub temperature_f: f64,
782    /// Station pressure in inches of mercury.
783    pub pressure_inhg: f64,
784    /// Relative humidity in percent (0 through 100).
785    pub humidity_pct: f64,
786    /// Altitude in feet.
787    pub altitude_ft: f64,
788}
789
790impl TruingModelInputsV1 {
791    /// Validate the scalar-BC model before an expensive design or uncertainty
792    /// calculation.  These bounds mirror the existing truing solver where
793    /// applicable, while rejecting NaN/infinity at the library boundary.
794    pub fn validate(&self) -> Result<(), String> {
795        if !self.muzzle_velocity_fps.is_finite()
796            || !(TRUING_MV_MIN_FPS..=TRUING_MV_MAX_FPS).contains(&self.muzzle_velocity_fps)
797        {
798            return Err(format!(
799                "nominal muzzle velocity must be finite and within {TRUING_MV_MIN_FPS:.0}..={TRUING_MV_MAX_FPS:.0} fps"
800            ));
801        }
802        if !self.ballistic_coefficient.is_finite()
803            || !(TRUING_BC_MIN..=TRUING_BC_MAX).contains(&self.ballistic_coefficient)
804        {
805            return Err(format!(
806                "nominal ballistic coefficient must be finite and within {TRUING_BC_MIN:.2}..={TRUING_BC_MAX:.1}"
807            ));
808        }
809        for (name, value) in [
810            ("bullet mass", self.mass_gr),
811            ("bullet diameter", self.diameter_in),
812            ("zero distance", self.zero_distance_yd),
813            ("sight height", self.sight_height_in),
814            ("pressure", self.pressure_inhg),
815        ] {
816            if !value.is_finite() || value <= 0.0 {
817                return Err(format!("{name} must be positive and finite"));
818            }
819        }
820        if !self.temperature_f.is_finite() {
821            return Err("temperature must be finite".to_string());
822        }
823        if !self.humidity_pct.is_finite() || !(0.0..=100.0).contains(&self.humidity_pct) {
824            return Err("humidity must be finite and within 0..=100 percent".to_string());
825        }
826        if !self.altitude_ft.is_finite() {
827            return Err("altitude must be finite".to_string());
828        }
829        Ok(())
830    }
831
832    /// Borrow this owned public shape as the existing internal forward model.
833    /// The local `None` is intentional: V1 estimates one scalar BC and therefore
834    /// cannot accept a velocity-banded BC schedule.
835    pub(crate) fn with_forward_model<T>(
836        &self,
837        drop_unit: DropUnit,
838        f: impl FnOnce(&TruingForwardModel<'_>) -> T,
839    ) -> T {
840        let no_bc_segments = None;
841        let model = TruingForwardModel {
842            drag_model: self.drag_model,
843            mass_gr: self.mass_gr,
844            diameter_in: self.diameter_in,
845            zero_yd: self.zero_distance_yd,
846            sight_in: self.sight_height_in,
847            temp_f: self.temperature_f,
848            press_inhg: self.pressure_inhg,
849            humidity: self.humidity_pct,
850            alt_ft: self.altitude_ft,
851            bc_segments: &no_bc_segments,
852            drop_unit,
853        };
854        f(&model)
855    }
856}
857
858/// Parse an `--observed RANGE:DROP` token. RANGE is in the caller's distance
859/// units (yards imperial / meters metric) and is normalized to internal yards;
860/// DROP stays in the caller's drop unit. Returns a user-facing error string on
861/// malformed input so the CLI can report cleanly instead of panicking.
862pub fn parse_truing_observation(s: &str, units: UnitSystem) -> Result<TruingObservation, String> {
863    let parts: Vec<&str> = s.split(':').collect();
864    if parts.len() != 2 {
865        return Err(format!(
866            "invalid --observed '{s}': expected RANGE:DROP (e.g. 600:5.1)"
867        ));
868    }
869    let range: f64 = parts[0]
870        .trim()
871        .parse()
872        .map_err(|_| format!("invalid --observed range '{}' in '{s}'", parts[0]))?;
873    let drop: f64 = parts[1]
874        .trim()
875        .parse()
876        .map_err(|_| format!("invalid --observed drop '{}' in '{s}'", parts[1]))?;
877    if !range.is_finite() || !drop.is_finite() {
878        return Err(format!("invalid --observed '{s}': values must be finite"));
879    }
880    let range_yd = match units {
881        UnitSystem::Imperial => range,
882        UnitSystem::Metric => range / 0.9144,
883    };
884    Ok(TruingObservation { range_yd, drop })
885}
886
887/// Validate a truing observation set: every range finite and positive, every
888/// drop finite and non-zero, and no two observations at (numerically) the same
889/// range. [`run_multi_observation_truing_core`] runs this itself, so callers
890/// don't have to — it is public so a front end can pre-validate when it wants
891/// to sequence its own progress output strictly after validation (the native
892/// CLI prints its "Fitting N observations..." progress line only for sets that
893/// will actually be fitted). Error strings are the stable user-facing ones.
894pub fn validate_truing_observations(observations: &[TruingObservation]) -> Result<(), String> {
895    for o in observations {
896        if !o.range_yd.is_finite() || o.range_yd <= 0.0 {
897            return Err(format!(
898                "observation range must be a positive finite distance (got {})",
899                o.range_yd
900            ));
901        }
902        if !o.drop.is_finite() || o.drop == 0.0 {
903            return Err(
904                "observation drop must be non-zero (a zero drop carries no truing information)"
905                    .to_string(),
906            );
907        }
908    }
909    for i in 0..observations.len() {
910        for j in (i + 1)..observations.len() {
911            if (observations[i].range_yd - observations[j].range_yd).abs() < 1e-6 {
912                return Err(format!(
913                    "duplicate observation range ({:.3} yd internal): each observation must be at a distinct range",
914                    observations[i].range_yd
915                ));
916            }
917        }
918    }
919    Ok(())
920}
921
922/// Fixed load / atmosphere for a truing fit. The two free parameters (muzzle
923/// velocity and BC) are supplied per prediction so the fitter can vary them.
924pub(crate) struct TruingForwardModel<'a> {
925    pub drag_model: DragModelArg,
926    pub mass_gr: f64,
927    pub diameter_in: f64,
928    pub zero_yd: f64,
929    pub sight_in: f64,
930    pub temp_f: f64,
931    pub press_inhg: f64,
932    pub humidity: f64,
933    pub alt_ft: f64,
934    pub bc_segments: &'a Option<Vec<BCSegmentData>>,
935    pub drop_unit: DropUnit,
936}
937
938impl TruingForwardModel<'_> {
939    /// Predicted drop (in the configured drop unit) at `range_yd` for the given
940    /// muzzle velocity and BC, using the real trajectory solver.
941    pub fn predict(&self, mv_fps: f64, bc: f64, range_yd: f64) -> Result<f64, Box<dyn Error>> {
942        self.predict_in_unit(mv_fps, bc, range_yd, self.drop_unit)
943    }
944
945    /// `predict` expressed in an explicit unit, independent of the configured
946    /// `--drop-unit`. The identifiability diagnostics use this to stay in mil space
947    /// (MBA-1337 t1): the linear `in` unit weights each Jacobian row by its range,
948    /// which shifts the column correlation. NOTE this makes the gate DIAGNOSTICS
949    /// unit-invariant; the MV-only operating point and the least-squares cost are
950    /// still minimized in the display unit (deliberately out of scope — changing
951    /// them changes fitted numbers), so extreme near-boundary sets can still differ.
952    pub fn predict_in_unit(
953        &self,
954        mv_fps: f64,
955        bc: f64,
956        range_yd: f64,
957        unit: DropUnit,
958    ) -> Result<f64, Box<dyn Error>> {
959        let (drop_m, z_m) = solve_trajectory_drop(
960            mv_fps,
961            bc,
962            self.drag_model,
963            self.mass_gr,
964            self.diameter_in,
965            self.zero_yd,
966            range_yd,
967            self.sight_in,
968            self.temp_f,
969            self.press_inhg,
970            self.humidity,
971            self.alt_ft,
972            self.bc_segments,
973            true, // interpolate: smooth forward model for the fitter
974        )?;
975        Ok(unit.express_drop_m(drop_m, z_m))
976    }
977
978    /// Predict several ranges from one zero solve and one trajectory integration.
979    /// `None` marks a candidate the trajectory did not physically reach (or an
980    /// invalid non-positive/non-finite candidate); other solver failures remain
981    /// errors because they invalidate the entire nominal/perturbed model.
982    ///
983    /// This is the high-throughput path for experiment design and predictive
984    /// bands.  It deliberately reproduces [`solve_trajectory_drop`]'s assembly
985    /// and interpolation, but integrates through the farthest candidate once.
986    pub(crate) fn predict_many_in_unit(
987        &self,
988        mv_fps: f64,
989        bc: f64,
990        ranges_yd: &[f64],
991        unit: DropUnit,
992    ) -> Result<Vec<Option<f64>>, Box<dyn Error>> {
993        if ranges_yd.is_empty() {
994            return Ok(Vec::new());
995        }
996        let max_range_yd = ranges_yd
997            .iter()
998            .copied()
999            .filter(|r| r.is_finite() && *r > 0.0)
1000            .fold(0.0_f64, f64::max);
1001        if max_range_yd <= 0.0 {
1002            return Ok(vec![None; ranges_yd.len()]);
1003        }
1004
1005        let velocity_ms = mv_fps * 0.3048;
1006        let mass_kg = self.mass_gr * GRAINS_TO_KG;
1007        let diameter_m = self.diameter_in * 0.0254;
1008        let zero_m = self.zero_yd * 0.9144;
1009        let max_range_m = max_range_yd * 0.9144;
1010        let sight_height_m = self.sight_in * 0.0254;
1011        let altitude_m = self.alt_ft * 0.3048;
1012        let temperature_c = (self.temp_f - 32.0) * 5.0 / 9.0;
1013        let pressure_hpa = self.press_inhg * 33.8639;
1014        let drag_model = match self.drag_model {
1015            DragModelArg::G1 => DragModel::G1,
1016            DragModelArg::G7 => DragModel::G7,
1017        };
1018
1019        let mut inputs = BallisticInputs {
1020            muzzle_velocity: velocity_ms,
1021            bc_value: bc,
1022            bc_type: drag_model,
1023            bullet_mass: mass_kg,
1024            bullet_diameter: diameter_m,
1025            bullet_length: fallback_bullet_length_m(diameter_m, mass_kg),
1026            sight_height: sight_height_m,
1027            target_distance: max_range_m + 100.0,
1028            use_bc_segments: self.bc_segments.is_some(),
1029            bc_segments_data: self.bc_segments.clone(),
1030            use_rk4: true,
1031            muzzle_angle: 0.0,
1032            ..Default::default()
1033        };
1034        let atmosphere = AtmosphericConditions {
1035            temperature: temperature_c,
1036            pressure: pressure_hpa,
1037            humidity: self.humidity,
1038            altitude: altitude_m,
1039        };
1040        let wind = WindConditions::default();
1041        inputs.muzzle_angle = crate::calculate_zero_angle_with_conditions(
1042            inputs.clone(),
1043            zero_m,
1044            sight_height_m,
1045            wind.clone(),
1046            atmosphere.clone(),
1047        )?;
1048
1049        let mut solver = TrajectorySolver::new(inputs, wind, atmosphere);
1050        solver.set_max_range(max_range_m + 100.0);
1051        solver.set_time_step(0.0001);
1052        let result = solver.solve()?;
1053
1054        let predictions = ranges_yd
1055            .iter()
1056            .map(|range_yd| {
1057                if !range_yd.is_finite() || *range_yd <= 0.0 {
1058                    return None;
1059                }
1060                let range_m = *range_yd * 0.9144;
1061                let idx = result.points.iter().position(|p| p.position.x >= range_m)?;
1062                let (z_m, bullet_y) = if idx > 0 {
1063                    let p0 = &result.points[idx - 1];
1064                    let p1 = &result.points[idx];
1065                    let span = p1.position.x - p0.position.x;
1066                    if span.abs() < f64::EPSILON {
1067                        (p1.position.x, p1.position.y)
1068                    } else {
1069                        let fraction = (range_m - p0.position.x) / span;
1070                        (
1071                            range_m,
1072                            p0.position.y + fraction * (p1.position.y - p0.position.y),
1073                        )
1074                    }
1075                } else {
1076                    let p = &result.points[idx];
1077                    (p.position.x, p.position.y)
1078                };
1079                let drop_m = sight_height_m - bullet_y;
1080                Some(unit.express_drop_m(drop_m, z_m))
1081            })
1082            .collect();
1083        Ok(predictions)
1084    }
1085
1086    /// Sum of squared residuals (predicted - observed) over all observations.
1087    pub fn cost(&self, mv: f64, bc: f64, obs: &[TruingObservation]) -> Result<f64, Box<dyn Error>> {
1088        let mut c = 0.0;
1089        for o in obs {
1090            let r = self.predict(mv, bc, o.range_yd)? - o.drop;
1091            c += r * r;
1092        }
1093        Ok(c)
1094    }
1095}
1096
1097/// One finite-difference row of the truing forward model.  The planner and
1098/// uncertainty engine deliberately share this helper with the point fitter so
1099/// that experiment-design claims and posterior diagnostics describe the exact
1100/// same physics and perturbation sizes.
1101#[derive(Debug, Clone, Copy)]
1102pub(crate) struct TruingJacobianRow {
1103    pub predicted_drop: f64,
1104    pub d_drop_d_mv: f64,
1105    pub d_drop_d_bc: f64,
1106}
1107
1108pub(crate) fn truing_jacobian_row(
1109    model: &TruingForwardModel<'_>,
1110    mv_fps: f64,
1111    bc: f64,
1112    range_yd: f64,
1113    unit: DropUnit,
1114) -> Result<TruingJacobianRow, Box<dyn Error>> {
1115    let hmv = (mv_fps * 1e-3).max(0.5);
1116    let hbc = (bc * 1e-3).max(1e-4);
1117    let predicted_drop = model.predict_in_unit(mv_fps, bc, range_yd, unit)?;
1118    let d_drop_d_mv = (model.predict_in_unit(mv_fps + hmv, bc, range_yd, unit)?
1119        - model.predict_in_unit(mv_fps - hmv, bc, range_yd, unit)?)
1120        / (2.0 * hmv);
1121    let d_drop_d_bc = (model.predict_in_unit(mv_fps, bc + hbc, range_yd, unit)?
1122        - model.predict_in_unit(mv_fps, bc - hbc, range_yd, unit)?)
1123        / (2.0 * hbc);
1124    Ok(TruingJacobianRow {
1125        predicted_drop,
1126        d_drop_d_mv,
1127        d_drop_d_bc,
1128    })
1129}
1130
1131/// Batched form of [`truing_jacobian_row`].  Five integrations (nominal,
1132/// MV+/-, BC+/-) cover every requested range.  A row is `None` if any of those
1133/// trajectories cannot reach that candidate, preventing one-sided or silently
1134/// fabricated sensitivity estimates.
1135pub(crate) fn truing_jacobian_rows(
1136    model: &TruingForwardModel<'_>,
1137    mv_fps: f64,
1138    bc: f64,
1139    ranges_yd: &[f64],
1140    unit: DropUnit,
1141) -> Result<Vec<Option<TruingJacobianRow>>, Box<dyn Error>> {
1142    let hmv = (mv_fps * 1e-3).max(0.5);
1143    let hbc = (bc * 1e-3).max(1e-4);
1144    let nominal = model.predict_many_in_unit(mv_fps, bc, ranges_yd, unit)?;
1145    let mv_plus = model.predict_many_in_unit(mv_fps + hmv, bc, ranges_yd, unit)?;
1146    let mv_minus = model.predict_many_in_unit(mv_fps - hmv, bc, ranges_yd, unit)?;
1147    let bc_plus = model.predict_many_in_unit(mv_fps, bc + hbc, ranges_yd, unit)?;
1148    let bc_minus = model.predict_many_in_unit(mv_fps, bc - hbc, ranges_yd, unit)?;
1149
1150    Ok((0..ranges_yd.len())
1151        .map(|i| {
1152            Some(TruingJacobianRow {
1153                predicted_drop: nominal[i]?,
1154                d_drop_d_mv: (mv_plus[i]? - mv_minus[i]?) / (2.0 * hmv),
1155                d_drop_d_bc: (bc_plus[i]? - bc_minus[i]?) / (2.0 * hbc),
1156            })
1157        })
1158        .collect())
1159}
1160
1161/// One-parameter (muzzle velocity) least-squares fit with BC held fixed. Damped
1162/// Gauss-Newton with a central finite-difference derivative; robust because drop
1163/// is monotonic in muzzle velocity. Returns `(mv_fps, iterations, converged)`.
1164pub(crate) fn fit_truing_mv_only(
1165    model: &TruingForwardModel<'_>,
1166    obs: &[TruingObservation],
1167    bc: f64,
1168    mv_init: f64,
1169) -> Result<(f64, usize, bool), Box<dyn Error>> {
1170    let mut mv = mv_init.clamp(TRUING_MV_MIN_FPS, TRUING_MV_MAX_FPS);
1171    let mut converged = false;
1172    let mut iters = 0;
1173    for i in 0..TRUING_MAX_ITERS {
1174        iters = i + 1;
1175        let h = (mv * 1e-3).max(0.5);
1176        let mut num = 0.0;
1177        let mut den = 0.0;
1178        for o in obs {
1179            let r = model.predict(mv, bc, o.range_yd)? - o.drop;
1180            let dp = model.predict(mv + h, bc, o.range_yd)?;
1181            let dm = model.predict(mv - h, bc, o.range_yd)?;
1182            let j = (dp - dm) / (2.0 * h);
1183            num += j * r;
1184            den += j * j;
1185        }
1186        if den < 1e-12 {
1187            break;
1188        }
1189        // Gauss-Newton step, limited to keep the solver in a sane regime.
1190        let step = (-num / den).clamp(-300.0, 300.0);
1191        let new_mv = (mv + step).clamp(TRUING_MV_MIN_FPS, TRUING_MV_MAX_FPS);
1192        if (new_mv - mv).abs() < 0.05 {
1193            mv = new_mv;
1194            converged = true;
1195            break;
1196        }
1197        mv = new_mv;
1198    }
1199    Ok((mv, iters, converged))
1200}
1201
1202/// Identifiability diagnostics for BC at the operating point `(mv, bc)`.
1203///
1204/// Returns `(sensitivity_ratio, condition_number)`:
1205///
1206/// * `sensitivity_ratio = ||bc*d(drop)/d(bc)|| / ||mv*d(drop)/d(mv)||` — the
1207///   relative influence of a fractional BC change vs a fractional MV change on
1208///   the predicted drops. Small => BC barely moves the drops (weak-signal mode).
1209///
1210/// * `condition_number = (1+|c|)/(1-|c|)` where `c` is the correlation between
1211///   the raw MV and BC Jacobian columns. Large => the two columns point the same
1212///   way, so a BC change can be undone by an MV change and the pair is not
1213///   separable (collinearity mode). This is magnitude-independent, unlike the
1214///   scaled-normal-matrix condition, so it actually tracks observation spread.
1215pub(crate) fn truing_identifiability(
1216    model: &TruingForwardModel<'_>,
1217    obs: &[TruingObservation],
1218    mv: f64,
1219    bc: f64,
1220) -> Result<(f64, f64), Box<dyn Error>> {
1221    let (mut n_mv, mut n_bc, mut cross) = (0.0, 0.0, 0.0);
1222    // Differentiate in mil space regardless of --drop-unit (MBA-1337 t1) so these
1223    // gate diagnostics do not shift with the display unit. (The operating point mv
1224    // comes from a display-unit fit, so full invariance is not guaranteed — see
1225    // predict_in_unit's note.)
1226    let unit = DropUnit::Mil;
1227    for o in obs {
1228        let row = truing_jacobian_row(model, mv, bc, o.range_yd, unit)?;
1229        n_mv += row.d_drop_d_mv * row.d_drop_d_mv;
1230        n_bc += row.d_drop_d_bc * row.d_drop_d_bc;
1231        cross += row.d_drop_d_mv * row.d_drop_d_bc;
1232    }
1233    let norm_mv = n_mv.sqrt();
1234    let norm_bc = n_bc.sqrt();
1235    let sensitivity_ratio = if mv * norm_mv > 0.0 {
1236        (bc * norm_bc) / (mv * norm_mv)
1237    } else {
1238        0.0
1239    };
1240    // Correlation between the raw Jacobian columns.
1241    let condition_number = if norm_mv > 0.0 && norm_bc > 0.0 {
1242        let c = (cross / (norm_mv * norm_bc)).clamp(-1.0, 1.0).abs();
1243        if (1.0 - c) > 1e-15 {
1244            (1.0 + c) / (1.0 - c)
1245        } else {
1246            f64::INFINITY
1247        }
1248    } else {
1249        // One column is numerically zero: the missing parameter is unconstrained.
1250        f64::INFINITY
1251    };
1252    Ok((sensitivity_ratio, condition_number))
1253}
1254
1255/// Two-parameter (muzzle velocity, BC) joint fit via Levenberg-Marquardt
1256/// (damped Gauss-Newton) with central finite-difference Jacobian. Only the
1257/// diagonal of the normal matrix is damped (classic Marquardt scaling). Returns
1258/// `(mv_fps, bc, iterations, converged)`.
1259pub(crate) fn fit_truing_joint(
1260    model: &TruingForwardModel<'_>,
1261    obs: &[TruingObservation],
1262    mv_init: f64,
1263    bc_init: f64,
1264) -> Result<(f64, f64, usize, bool), Box<dyn Error>> {
1265    let mut mv = mv_init.clamp(TRUING_MV_MIN_FPS, TRUING_MV_MAX_FPS);
1266    let mut bc = bc_init.clamp(TRUING_BC_MIN, TRUING_BC_MAX);
1267    let mut lambda = 1e-3;
1268    let mut cur_cost = model.cost(mv, bc, obs)?;
1269    let mut converged = false;
1270    let mut iters = 0;
1271
1272    for it in 0..TRUING_MAX_ITERS {
1273        iters = it + 1;
1274        // Build J^T J (a00,a01,a11) and J^T r (g0,g1).
1275        let (mut a00, mut a01, mut a11) = (0.0, 0.0, 0.0);
1276        let (mut g0, mut g1) = (0.0, 0.0);
1277        for o in obs {
1278            let row = truing_jacobian_row(model, mv, bc, o.range_yd, model.drop_unit)?;
1279            let r = row.predicted_drop - o.drop;
1280            let jmv = row.d_drop_d_mv;
1281            let jbc = row.d_drop_d_bc;
1282            a00 += jmv * jmv;
1283            a01 += jmv * jbc;
1284            a11 += jbc * jbc;
1285            g0 += jmv * r;
1286            g1 += jbc * r;
1287        }
1288
1289        // Inner loop: grow lambda until a step reduces the cost.
1290        let mut accepted = false;
1291        for _ in 0..30 {
1292            let m00 = a00 + lambda * a00.max(1e-12);
1293            let m11 = a11 + lambda * a11.max(1e-12);
1294            let det = m00 * m11 - a01 * a01;
1295            if det.abs() < 1e-20 {
1296                lambda *= 10.0;
1297                continue;
1298            }
1299            // delta = -(JtJ + lambda*diag)^-1 * Jtr
1300            let dmv = -(m11 * g0 - a01 * g1) / det;
1301            let dbc = -(-a01 * g0 + m00 * g1) / det;
1302            let nmv = (mv + dmv).clamp(TRUING_MV_MIN_FPS, TRUING_MV_MAX_FPS);
1303            let nbc = (bc + dbc).clamp(TRUING_BC_MIN, TRUING_BC_MAX);
1304            let nc = model.cost(nmv, nbc, obs)?;
1305            if nc < cur_cost {
1306                let rel_change =
1307                    (nmv - mv).abs() / mv.max(1.0) + (nbc - bc).abs() / bc.max(1e-3);
1308                mv = nmv;
1309                bc = nbc;
1310                cur_cost = nc;
1311                lambda = (lambda * 0.5).max(1e-9);
1312                accepted = true;
1313                if rel_change < 1e-6 {
1314                    converged = true;
1315                }
1316                break;
1317            }
1318            lambda *= 4.0;
1319            if lambda > 1e12 {
1320                break;
1321            }
1322        }
1323        if !accepted {
1324            // No downhill step exists within damping range: we are at (or
1325            // numerically indistinguishable from) a local optimum.
1326            converged = true;
1327            break;
1328        }
1329        if converged {
1330            break;
1331        }
1332    }
1333    Ok((mv, bc, iters, converged))
1334}
1335
1336/// Final report produced by a multi-observation truing fit.
1337#[derive(Debug, Clone)]
1338pub struct MultiTruingReport {
1339    pub fitted_mv_fps: f64,
1340    pub fitted_bc: f64,
1341    pub bc_input: f64,
1342    pub bc_fitted: bool,
1343    pub observations: Vec<TruingObservation>,
1344    pub predicted: Vec<f64>,
1345    pub residuals: Vec<f64>,
1346    pub rms: f64,
1347    pub iterations: usize,
1348    pub converged: bool,
1349    pub sensitivity_ratio: f64,
1350    pub condition_number: f64,
1351    pub quality: String,
1352    pub reason: String,
1353    /// Downward Mach-1.2 crossing distance (meters) for the finally fitted
1354    /// (`fitted_mv_fps`, `fitted_bc`) load, re-solved out to a fixed 3000 yd
1355    /// envelope independent of the observation set (the window describes the
1356    /// load, not wherever the caller happened to shoot). Feeds
1357    /// [`mv_calibration_window`] for the MV-calibration window report line
1358    /// (MBA-1405 Task 2). `None` when the trajectory never crosses within that
1359    /// envelope.
1360    pub mach_1_2_distance_m: Option<f64>,
1361    /// The downrange distance (meters) the fixed-envelope re-solve actually
1362    /// reached — names the range checked in the "no MV window" report note
1363    /// when `mach_1_2_distance_m` is `None` (MBA-1405 Task 2).
1364    pub window_solved_range_m: f64,
1365    /// Mach number at the first trajectory point of the fixed-envelope re-solve
1366    /// (`points[0].velocity_magnitude / station_speed_of_sound_mps`, the same
1367    /// convention `apply_dsf`/`truing_dsf` use for per-point Mach). Distinguishes,
1368    /// when `mach_1_2_distance_m` is `None`, a load that is still supersonic at
1369    /// the end of the window envelope (muzzle Mach >= 1.2) from one that launches
1370    /// below Mach 1.2 and never crosses downward at all (muzzle Mach < 1.2) — the
1371    /// two have opposite report text (MBA-1405 Task 2 fix pass).
1372    pub muzzle_mach: f64,
1373}
1374
1375/// Orchestrate the multi-observation joint MV+BC calibration and build the
1376/// final [`MultiTruingReport`] (MBA-1343: the compute half only — rendering
1377/// stays with the caller).
1378///
1379/// `observations` is the already-parsed observation set, primary
1380/// (`--range`/`--measured-drop`) first, then each `--observed` impact in order
1381/// (use [`parse_truing_observation`] to build them from `RANGE:DROP` tokens);
1382/// every drop is already in `drop_unit` and every range in internal yards. The
1383/// set is validated with [`validate_truing_observations`] before any fitting.
1384#[allow(
1385    clippy::too_many_arguments,
1386    reason = "flat arguments mirror the stable true-velocity CLI command shape"
1387)]
1388pub fn run_multi_observation_truing_core(
1389    observations: &[TruingObservation],
1390    drop_unit: DropUnit,
1391    bc_input: f64,
1392    drag_model: DragModelArg,
1393    mass_gr: f64,
1394    diameter_in: f64,
1395    zero_yd: f64,
1396    sight_in: f64,
1397    temp_f: f64,
1398    press_inhg: f64,
1399    humidity: f64,
1400    alt_ft: f64,
1401    bc_segments: &Option<Vec<BCSegmentData>>,
1402) -> Result<MultiTruingReport, Box<dyn Error>> {
1403    // Validate: finite, positive range, non-zero drop, no duplicate ranges.
1404    validate_truing_observations(observations)?;
1405    let observations: Vec<TruingObservation> = observations.to_vec();
1406
1407    let model = TruingForwardModel {
1408        drag_model,
1409        mass_gr,
1410        diameter_in,
1411        zero_yd,
1412        sight_in,
1413        temp_f,
1414        press_inhg,
1415        humidity,
1416        alt_ft,
1417        bc_segments,
1418        drop_unit,
1419    };
1420
1421    // Step 1: MV-only fit holding BC at the supplied value. This is always
1422    // well-posed and gives a good operating point for the identifiability check
1423    // and (if BC is identifiable) the joint fit.
1424    let mv_init = (TRUING_MV_MIN_FPS + TRUING_MV_MAX_FPS) / 2.0;
1425    let (mv0, mv_iters, mv_conv) = fit_truing_mv_only(&model, &observations, bc_input, mv_init)?;
1426    let rms_mv_only = rms_at(&model, &observations, mv0, bc_input)?;
1427
1428    // Step 2: is BC identifiable from this observation set?
1429    let (sensitivity_ratio, condition_number) =
1430        truing_identifiability(&model, &observations, mv0, bc_input)?;
1431    let bc_identifiable = sensitivity_ratio >= TRUING_MIN_BC_SENSITIVITY_RATIO
1432        && condition_number <= TRUING_MAX_CONDITION_NUMBER
1433        && condition_number.is_finite();
1434
1435    // Step 3: joint fit when identifiable, with a guard against a worse or
1436    // out-of-bounds result (never report a garbage joint fit).
1437    let mut fitted_mv = mv0;
1438    let mut fitted_bc = bc_input;
1439    let mut bc_fitted = false;
1440    let mut iterations = mv_iters;
1441    // Start from the MV-only fitter's own flag (MBA-1337 t2): both MV-only outcomes
1442    // (gate-refused joint, or joint rejected as worse) report THIS fit's convergence.
1443    // The accepted-joint branch overwrites it with the joint fitter's flag.
1444    let mut converged = mv_conv;
1445    let mut reason = String::new();
1446
1447    if bc_identifiable {
1448        let (mv_j, bc_j, iters_j, conv_j) =
1449            fit_truing_joint(&model, &observations, mv0, bc_input)?;
1450        let rms_joint = rms_at(&model, &observations, mv_j, bc_j)?;
1451        let bc_at_bound = bc_j <= TRUING_BC_MIN * 1.001 || bc_j >= TRUING_BC_MAX * 0.999;
1452        if !bc_at_bound && rms_joint <= rms_mv_only + 1e-9 {
1453            fitted_mv = mv_j;
1454            fitted_bc = bc_j;
1455            bc_fitted = true;
1456            iterations = iters_j;
1457            converged = conv_j;
1458        } else {
1459            // Joint fit did not help (or ran to a bound): keep the honest
1460            // MV-only answer rather than a false-precision BC.
1461            reason = if bc_at_bound {
1462                format!(
1463                    "joint fit drove BC to a bound ({bc_j:.3}); BC held at input {bc_input:.3}"
1464                )
1465            } else {
1466                format!(
1467                    "joint fit did not improve on the MV-only solution; BC held at input {bc_input:.3}"
1468                )
1469            };
1470        }
1471    } else {
1472        reason = if !condition_number.is_finite() || condition_number > TRUING_MAX_CONDITION_NUMBER
1473        {
1474            format!(
1475                "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}"
1476            )
1477        } else {
1478            format!(
1479                "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."
1480            )
1481        };
1482    }
1483
1484    // Final residuals at the reported parameters. Alongside the display-unit RMS,
1485    // accumulate a mil-equivalent RMS: the quality bands were calibrated in mil
1486    // (~0.03 mil observation noise), so banding must not shift with --drop-unit
1487    // (MBA-1337 t1). Reported numbers stay in the user's unit.
1488    let mut predicted = Vec::with_capacity(observations.len());
1489    let mut residuals = Vec::with_capacity(observations.len());
1490    let mut sse = 0.0;
1491    let mut sse_mil = 0.0;
1492    for o in &observations {
1493        let p = model.predict(fitted_mv, fitted_bc, o.range_yd)?;
1494        let r = p - o.drop;
1495        let r_mil = match drop_unit {
1496            DropUnit::Mil => r,
1497            // moa -> mil: divide by (180/pi)*60/1000 moa-per-mil.
1498            DropUnit::Moa => r / ((180.0 / std::f64::consts::PI) * 60.0 / 1000.0),
1499            // inches -> meters -> small-angle mil at this observation's range.
1500            DropUnit::In => r * 0.0254 / (o.range_yd * 0.9144) * 1000.0,
1501        };
1502        predicted.push(p);
1503        residuals.push(r);
1504        sse += r * r;
1505        sse_mil += r_mil * r_mil;
1506    }
1507    let rms = (sse / observations.len() as f64).sqrt();
1508    let rms_mil = (sse_mil / observations.len() as f64).sqrt();
1509
1510    let quality = truing_quality_line(
1511        bc_fitted,
1512        rms,
1513        rms_mil,
1514        drop_unit,
1515        condition_number,
1516        converged,
1517        observations.len(),
1518    );
1519
1520    // MBA-1405 Task 2: one extra solve at the finally fitted (mv, bc), out to a
1521    // fixed generous envelope independent of the observation set, purely to read
1522    // off the downward Mach-1.2 crossing for the MV-calibration window report line.
1523    let (mach_1_2_distance_m, window_solved_range_m, muzzle_mach) =
1524        truing_mv_window_mach_1_2_crossing(&model, fitted_mv, fitted_bc)?;
1525
1526    let report = MultiTruingReport {
1527        fitted_mv_fps: fitted_mv,
1528        fitted_bc,
1529        bc_input,
1530        bc_fitted,
1531        observations,
1532        predicted,
1533        residuals,
1534        rms,
1535        iterations,
1536        converged,
1537        sensitivity_ratio,
1538        condition_number,
1539        quality,
1540        reason,
1541        mach_1_2_distance_m,
1542        window_solved_range_m,
1543        muzzle_mach,
1544    };
1545
1546    Ok(report)
1547}
1548
1549/// Downrange envelope (yards) for the extra solve [`truing_mv_window_mach_1_2_crossing`]
1550/// runs to locate the downward Mach-1.2 crossing for the MV-calibration window report
1551/// line (MBA-1405 Task 2): generous enough to cover essentially every practical
1552/// small-arms transonic transition, independent of wherever the caller's own
1553/// observations happen to sit — the window describes the load, not the observation set.
1554const MV_WINDOW_SOLVE_MAX_RANGE_YD: f64 = 3000.0;
1555
1556/// Re-solve the finally fitted `(mv, bc)` load out to [`MV_WINDOW_SOLVE_MAX_RANGE_YD`]
1557/// to read off the downward Mach-1.2 crossing distance (meters) for the MV-calibration
1558/// window (MBA-1405 Task 2). Returns `(mach_1_2_distance_m, solved_max_range_m,
1559/// muzzle_mach)`; the second element lets the report's "no MV window" note name the
1560/// range actually checked rather than the nominal envelope (relevant if the solve
1561/// terminates early, e.g. ground impact). The third element is the Mach number at the
1562/// first trajectory point (`points[0].velocity_magnitude / station_speed_of_sound_mps`,
1563/// the same convention `truing_dsf::apply_dsf` uses) — it lets the caller distinguish,
1564/// when `mach_1_2_distance_m` is `None`, "still supersonic through the whole envelope"
1565/// (muzzle Mach >= 1.2) from "never reaches Mach 1.2 at all" (muzzle Mach < 1.2), which
1566/// need opposite report text (MBA-1405 Task 2 fix pass).
1567fn truing_mv_window_mach_1_2_crossing(
1568    model: &TruingForwardModel<'_>,
1569    mv_fps: f64,
1570    bc: f64,
1571) -> Result<(Option<f64>, f64, f64), Box<dyn Error>> {
1572    let result = solve_truing_trajectory(
1573        mv_fps,
1574        bc,
1575        model.drag_model,
1576        model.mass_gr,
1577        model.diameter_in,
1578        model.zero_yd,
1579        MV_WINDOW_SOLVE_MAX_RANGE_YD,
1580        model.sight_in,
1581        model.temp_f,
1582        model.press_inhg,
1583        model.humidity,
1584        model.alt_ft,
1585        model.bc_segments,
1586    )?;
1587    let muzzle_mach = result.points.first().map_or(0.0, |p| {
1588        if result.station_speed_of_sound_mps > 0.0 {
1589            p.velocity_magnitude / result.station_speed_of_sound_mps
1590        } else {
1591            0.0
1592        }
1593    });
1594    Ok((result.mach_1_2_distance_m, result.max_range, muzzle_mach))
1595}
1596
1597/// RMS of residuals at a candidate `(mv, bc)`.
1598pub(crate) fn rms_at(
1599    model: &TruingForwardModel<'_>,
1600    obs: &[TruingObservation],
1601    mv: f64,
1602    bc: f64,
1603) -> Result<f64, Box<dyn Error>> {
1604    let mut sse = 0.0;
1605    for o in obs {
1606        let r = model.predict(mv, bc, o.range_yd)? - o.drop;
1607        sse += r * r;
1608    }
1609    Ok((sse / obs.len() as f64).sqrt())
1610}
1611
1612/// Plain-language quality assessment for the fit. `rms` is in the user's drop unit
1613/// (displayed); `rms_mil` is the mil-equivalent the bands were calibrated against
1614/// (~0.03 mil observation noise), so the quality word is unit-invariant (MBA-1337 t1).
1615pub(crate) fn truing_quality_line(
1616    bc_fitted: bool,
1617    rms: f64,
1618    rms_mil: f64,
1619    drop_unit: DropUnit,
1620    condition_number: f64,
1621    converged: bool,
1622    n_obs: usize,
1623) -> String {
1624    let unit = drop_unit.label();
1625    let n_params = if bc_fitted { 2 } else { 1 };
1626    // Exactly-determined fit (MBA-1337 t3): zero degrees of freedom drive the
1627    // residuals to ~0 by construction — an "excellent" RMS validates nothing.
1628    if n_obs == n_params {
1629        return format!(
1630            "{} fit is exactly determined ({n_obs} observations, {n_params} fitted \
1631             parameters): residuals are zero by construction and do not validate the \
1632             fit; add an observation to assess quality",
1633            if bc_fitted { "Joint MV+BC" } else { "MV-only" }
1634        );
1635    }
1636    let quality = if rms_mil < 0.05 {
1637        "excellent"
1638    } else if rms_mil < 0.15 {
1639        "good"
1640    } else if rms_mil < 0.4 {
1641        "fair"
1642    } else {
1643        "poor (observations may be inconsistent)"
1644    };
1645    let nonconv = if converged { "" } else { " (did not fully converge)" };
1646    if bc_fitted {
1647        let cond = if condition_number.is_finite() {
1648            format!("{condition_number:.0}")
1649        } else {
1650            "inf".to_string()
1651        };
1652        format!(
1653            "Joint MV+BC fit, {quality}: RMS residual {rms:.3} {unit}, conditioning {cond}{nonconv}"
1654        )
1655    } else {
1656        format!("MV-only fit, {quality}: RMS residual {rms:.3} {unit} (BC held fixed){nonconv}")
1657    }
1658}
1659
1660/// Fraction of the downward Mach 1.2 crossing distance that bounds the near edge of the MV
1661/// (muzzle-velocity) calibration window (MBA-1405): observations closer than this fraction of
1662/// the crossing are considered too far from the transonic region to usefully calibrate MV.
1663const MV_CALIBRATION_WINDOW_START_FRACTION: f64 = 0.90;
1664
1665/// Fraction of the downward Mach 0.9 crossing distance at which the DSF (drag-scale-factor)
1666/// window is considered to start (MBA-1405): DSF observations nearer the muzzle than this are
1667/// still comfortably supersonic/transonic and not representative of the DSF-correction regime.
1668const DSF_WINDOW_START_FRACTION: f64 = 0.90;
1669
1670/// The MV (muzzle-velocity) calibration validity window: `(start_m, end_m)`, where `end_m` is
1671/// the downward Mach 1.2 crossing distance and `start_m` is 90% of it. `None` when the
1672/// trajectory never crosses Mach 1.2 (nothing to bound the window with) — mirrors
1673/// `TrajectoryResult::mach_1_2_distance_m`'s own `None` case, MBA-1405. `pub` (not
1674/// `pub(crate)`): consumed by the native CLI (`main.rs`) and the WASM terminal
1675/// (`wasm.rs`), both separate crates from this lib (MBA-1405 Task 2).
1676pub fn mv_calibration_window(mach_1_2_distance_m: Option<f64>) -> Option<(f64, f64)> {
1677    let d = mach_1_2_distance_m?;
1678    Some((MV_CALIBRATION_WINDOW_START_FRACTION * d, d))
1679}
1680
1681/// The downrange distance (m) at which the DSF (drag-scale-factor) window starts: 90% of the
1682/// downward Mach 0.9 crossing distance. `None` when the trajectory never crosses Mach 0.9 —
1683/// mirrors `TrajectoryResult::mach_0_9_distance_m`'s own `None` case, MBA-1405. `pub`: consumed
1684/// by the native CLI's `dsf` verb (MBA-1405 Task 2).
1685pub fn dsf_window_start(mach_0_9_distance_m: Option<f64>) -> Option<f64> {
1686    Some(DSF_WINDOW_START_FRACTION * mach_0_9_distance_m?)
1687}
1688
1689#[cfg(test)]
1690mod window_helper_tests {
1691    use super::*;
1692
1693    #[test]
1694    fn mv_calibration_window_is_90_to_100_percent_of_the_1_2_crossing() {
1695        assert_eq!(mv_calibration_window(Some(1000.0)), Some((900.0, 1000.0)));
1696        assert_eq!(mv_calibration_window(Some(671.7257336844475)), Some((0.9 * 671.7257336844475, 671.7257336844475)));
1697    }
1698
1699    #[test]
1700    fn mv_calibration_window_passes_through_none() {
1701        assert_eq!(mv_calibration_window(None), None);
1702    }
1703
1704    #[test]
1705    fn dsf_window_start_is_90_percent_of_the_0_9_crossing() {
1706        assert_eq!(dsf_window_start(Some(1000.0)), Some(900.0));
1707        assert_eq!(dsf_window_start(Some(806.5709746782849)), Some(0.9 * 806.5709746782849));
1708    }
1709
1710    #[test]
1711    fn dsf_window_start_passes_through_none() {
1712        assert_eq!(dsf_window_start(None), None);
1713    }
1714
1715    #[test]
1716    fn mv_calibration_window_exact_arithmetic_at_a_representative_distance() {
1717        // Exact f64 arithmetic check (not just structural equality): 0.90 * 500.0 = 450.0
1718        // has an exact binary representation, so this pins the literal formula, not just
1719        // its shape.
1720        let (start, end) = mv_calibration_window(Some(500.0)).expect("Some");
1721        assert_eq!(start.to_bits(), 450.0_f64.to_bits());
1722        assert_eq!(end.to_bits(), 500.0_f64.to_bits());
1723    }
1724
1725    #[test]
1726    fn dsf_window_start_exact_arithmetic_at_a_representative_distance() {
1727        let start = dsf_window_start(Some(500.0)).expect("Some");
1728        assert_eq!(start.to_bits(), 450.0_f64.to_bits());
1729    }
1730}
1731
1732/// MBA-1377: `correct_chrono_velocity_fps` (screen-distance chronograph correction).
1733#[cfg(test)]
1734mod chrono_correction_tests {
1735    use super::*;
1736
1737    /// Hand-computed pin, matching a real chronograph shot: 168 gr, 0.308", BC 0.475 G7, a
1738    /// reading taken 15 ft downrange, dry standard atmosphere (59 F / 29.92124356615747 inHg
1739    /// == 1013.25 hPa exactly / 0% humidity / sea level).
1740    ///
1741    /// Arithmetic (all from published/documented physical constants, not this function).
1742    /// Speed of sound (dry air, ICAO): `a = sqrt(gamma * R_air * T_K)`, gamma = 1.4, R_air =
1743    /// 287.0531 J/(kg K) (see `atmosphere.rs`), T_K = 288.15 (59 F == 15 C exactly), giving
1744    /// `a = sqrt(1.4 * 287.0531 * 288.15) = 340.294124 m/s = 1116.450575 fps`.
1745    ///
1746    /// The measured (screen) velocity is chosen so its Mach number lands exactly on a G7
1747    /// table grid point -- Mach 2.40, Cd = 0.2752 (data/g7.csv row "2.40,0.2752") -- which
1748    /// sidesteps interpolation uncertainty entirely (cubic Hermite interpolation is exact at
1749    /// its own knots): `v_screen = 2.40 * 1116.450575 = 2679.481380 fps`.
1750    ///
1751    /// Air density ratio vs the 1.225 kg/m^3 CD_TO_RETARD reference, via the crate's own
1752    /// published CIPM-2007 formula (`calculate_air_density_cimp`, dry air, sea level, 1013.25
1753    /// hPa, 15 C): `rho = 1.225521 kg/m^3`, `ratio = rho / 1.225 = 1.00042559`.
1754    ///
1755    /// Deceleration (McCoy retardation form, see the `constants::CD_TO_RETARD` doc comment):
1756    /// `a_ft_s2 = Cd * v_fps^2 * (rho/1.225) * CD_TO_RETARD / BC`, CD_TO_RETARD = 2.08551e-4.
1757    /// Over a flight this short, Cd and the density ratio are effectively constant, so
1758    /// `dv/dx = a/v = -(CD_TO_RETARD * Cd * ratio / BC) * v` (linear in v), giving the
1759    /// classic short-range exponential-decay solution `v(x) = v0 * exp(-C*x)`, x in FEET:
1760    /// `C = CD_TO_RETARD * Cd * ratio / BC = 2.08551e-4 * 0.2752 * 1.00042559 / 0.475
1761    /// = 1.2087929e-4` (1/ft), so `v0 = v_screen * exp(C * 15)
1762    /// = 2679.481380 * exp(1.2087929e-4 * 15) = 2684.344194 fps`.
1763    ///
1764    /// The real solver differs only by the (second-order, and here tiny) fact that its Cd is
1765    /// evaluated continuously at the LOCAL velocity along the RK45 path rather than held fixed
1766    /// at v_screen's value; the measured residual is ~0.0024 fps, so 0.05 fps is a safe,
1767    /// still-tight pin tolerance (0.001% of muzzle velocity).
1768    #[test]
1769    fn hand_computed_pin_168gr_308_bc475_g7_15ft() {
1770        let measured_screen_velocity_fps = 2679.481379882625;
1771        let correction = correct_chrono_velocity_fps(
1772            measured_screen_velocity_fps,
1773            15.0 * 0.3048, // 15 ft screen distance, in meters
1774            0.475,         // BC
1775            DragModelArg::G7,
1776            168.0,               // mass, grains
1777            0.308,               // diameter, inches
1778            59.0,                // temperature, F (== 15 C exactly)
1779            1013.25 / 33.8639,   // pressure, inHg (== 1013.25 hPa exactly)
1780            0.0,                 // humidity, % (dry air keeps the arithmetic exact)
1781            0.0,                 // altitude, ft
1782            &None,
1783        )
1784        .expect("hand-computed case must converge");
1785
1786        let expected_v0 = 2684.344194108996;
1787        assert!(
1788            (correction.muzzle_velocity_fps - expected_v0).abs() < 0.05,
1789            "got {}, expected {} +/- 0.05",
1790            correction.muzzle_velocity_fps,
1791            expected_v0
1792        );
1793        // The corrected muzzle velocity must exceed the raw downrange reading -- drag only
1794        // decelerates, so a chronograph reading downrange always understates true MV.
1795        assert!(correction.muzzle_velocity_fps > measured_screen_velocity_fps);
1796        // McCoy/JBM: this band converges in a handful of secant iterations, not dozens.
1797        assert!(
1798            correction.iterations <= 6,
1799            "expected fast convergence, took {} iterations",
1800            correction.iterations
1801        );
1802    }
1803
1804    /// Realistic smoke case from the task brief: 168 gr .308, BC 0.475 G7, 2680 fps measured
1805    /// at 15 ft, default atmosphere. The corrected muzzle velocity must be higher than the
1806    /// measured value by a small, physically plausible margin (a few fps, not tens).
1807    #[test]
1808    fn smoke_2680fps_at_15ft_corrects_upward_by_a_plausible_margin() {
1809        let correction = correct_chrono_velocity_fps(
1810            2680.0,
1811            15.0 * 0.3048,
1812            0.475,
1813            DragModelArg::G7,
1814            168.0,
1815            0.308,
1816            59.0,
1817            29.92,
1818            50.0,
1819            0.0,
1820            &None,
1821        )
1822        .expect("realistic case must converge");
1823
1824        let margin = correction.muzzle_velocity_fps - 2680.0;
1825        assert!(
1826            (0.5..15.0).contains(&margin),
1827            "expected a plausible few-fps correction, got {margin} fps (V0={})",
1828            correction.muzzle_velocity_fps
1829        );
1830    }
1831
1832    /// Convergence across the full stated screen-distance band (3-25 m): every distance in
1833    /// range solves without error, in a small number of secant iterations, and the correction
1834    /// grows monotonically with distance (more travel -> more velocity lost -> a bigger
1835    /// muzzle-velocity correction to explain the same downrange reading).
1836    #[test]
1837    fn convergence_across_the_3_to_25_m_band() {
1838        let mut last_correction = 0.0;
1839        for distance_m in [3.0, 5.0, 10.0, 15.0, 20.0, 25.0] {
1840            let correction = correct_chrono_velocity_fps(
1841                2680.0,
1842                distance_m,
1843                0.475,
1844                DragModelArg::G7,
1845                168.0,
1846                0.308,
1847                59.0,
1848                29.92,
1849                50.0,
1850                0.0,
1851                &None,
1852            )
1853            .unwrap_or_else(|e| panic!("distance {distance_m} m must converge: {e}"));
1854            assert!(
1855                correction.iterations <= 6,
1856                "distance {distance_m} m took {} iterations",
1857                correction.iterations
1858            );
1859            let delta = correction.muzzle_velocity_fps - 2680.0;
1860            assert!(
1861                delta > last_correction,
1862                "correction must grow with distance: {delta} <= {last_correction} at {distance_m} m"
1863            );
1864            last_correction = delta;
1865        }
1866    }
1867
1868    /// Absent distance is handled entirely by the CLI/WASM callers (they special-case
1869    /// `None`/`0.0` as a byte-identical no-op and never call into this solver at all); at this
1870    /// layer, an explicit zero screen distance is simply out of the validated band and must be
1871    /// rejected rather than silently treated as "at the muzzle" by a solver that has no
1872    /// special-case for it.
1873    #[test]
1874    fn zero_distance_is_rejected_by_the_low_level_solver() {
1875        let err = correct_chrono_velocity_fps(
1876            2680.0, 0.0, 0.475, DragModelArg::G7, 168.0, 0.308, 59.0, 29.92, 50.0, 0.0, &None,
1877        )
1878        .unwrap_err();
1879        assert!(err.to_string().contains("out of range"), "{err}");
1880    }
1881
1882    /// Out-of-band screen distances (negative, too-short, too-long) are rejected outright
1883    /// rather than silently clamped or extrapolated into a garbage muzzle velocity.
1884    #[test]
1885    fn out_of_band_distances_are_rejected() {
1886        for bad_distance_m in [-5.0, -0.001, 0.05, 30.001, 100.0, f64::NAN, f64::INFINITY] {
1887            let err = correct_chrono_velocity_fps(
1888                2680.0,
1889                bad_distance_m,
1890                0.475,
1891                DragModelArg::G7,
1892                168.0,
1893                0.308,
1894                59.0,
1895                29.92,
1896                50.0,
1897                0.0,
1898                &None,
1899            )
1900            .unwrap_err();
1901            assert!(
1902                err.to_string().contains("out of range"),
1903                "distance {bad_distance_m}: {err}"
1904            );
1905        }
1906    }
1907
1908    /// A non-positive or non-finite measured velocity is rejected outright.
1909    #[test]
1910    fn invalid_measured_velocity_is_rejected() {
1911        for bad_v in [0.0, -100.0, f64::NAN, f64::INFINITY] {
1912            let err = correct_chrono_velocity_fps(
1913                bad_v, 4.572, 0.475, DragModelArg::G7, 168.0, 0.308, 59.0, 29.92, 50.0, 0.0, &None,
1914            )
1915            .unwrap_err();
1916            assert!(err.to_string().contains("must be positive and finite"), "{err}");
1917        }
1918    }
1919
1920    /// The correction must actually consult the configured drag model, not a hardcoded G1: the
1921    /// SAME (measured velocity, distance, BC) triple fed through G1 and G7 must back-solve to
1922    /// DIFFERENT muzzle velocities, since the two standard drag curves are numerically distinct
1923    /// at this Mach range.
1924    #[test]
1925    fn drag_model_is_actually_consulted_g1_vs_g7_diverge() {
1926        let g1 = correct_chrono_velocity_fps(
1927            2680.0,
1928            15.0 * 0.3048,
1929            0.475,
1930            DragModelArg::G1,
1931            168.0,
1932            0.308,
1933            59.0,
1934            29.92,
1935            50.0,
1936            0.0,
1937            &None,
1938        )
1939        .expect("G1 must converge");
1940        let g7 = correct_chrono_velocity_fps(
1941            2680.0,
1942            15.0 * 0.3048,
1943            0.475,
1944            DragModelArg::G7,
1945            168.0,
1946            0.308,
1947            59.0,
1948            29.92,
1949            50.0,
1950            0.0,
1951            &None,
1952        )
1953        .expect("G7 must converge");
1954
1955        assert!(
1956            (g1.muzzle_velocity_fps - g7.muzzle_velocity_fps).abs() > 0.05,
1957            "G1 ({}) and G7 ({}) corrections should differ measurably for the same inputs",
1958            g1.muzzle_velocity_fps,
1959            g7.muzzle_velocity_fps
1960        );
1961    }
1962}