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