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