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