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