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