Skip to main content

ballistics_engine/
truing_wind.rs

1//! MBA-1392: back-solve the effective crosswind from an observed horizontal miss.
2//!
3//! The rest of the truing family fits muzzle velocity and BC from VERTICAL drop. This
4//! module fits the other axis: given where a group actually landed left/right of the aim
5//! point, it reports the constant crosswind that reproduces that miss through the real
6//! forward model — the number a shooter compares against the wind they called, so their
7//! wind calls can be calibrated instead of guessed at.
8//!
9//! # Sign conventions (the whole set, in one place)
10//!
11//! * **Observed miss** ([`WindObservation::miss_right_m`], CLI `--miss`): signed, POSITIVE
12//!   = the group landed RIGHT of the aim point.
13//! * **Solved crosswind** ([`WindTruingSolution::solved_crosswind_mph`]): signed, and the
14//!   sign follows the deflection it produces — POSITIVE = a wind FROM the shooter's LEFT
15//!   (9 o'clock) that pushes impacts RIGHT; NEGATIVE = a wind FROM the shooter's RIGHT
16//!   (3 o'clock) pushing impacts LEFT. It is a full-value crosswind (a 90-degree wind),
17//!   not a half-value component of some other bearing.
18//! * That maps onto the engine's wind-FROM convention ([`crate::wind::wind_vector`],
19//!   `0 = headwind`, `PI/2 = from the right`, `3*PI/2 = from the left`) — the convention
20//!   established by the 0.19.0 wind-direction sign fix, which flipped `0` from tailwind
21//!   to headwind. A positive solved crosswind is therefore direction `3*PI/2`.
22//! * **Twist** ([`crate::truing::TruingTwist::right_hand`]): a right-hand twist drifts
23//!   RIGHT (positive lateral), a left-hand twist drifts LEFT.
24//! * **Shot azimuth** ([`crate::truing::TruingEarthFrame::shot_azimuth_deg`]): compass
25//!   bearing fired ALONG, 0 = North, 90 = East.
26//!
27//! # What the solved wind actually contains
28//!
29//! A horizontal miss is not purely wind. Spin drift is always modelled here (a twist rate
30//! is required, precisely so it can be), and Coriolis is modelled when a latitude and shot
31//! azimuth are supplied. Anything the model was not given data for stays ABSORBED in the
32//! solved crosswind, and the report says so ([`WindTruingReport::unsubtracted_effects`])
33//! rather than quietly presenting a contaminated number as pure wind.
34//!
35//! # No scope-tracking correction
36//!
37//! `--miss` values are LINEAR measurements off the target (inches), not dial readings, so
38//! the MBA-1358 tracking correction factor does NOT apply to them — only DIALED
39//! observations are CF-converted. This module deliberately has no CF input.
40
41use std::error::Error;
42
43use serde::{Deserialize, Serialize};
44
45use crate::cli_api::UnitSystem;
46use crate::truing::{
47    DragModelArg, TruingEarthFrame, TruingEnvironment, TruingTwist, TRUING_BC_MAX, TRUING_BC_MIN,
48    TRUING_MV_MAX_FPS, TRUING_MV_MIN_FPS,
49};
50use crate::{BCSegmentData, WindConditions};
51
52/// Miles per hour to meters per second (exact, by definition of the international mile).
53/// `pub` so the front ends convert `--called-wind` with the same factor this module
54/// renders with, rather than each re-typing the literal.
55pub const MPH_TO_MPS: f64 = 0.44704;
56
57/// Widest crosswind the solver will bracket, in mph (signed, so the bracket is
58/// `-100..=+100`). Comfortably past any wind a rifle shooter reports; a miss that needs
59/// more than this is a data-entry or sign error, not a wind call, and is rejected with a
60/// diagnostic rather than solved into a fantasy number.
61pub const MAX_SOLVABLE_CROSSWIND_MPH: f64 = 100.0;
62
63/// Convergence tolerance on the modelled-minus-observed lateral miss, in meters
64/// (0.01 mm — four orders of magnitude finer than anyone can measure a group centre).
65/// `pub` because it is the published meaning of a converged
66/// [`WindTruingSolution::residual_m`].
67pub const WIND_SOLVE_TOLERANCE_M: f64 = 1.0e-5;
68
69/// Bracket width (mph) at which the root find stops regardless of residual: the two ends
70/// are numerically the same wind, so further bisection cannot improve the answer.
71const WIND_SOLVE_MIN_BRACKET_MPH: f64 = 1.0e-9;
72
73/// Iteration cap for the per-observation root find. Lateral deflection is monotone and
74/// near-linear in crosswind speed (the Didion lag-time relation), so the bracketed
75/// false-position iteration below normally converges in well under ten evaluations; this
76/// is the runaway guard, not the expected count.
77const WIND_SOLVE_MAX_ITERATIONS: u32 = 60;
78
79/// Central-difference step (mph) used to measure how strongly the observation constrains
80/// the wind. Large enough that the trajectory integrator's own sampling noise does not
81/// dominate the difference, small enough to stay in the locally-linear regime.
82const WIND_SENSITIVITY_STEP_MPH: f64 = 0.5;
83
84/// Guide value for the wind-truing validity note: below this many inches of lateral
85/// movement per mph of crosswind, the observation barely constrains the wind and the
86/// number solved from it is weakly identified. Same spirit as the MV-calibration window
87/// (MBA-1405) — a stated band the report checks each observation against, not a hard gate.
88pub const MIN_WIND_SENSITIVITY_IN_PER_MPH: f64 = 0.25;
89
90/// A single observed horizontal miss used to back-solve effective wind (MBA-1392).
91///
92/// SI throughout; front ends convert their display units once at the boundary (see
93/// [`parse_wind_observation`]).
94#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
95#[serde(deny_unknown_fields)]
96pub struct WindObservation {
97    /// Range at which the group centre was measured, meters.
98    pub range_m: f64,
99    /// Signed horizontal miss of the group centre, meters. POSITIVE = RIGHT of aim.
100    pub miss_right_m: f64,
101    /// Optional one-standard-deviation measurement error of `miss_right_m`, meters.
102    /// Supply it on every observation or on none — see [`WindTruingRequest::validate`].
103    pub sigma_m: Option<f64>,
104}
105
106/// Parse a `--miss RANGE:RIGHT[:SIGMA]` token.
107///
108/// `RANGE` is in the caller's distance units (yards imperial / meters metric); `RIGHT` and
109/// `SIGMA` are LINEAR INCHES in both unit systems, matching the existing `--drop-unit in`
110/// contract (a tape measurement off the target is reported in inches whatever the range
111/// unit is). Returns a user-facing error string on malformed input.
112pub fn parse_wind_observation(s: &str, units: UnitSystem) -> Result<WindObservation, String> {
113    let parts: Vec<&str> = s.split(':').collect();
114    if parts.len() != 2 && parts.len() != 3 {
115        return Err(format!(
116            "invalid --miss '{s}': expected RANGE:RIGHT_IN[:SIGMA] (e.g. 600:8.5 or 600:8.5:0.75)"
117        ));
118    }
119    let range: f64 = parts[0]
120        .trim()
121        .parse()
122        .map_err(|_| format!("invalid --miss range '{}' in '{s}'", parts[0]))?;
123    let miss_in: f64 = parts[1]
124        .trim()
125        .parse()
126        .map_err(|_| format!("invalid --miss offset '{}' in '{s}'", parts[1]))?;
127    let sigma_in: Option<f64> = match parts.get(2) {
128        Some(token) => Some(
129            token
130                .trim()
131                .parse()
132                .map_err(|_| format!("invalid --miss sigma '{token}' in '{s}'"))?,
133        ),
134        None => None,
135    };
136    if !range.is_finite() || !miss_in.is_finite() || sigma_in.is_some_and(|v| !v.is_finite()) {
137        return Err(format!("invalid --miss '{s}': values must be finite"));
138    }
139    let range_m = match units {
140        UnitSystem::Imperial => range * 0.9144,
141        UnitSystem::Metric => range,
142    };
143    Ok(WindObservation {
144        range_m,
145        miss_right_m: miss_in * 0.0254,
146        sigma_m: sigma_in.map(|v| v * 0.0254),
147    })
148}
149
150/// Everything the wind fit needs: the observed misses plus the load, rifle, atmosphere and
151/// the opt-in earth frame (MBA-1392).
152///
153/// Imperial units for the load/atmosphere fields, matching the truing core's historical
154/// internal convention; the observations themselves are SI. Unlike the drop-based truing
155/// commands this carries a KNOWN muzzle velocity — wind is the unknown being fitted, so
156/// velocity is an input, not an output.
157#[derive(Debug, Clone, Serialize, Deserialize)]
158#[serde(deny_unknown_fields)]
159pub struct WindTruingRequest {
160    /// One or more observed horizontal misses. Ranges must be distinct.
161    pub observations: Vec<WindObservation>,
162    /// Known muzzle velocity, feet/second (true up first with `true-velocity` if unsure).
163    pub muzzle_velocity_fps: f64,
164    /// Scalar ballistic coefficient for `drag_model`.
165    pub bc: f64,
166    pub drag_model: DragModelArg,
167    /// Bullet mass in grains.
168    pub mass_gr: f64,
169    /// Bullet diameter in inches.
170    pub diameter_in: f64,
171    /// Zero distance in yards.
172    pub zero_distance_yd: f64,
173    /// Sight height over bore in inches.
174    pub sight_height_in: f64,
175    /// Ambient temperature in degrees Fahrenheit.
176    pub temperature_f: f64,
177    /// Station pressure in inches of mercury.
178    pub pressure_inhg: f64,
179    /// Relative humidity in percent (0 through 100).
180    pub humidity_pct: f64,
181    /// Altitude in feet.
182    pub altitude_ft: f64,
183    /// Barrel twist. REQUIRED: spin drift is a lateral effect of the same order as a
184    /// light wind at long range, so without it the fit would silently report spin drift
185    /// as wind.
186    pub twist: TruingTwist,
187    /// Latitude + shot azimuth. `None` leaves Coriolis unmodelled and absorbed into the
188    /// solved wind (the report names it as unsubtracted).
189    pub earth: Option<TruingEarthFrame>,
190    /// The wind the shooter CALLED, mph, in the same signed convention as the solved
191    /// value. `Some` adds a wind-call correction factor (solved / called) to the report.
192    pub called_crosswind_mph: Option<f64>,
193}
194
195impl WindTruingRequest {
196    /// Validate the whole request before any (expensive) trajectory work begins.
197    ///
198    /// Mirrors [`crate::truing::TruingModelInputsV1::validate`] plus the observation-set
199    /// rules from [`crate::truing::validate_truing_observations`]: finite positive ranges,
200    /// finite misses, no duplicate ranges. Sigmas are all-or-none — a half-weighted set
201    /// would silently mix an inverse-variance mean with unit weights.
202    pub fn validate(&self) -> Result<(), String> {
203        if self.observations.is_empty() {
204            return Err("at least one observed horizontal miss is required".to_string());
205        }
206        if !self.muzzle_velocity_fps.is_finite()
207            || !(TRUING_MV_MIN_FPS..=TRUING_MV_MAX_FPS).contains(&self.muzzle_velocity_fps)
208        {
209            return Err(format!(
210                "muzzle velocity must be finite and within {TRUING_MV_MIN_FPS:.0}..={TRUING_MV_MAX_FPS:.0} fps"
211            ));
212        }
213        if !self.bc.is_finite() || !(TRUING_BC_MIN..=TRUING_BC_MAX).contains(&self.bc) {
214            return Err(format!(
215                "ballistic coefficient must be finite and within {TRUING_BC_MIN:.2}..={TRUING_BC_MAX:.1}"
216            ));
217        }
218        for (name, value) in [
219            ("bullet mass", self.mass_gr),
220            ("bullet diameter", self.diameter_in),
221            ("zero distance", self.zero_distance_yd),
222            ("sight height", self.sight_height_in),
223            ("pressure", self.pressure_inhg),
224            ("twist rate", self.twist.rate_in),
225        ] {
226            if !value.is_finite() || value <= 0.0 {
227                return Err(format!("{name} must be positive and finite"));
228            }
229        }
230        if !self.temperature_f.is_finite() {
231            return Err("temperature must be finite".to_string());
232        }
233        if !self.humidity_pct.is_finite() || !(0.0..=100.0).contains(&self.humidity_pct) {
234            return Err("humidity must be finite and within 0..=100 percent".to_string());
235        }
236        if !self.altitude_ft.is_finite() {
237            return Err("altitude must be finite".to_string());
238        }
239        if let Some(earth) = self.earth {
240            if !earth.latitude_deg.is_finite() || !(-90.0..=90.0).contains(&earth.latitude_deg) {
241                return Err("latitude must be finite and within -90..=90 degrees".to_string());
242            }
243            if !earth.shot_azimuth_deg.is_finite() {
244                return Err("shot azimuth must be finite".to_string());
245            }
246        }
247        if let Some(called) = self.called_crosswind_mph {
248            if !called.is_finite() || called == 0.0 {
249                return Err(
250                    "the called wind must be finite and non-zero (a zero call has no \
251                     correction factor)"
252                        .to_string(),
253                );
254            }
255        }
256        for observation in &self.observations {
257            if !observation.range_m.is_finite() || observation.range_m <= 0.0 {
258                return Err(format!(
259                    "observation range must be a positive finite distance (got {})",
260                    observation.range_m
261                ));
262            }
263            if !observation.miss_right_m.is_finite() {
264                return Err("observed horizontal miss must be finite".to_string());
265            }
266            if observation
267                .sigma_m
268                .is_some_and(|sigma| !sigma.is_finite() || sigma <= 0.0)
269            {
270                return Err("an observed-miss sigma must be positive and finite".to_string());
271            }
272        }
273        for i in 0..self.observations.len() {
274            for j in (i + 1)..self.observations.len() {
275                if (self.observations[i].range_m - self.observations[j].range_m).abs() < 1e-6 {
276                    return Err(format!(
277                        "duplicate observation range ({:.3} m): each observed miss must be at a \
278                         distinct range",
279                        self.observations[i].range_m
280                    ));
281                }
282            }
283        }
284        let with_sigma = self
285            .observations
286            .iter()
287            .filter(|o| o.sigma_m.is_some())
288            .count();
289        if with_sigma != 0 && with_sigma != self.observations.len() {
290            return Err(
291                "supply a sigma on every observed miss or on none: mixing weighted and \
292                 unweighted observations would silently combine inverse-variance weights \
293                 with unit weights"
294                    .to_string(),
295            );
296        }
297        Ok(())
298    }
299}
300
301/// The wind fitted from ONE observed miss (MBA-1392).
302#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
303pub struct WindTruingSolution {
304    /// Observation range, meters.
305    pub range_m: f64,
306    /// The observed miss that was fitted, meters (positive = right).
307    pub observed_miss_right_m: f64,
308    /// The supplied measurement sigma, meters, if any.
309    pub sigma_m: Option<f64>,
310    /// The fitted constant crosswind, mph, signed (positive = from the left, pushing right).
311    pub solved_crosswind_mph: f64,
312    /// The model's lateral miss at `solved_crosswind_mph`, meters — equals the observed
313    /// miss to within [`WIND_SOLVE_TOLERANCE_M`] on a converged solve.
314    pub modeled_miss_right_m: f64,
315    /// `modeled_miss_right_m - observed_miss_right_m`, meters.
316    pub residual_m: f64,
317    /// The model's lateral miss with ZERO wind, meters: the part of the observation
318    /// attributed to spin drift (always) and Coriolis (when an earth frame was supplied)
319    /// rather than to wind.
320    pub no_wind_lateral_m: f64,
321    /// How far the impact moves per mph of crosswind at the solution, meters/mph — the
322    /// identifiability measure behind the report's weak-signal note.
323    pub sensitivity_m_per_mph: f64,
324    /// The observation sigma propagated into wind units, mph
325    /// (`sigma_m / |sensitivity_m_per_mph|`); `None` when no sigma was supplied.
326    pub solved_sigma_mph: Option<f64>,
327    /// Root-find iterations actually run.
328    pub iterations: u32,
329    /// Whether the root find hit its tolerance (`false` = the reported value is the best
330    /// estimate at the iteration cap).
331    pub converged: bool,
332}
333
334/// Two-sided 95% Student-t quantiles, indexed by (dof - 1) for dof 1..=30. Beyond 30 the
335/// t quantile is within 0.5% of the normal z, so [`NORMAL_95_TWO_SIDED_Z`] is used instead.
336/// Generated from the regularized incomplete beta and checked against published tables.
337const T_95_TWO_SIDED: [f64; 30] = [
338    12.706204736, 4.302652730, 3.182446305, 2.776445105, 2.570581836, 2.446911851,
339    2.364624252, 2.306004135, 2.262157163, 2.228138852, 2.200985160, 2.178812830,
340    2.160368656, 2.144786688, 2.131449546, 2.119905299, 2.109815578, 2.100922040,
341    2.093024054, 2.085963447, 2.079613845, 2.073873068, 2.068657610, 2.063898562,
342    2.059538553, 2.055529439, 2.051830516, 2.048407142, 2.045229642, 2.042272456,
343];
344
345/// Two-sided 95% normal quantile, matching `truing_uncertainty`'s constant.
346const NORMAL_95_TWO_SIDED_Z: f64 = 1.959_963_984_540_054;
347
348/// The nominal coverage of every interval this module reports.
349const WIND_INTERVAL_PROBABILITY: f64 = 0.95;
350
351/// Which of the two independent uncertainty estimates set the reported interval.
352#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
353#[serde(rename_all = "snake_case")]
354pub enum WindUncertaintyBasisV1 {
355    /// The spread of the per-observation solved winds: how much the shots actually
356    /// disagree. Needs no input from the shooter and is a Student-t interval on `dof`.
357    EmpiricalScatter,
358    /// The supplied measurement sigmas propagated into wind units. A normal interval,
359    /// because a supplied sigma is treated as known rather than estimated.
360    PropagatedMeasurement,
361}
362
363/// Why no interval could be produced.
364#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
365#[serde(rename_all = "snake_case")]
366pub enum WindUncertaintyFailureCodeV1 {
367    /// One observation and no supplied sigma: nothing to estimate a spread from. A single
368    /// shot cannot disagree with itself, and this is reported rather than papered over.
369    SingleObservation,
370    /// Every candidate estimate was zero or non-finite.
371    NoUsableEstimate,
372}
373
374/// Structured explanation for an absent interval, mirroring `true.fit`'s failure shape.
375#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
376pub struct WindUncertaintyFailureV1 {
377    pub code: WindUncertaintyFailureCodeV1,
378    pub message: String,
379}
380
381/// A two-sided interval on the combined crosswind, plus both estimates behind it.
382#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
383pub struct WindIntervalV1 {
384    /// The standard error that set the interval, mph — the LARGER of the two estimates.
385    pub sigma_mph: f64,
386    /// Nominal coverage (0.95).
387    pub probability: f64,
388    /// Lower endpoint, mph.
389    pub low_mph: f64,
390    /// Upper endpoint, mph.
391    pub high_mph: f64,
392    /// Which estimate won.
393    pub basis: WindUncertaintyBasisV1,
394    /// Standard error of the mean from observed scatter, mph; `None` with one observation.
395    pub empirical_sigma_mph: Option<f64>,
396    /// Standard error from propagating supplied sigmas, mph; `None` when sigmas were not
397    /// supplied on every observation.
398    pub propagated_sigma_mph: Option<f64>,
399    /// Degrees of freedom for the Student-t interval; `None` when the normal was used.
400    pub dof: Option<u32>,
401}
402
403/// The interval, or a structured reason there is not one.
404///
405/// This is a REQUIRED field of [`WindTruingReport`] and is deliberately not an
406/// `Option`: a wind fit from a handful of shots produces a confident-looking number, and
407/// an absent interval must be explained rather than simply missing.
408#[derive(Debug, Clone, PartialEq, Serialize)]
409#[serde(rename_all = "snake_case", tag = "status", content = "detail")]
410pub enum WindUncertaintyV1 {
411    Available(WindIntervalV1),
412    Unavailable(WindUncertaintyFailureV1),
413}
414
415/// Build the interval from the per-observation fits and the propagated sigma.
416///
417/// The two estimates answer different questions -- "how precisely did I measure each
418/// miss" versus "how much do my shots actually disagree" -- and the WIDER one is
419/// reported. A shooter whose stated sigmas are optimistic relative to their own scatter
420/// gets the honest interval rather than the flattering one.
421fn build_wind_uncertainty(
422    solutions: &[WindTruingSolution],
423    mean_crosswind_mph: f64,
424    propagated_sigma_mph: Option<f64>,
425) -> WindUncertaintyV1 {
426    let n = solutions.len();
427
428    // Standard error of the mean from observed scatter (needs at least two shots).
429    let empirical_sigma_mph = if n >= 2 {
430        let mean: f64 = solutions.iter().map(|s| s.solved_crosswind_mph).sum::<f64>() / n as f64;
431        let var = solutions
432            .iter()
433            .map(|s| {
434                let d = s.solved_crosswind_mph - mean;
435                d * d
436            })
437            .sum::<f64>()
438            / (n as f64 - 1.0); // sample variance, Bessel-corrected
439        let se = (var / n as f64).sqrt();
440        if se.is_finite() && se > 0.0 {
441            Some(se)
442        } else {
443            None
444        }
445    } else {
446        None
447    };
448
449    let propagated = propagated_sigma_mph.filter(|v| v.is_finite() && *v > 0.0);
450
451    // Wider wins. When only one exists, it is used on its own terms.
452    let (sigma_mph, basis, dof) = match (empirical_sigma_mph, propagated) {
453        (Some(e), Some(p)) if e >= p => (e, WindUncertaintyBasisV1::EmpiricalScatter, Some(n as u32 - 1)),
454        (Some(_), Some(p)) => (p, WindUncertaintyBasisV1::PropagatedMeasurement, None),
455        (Some(e), None) => (e, WindUncertaintyBasisV1::EmpiricalScatter, Some(n as u32 - 1)),
456        (None, Some(p)) => (p, WindUncertaintyBasisV1::PropagatedMeasurement, None),
457        (None, None) => {
458            let (code, message) = if n < 2 {
459                (
460                    WindUncertaintyFailureCodeV1::SingleObservation,
461                    "a single observation with no measurement sigma gives nothing to                      estimate an interval from — shoot more observations, or supply a                      sigma on this one"
462                        .to_string(),
463                )
464            } else {
465                (
466                    WindUncertaintyFailureCodeV1::NoUsableEstimate,
467                    "the observations agree exactly and no measurement sigma was supplied,                      so the spread is zero — this reflects too few distinct observations,                      not a perfectly known wind"
468                        .to_string(),
469                )
470            };
471            return WindUncertaintyV1::Unavailable(WindUncertaintyFailureV1 { code, message });
472        }
473    };
474
475    let multiplier = match dof {
476        Some(d) if d >= 1 => *T_95_TWO_SIDED
477            .get((d - 1) as usize)
478            .unwrap_or(&NORMAL_95_TWO_SIDED_Z),
479        _ => NORMAL_95_TWO_SIDED_Z,
480    };
481    let half_width = multiplier * sigma_mph;
482    if !half_width.is_finite() {
483        return WindUncertaintyV1::Unavailable(WindUncertaintyFailureV1 {
484            code: WindUncertaintyFailureCodeV1::NoUsableEstimate,
485            message: "the interval half-width was not finite".to_string(),
486        });
487    }
488
489    WindUncertaintyV1::Available(WindIntervalV1 {
490        sigma_mph,
491        probability: WIND_INTERVAL_PROBABILITY,
492        low_mph: mean_crosswind_mph - half_width,
493        high_mph: mean_crosswind_mph + half_width,
494        basis,
495        empirical_sigma_mph,
496        propagated_sigma_mph: propagated,
497        dof,
498    })
499}
500
501/// The complete wind-truing result: one fit per observation plus the combined answer.
502#[derive(Debug, Clone, Serialize)]
503pub struct WindTruingReport {
504    /// Per-observation fits, in the order the observations were supplied.
505    pub solutions: Vec<WindTruingSolution>,
506    /// Combined effective crosswind, mph, signed.
507    pub mean_crosswind_mph: f64,
508    /// One-sigma uncertainty of `mean_crosswind_mph`, mph; `Some` only when every
509    /// observation carried a sigma. Retained for compatibility; prefer `uncertainty`,
510    /// which also covers the case where no sigmas were supplied.
511    pub mean_sigma_mph: Option<f64>,
512    /// A two-sided 95% interval on `mean_crosswind_mph`, or a structured reason there is
513    /// none. Always present.
514    pub uncertainty: WindUncertaintyV1,
515    /// `true` when the mean is inverse-variance weighted (all sigmas supplied), `false`
516    /// when it is the plain arithmetic mean.
517    pub inverse_variance_weighted: bool,
518    /// The wind the shooter called, mph, if supplied.
519    pub called_crosswind_mph: Option<f64>,
520    /// `mean_crosswind_mph / called_crosswind_mph`: >1 means the shooter under-called the
521    /// wind, <1 means they over-called it, negative means they called the wrong side.
522    pub wind_call_factor: Option<f64>,
523    /// Lateral effects the model actually accounted for, so they are NOT in the solved wind.
524    pub subtracted_effects: Vec<String>,
525    /// Lateral effects the model had no data for, which therefore ARE absorbed into the
526    /// solved wind. Empty means everything this model knows about was subtracted.
527    pub unsubtracted_effects: Vec<String>,
528}
529
530/// Wind conditions describing a signed full-value crosswind (MBA-1392).
531///
532/// Uses the engine's wind-FROM convention: `PI/2` is a wind FROM the shooter's RIGHT
533/// (pushing impacts LEFT) and `3*PI/2` is FROM the LEFT (pushing impacts RIGHT). The
534/// signed input follows the DEFLECTION, so positive maps to `3*PI/2`. The pair is
535/// continuous through zero — both directions give the zero vector at zero speed — so the
536/// root find sees a smooth function across the sign change.
537fn crosswind_conditions(signed_mph: f64) -> WindConditions {
538    WindConditions {
539        speed: signed_mph.abs() * MPH_TO_MPS,
540        direction: if signed_mph < 0.0 {
541            std::f64::consts::FRAC_PI_2
542        } else {
543            3.0 * std::f64::consts::FRAC_PI_2
544        },
545        vertical_speed: 0.0,
546    }
547}
548
549/// The FORWARD direction of [`solve_wind_truing`]: the lateral miss (meters, positive =
550/// right of the line of sight) this request's load / atmosphere / twist / earth frame
551/// predicts at `range_m` under a signed `crosswind_mph` (MBA-1392).
552///
553/// `request.observations` and `request.called_crosswind_mph` are ignored — only the model
554/// half is used — and no validation is run, so a caller that has not validated its request
555/// will simply see the solver's own error. Public because "what miss does N mph produce?"
556/// is the question a wind-call drill actually asks, and because it lets any caller check
557/// that the inversion round-trips on its own model rather than trusting it to.
558pub fn modeled_miss_right_m(
559    request: &WindTruingRequest,
560    crosswind_mph: f64,
561    range_m: f64,
562) -> Result<f64, Box<dyn Error>> {
563    let no_bc_segments: Option<Vec<BCSegmentData>> = None;
564    let env = TruingEnvironment {
565        wind: crosswind_conditions(crosswind_mph),
566        twist: Some(request.twist),
567        earth: request.earth,
568    };
569    let sample = crate::truing::solve_trajectory_sample(
570        request.muzzle_velocity_fps,
571        request.bc,
572        request.drag_model,
573        request.mass_gr,
574        request.diameter_in,
575        request.zero_distance_yd,
576        range_m / 0.9144,
577        request.sight_height_in,
578        request.temperature_f,
579        request.pressure_inhg,
580        request.humidity_pct,
581        request.altitude_ft,
582        &no_bc_segments,
583        &env,
584        true, // interpolate: land exactly on the requested range
585    )?;
586    Ok(sample.lateral_m)
587}
588
589/// Back-solve the effective crosswind from every observed miss in `request` (MBA-1392).
590///
591/// Each observation is fitted independently with a bracketed root find on the constant
592/// crosswind speed, against the real forward trajectory model (the truing core's own
593/// solver assembly, zero-angle solve and atmosphere — the same one the drop-based truing
594/// commands use, now sampled on the lateral axis and carrying spin drift, and optionally
595/// Coriolis). See [`modeled_miss_right_m`] for that forward direction on its own.
596///
597/// Errors on an invalid request, on any trajectory-solver failure, and when an observed
598/// miss cannot be produced by any crosswind inside `+/-`[`MAX_SOLVABLE_CROSSWIND_MPH`] —
599/// which in practice means the miss was entered with the wrong sign, or is not a wind
600/// effect at all.
601pub fn solve_wind_truing(request: &WindTruingRequest) -> Result<WindTruingReport, Box<dyn Error>> {
602    request.validate()?;
603
604    // No BC5D velocity-banded segments on this path: the wind fit exposes one scalar BC,
605    // matching the scalar-BC truing model (a banded schedule has no single BC to pair
606    // with the fitted wind), and keeps the command entirely offline.
607    let no_bc_segments: Option<Vec<BCSegmentData>> = None;
608
609    let environment = |crosswind_mph: f64| TruingEnvironment {
610        wind: crosswind_conditions(crosswind_mph),
611        twist: Some(request.twist),
612        earth: request.earth,
613    };
614
615    // Lateral (McCoy z, positive = right) at `range_yd` for a candidate crosswind.
616    let lateral_at = |crosswind_mph: f64, range_yd: f64| -> Result<f64, Box<dyn Error>> {
617        let sample = crate::truing::solve_trajectory_sample(
618            request.muzzle_velocity_fps,
619            request.bc,
620            request.drag_model,
621            request.mass_gr,
622            request.diameter_in,
623            request.zero_distance_yd,
624            range_yd,
625            request.sight_height_in,
626            request.temperature_f,
627            request.pressure_inhg,
628            request.humidity_pct,
629            request.altitude_ft,
630            &no_bc_segments,
631            &environment(crosswind_mph),
632            true, // interpolate: land exactly on the observation range
633        )?;
634        Ok(sample.lateral_m)
635    };
636
637    let mut solutions = Vec::with_capacity(request.observations.len());
638    for observation in &request.observations {
639        let range_yd = observation.range_m / 0.9144;
640        solutions.push(solve_one_observation(observation, range_yd, &lateral_at)?);
641    }
642
643    // Combine. Inverse-variance weighting only when every observation carried a sigma
644    // (all-or-none, enforced by `validate`), otherwise the plain arithmetic mean. A
645    // supplied sigma that could not be expressed in wind units is an error, NOT a quiet
646    // demotion to unit weights: the caller asked for a weighted answer.
647    let inverse_variance_weighted = solutions.iter().all(|s| s.sigma_m.is_some());
648    if inverse_variance_weighted && solutions.iter().any(|s| s.solved_sigma_mph.is_none()) {
649        return Err(
650            "an observation does not move with crosswind at all, so its measurement sigma \
651             cannot be expressed in wind units — drop that observation or its sigma"
652                .into(),
653        );
654    }
655    let (mean_crosswind_mph, mean_sigma_mph) = if inverse_variance_weighted {
656        let mut weight_sum = 0.0;
657        let mut weighted = 0.0;
658        for solution in &solutions {
659            let sigma = solution
660                .solved_sigma_mph
661                .expect("checked by inverse_variance_weighted");
662            let weight = 1.0 / (sigma * sigma);
663            weight_sum += weight;
664            weighted += weight * solution.solved_crosswind_mph;
665        }
666        if weight_sum > 0.0 && weight_sum.is_finite() {
667            (weighted / weight_sum, Some((1.0 / weight_sum).sqrt()))
668        } else {
669            return Err(
670                "observed-miss sigmas produced a degenerate weighting (check that every \
671                 sigma is positive and that the observations move with wind at all)"
672                    .into(),
673            );
674        }
675    } else {
676        let sum: f64 = solutions.iter().map(|s| s.solved_crosswind_mph).sum();
677        (sum / solutions.len() as f64, None)
678    };
679
680    let wind_call_factor = request
681        .called_crosswind_mph
682        .map(|called| mean_crosswind_mph / called);
683
684    // Spin drift is always modelled (the twist rate is required for exactly that reason);
685    // Coriolis only when an earth frame was supplied. Anything not listed as subtracted
686    // is, by construction, still inside the solved wind.
687    let mut subtracted_effects = vec!["spin drift".to_string()];
688    let mut unsubtracted_effects = Vec::new();
689    if request.earth.is_some() {
690        subtracted_effects.push("Coriolis".to_string());
691    } else {
692        unsubtracted_effects
693            .push("Coriolis (supply --latitude and --shot-direction to subtract it)".to_string());
694    }
695
696    let uncertainty = build_wind_uncertainty(&solutions, mean_crosswind_mph, mean_sigma_mph);
697
698    Ok(WindTruingReport {
699        solutions,
700        mean_crosswind_mph,
701        mean_sigma_mph,
702        uncertainty,
703        inverse_variance_weighted,
704        called_crosswind_mph: request.called_crosswind_mph,
705        wind_call_factor,
706        subtracted_effects,
707        unsubtracted_effects,
708    })
709}
710
711/// Fit one observation. Bracketed false position (Illinois), mirroring the bracketing and
712/// best-estimate-at-the-cap discipline of
713/// [`crate::truing::calculate_true_velocity_local`]: a verified sign change over the full
714/// solvable band, then an iteration that can never leave that bracket.
715fn solve_one_observation(
716    observation: &WindObservation,
717    range_yd: f64,
718    lateral_at: &impl Fn(f64, f64) -> Result<f64, Box<dyn Error>>,
719) -> Result<WindTruingSolution, Box<dyn Error>> {
720    let target = observation.miss_right_m;
721    let residual = |crosswind_mph: f64| -> Result<f64, Box<dyn Error>> {
722        Ok(lateral_at(crosswind_mph, range_yd)? - target)
723    };
724
725    let mut low = -MAX_SOLVABLE_CROSSWIND_MPH;
726    let mut high = MAX_SOLVABLE_CROSSWIND_MPH;
727    let mut f_low = residual(low)?;
728    let mut f_high = residual(high)?;
729    if f_low > 0.0 || f_high < 0.0 {
730        return Err(format!(
731            "no crosswind within +/-{MAX_SOLVABLE_CROSSWIND_MPH:.0} mph reproduces a {:.2} in \
732             miss at {range_yd:.0} yd (that band spans {:.2} to {:.2} in of deflection) — check \
733             the sign of --miss (positive = impact RIGHT of aim), the twist hand, and the load",
734            target / 0.0254,
735            (f_low + target) / 0.0254,
736            (f_high + target) / 0.0254,
737        )
738        .into());
739    }
740
741    let mut solved = 0.0;
742    let mut f_solved = 0.0;
743    let mut iterations = 0u32;
744    let mut converged = false;
745    while iterations < WIND_SOLVE_MAX_ITERATIONS {
746        iterations += 1;
747        let denom = f_high - f_low;
748        let mut candidate = if denom.abs() > f64::MIN_POSITIVE {
749            high - f_high * (high - low) / denom
750        } else {
751            0.5 * (low + high)
752        };
753        // Never step outside the bracket (that is the whole point of keeping one).
754        if !candidate.is_finite() || candidate <= low || candidate >= high {
755            candidate = 0.5 * (low + high);
756        }
757        let f = residual(candidate)?;
758        solved = candidate;
759        f_solved = f;
760        if f.abs() <= WIND_SOLVE_TOLERANCE_M || (high - low) <= WIND_SOLVE_MIN_BRACKET_MPH {
761            converged = true;
762            break;
763        }
764        // Illinois: halve the retained endpoint's function value so one side cannot stall.
765        if f < 0.0 {
766            low = candidate;
767            f_low = f;
768            f_high *= 0.5;
769        } else {
770            high = candidate;
771            f_high = f;
772            f_low *= 0.5;
773        }
774    }
775
776    // How hard the observation actually pushes back on the wind, measured at the solution.
777    let plus = lateral_at(solved + WIND_SENSITIVITY_STEP_MPH, range_yd)?;
778    let minus = lateral_at(solved - WIND_SENSITIVITY_STEP_MPH, range_yd)?;
779    let sensitivity_m_per_mph = (plus - minus) / (2.0 * WIND_SENSITIVITY_STEP_MPH);
780
781    // The lateral the model produces with NO wind: spin drift, plus Coriolis when the
782    // earth frame was supplied. This is what the fit accounted for instead of wind.
783    let no_wind_lateral_m = lateral_at(0.0, range_yd)?;
784
785    let solved_sigma_mph = observation.sigma_m.and_then(|sigma| {
786        let slope = sensitivity_m_per_mph.abs();
787        (slope > 0.0).then_some(sigma / slope)
788    });
789
790    Ok(WindTruingSolution {
791        range_m: observation.range_m,
792        observed_miss_right_m: target,
793        sigma_m: observation.sigma_m,
794        solved_crosswind_mph: solved,
795        modeled_miss_right_m: target + f_solved,
796        residual_m: f_solved,
797        no_wind_lateral_m,
798        sensitivity_m_per_mph,
799        solved_sigma_mph,
800        iterations,
801        converged,
802    })
803}
804
805/// Which rendering [`format_wind_truing_report`] should produce. Front-end-agnostic so the
806/// native CLI's `OutputFormat` and the WASM terminal's `--output` string map onto ONE
807/// formatter and cannot drift apart.
808#[derive(Debug, Clone, Copy, PartialEq, Eq)]
809pub enum WindTruingOutput {
810    Table,
811    Json,
812    Csv,
813}
814
815/// Display units for a wind-truing rendering. Ranges follow the unit system; the observed
816/// miss is always inches (a linear tape measurement, exactly like `--drop-unit in`).
817struct WindTruingUnits {
818    range_label: &'static str,
819    speed_label: &'static str,
820    range_scale: f64,
821    speed_scale: f64,
822}
823
824impl WindTruingUnits {
825    fn for_system(units: UnitSystem) -> Self {
826        match units {
827            UnitSystem::Imperial => Self {
828                range_label: "yd",
829                speed_label: "mph",
830                range_scale: 1.0 / 0.9144,
831                speed_scale: 1.0,
832            },
833            UnitSystem::Metric => Self {
834                range_label: "m",
835                speed_label: "m/s",
836                range_scale: 1.0,
837                speed_scale: MPH_TO_MPS,
838            },
839        }
840    }
841
842    fn range(&self, range_m: f64) -> f64 {
843        range_m * self.range_scale
844    }
845
846    fn speed(&self, mph: f64) -> f64 {
847        mph * self.speed_scale
848    }
849}
850
851/// Meters to inches, for the linear miss columns.
852fn inches(meters: f64) -> f64 {
853    meters / 0.0254
854}
855
856/// The wind-truing report as a JSON document (MBA-1392).
857///
858/// Split out from [`format_wind_truing_report`] so the conditional fields (the optional
859/// sigma, called wind and correction factor, which are `null` when absent) are testable on
860/// the host — `wasm.rs` is wasm32-gated, so a formatter that only existed inside it could
861/// never be asserted against natively. Same reason `drag_coefficient_json_value` exists.
862pub fn wind_truing_json_value(report: &WindTruingReport, units: UnitSystem) -> serde_json::Value {
863    let u = WindTruingUnits::for_system(units);
864    let observations: Vec<serde_json::Value> = report
865        .solutions
866        .iter()
867        .map(|s| {
868            serde_json::json!({
869                format!("range_{}", u.range_label): u.range(s.range_m),
870                "miss_right_in": inches(s.observed_miss_right_m),
871                "miss_sigma_in": s.sigma_m.map(inches),
872                "no_wind_lateral_in": inches(s.no_wind_lateral_m),
873                "solved_crosswind": u.speed(s.solved_crosswind_mph),
874                "solved_crosswind_sigma": s.solved_sigma_mph.map(|v| u.speed(v)),
875                "sensitivity_in_per_mph": inches(s.sensitivity_m_per_mph),
876                "residual_in": inches(s.residual_m),
877                "iterations": s.iterations,
878                "converged": s.converged,
879            })
880        })
881        .collect();
882
883    serde_json::json!({
884        "effective_crosswind": u.speed(report.mean_crosswind_mph),
885        "effective_crosswind_sigma": report.mean_sigma_mph.map(|v| u.speed(v)),
886        "inverse_variance_weighted": report.inverse_variance_weighted,
887        "uncertainty": match &report.uncertainty {
888            WindUncertaintyV1::Available(i) => serde_json::json!({
889                "status": "available",
890                "sigma": u.speed(i.sigma_mph),
891                "probability": i.probability,
892                "low": u.speed(i.low_mph),
893                "high": u.speed(i.high_mph),
894                "basis": match i.basis {
895                    WindUncertaintyBasisV1::EmpiricalScatter => "empirical_scatter",
896                    WindUncertaintyBasisV1::PropagatedMeasurement => "propagated_measurement",
897                },
898                "empirical_sigma": i.empirical_sigma_mph.map(|v| u.speed(v)),
899                "propagated_sigma": i.propagated_sigma_mph.map(|v| u.speed(v)),
900                "dof": i.dof,
901            }),
902            WindUncertaintyV1::Unavailable(f) => serde_json::json!({
903                "status": "unavailable",
904                "code": match f.code {
905                    WindUncertaintyFailureCodeV1::SingleObservation => "single_observation",
906                    WindUncertaintyFailureCodeV1::NoUsableEstimate => "no_usable_estimate",
907                },
908                "message": f.message,
909            }),
910        },
911        "called_crosswind": report.called_crosswind_mph.map(|v| u.speed(v)),
912        "wind_call_factor": report.wind_call_factor,
913        "observations": observations,
914        "effects_subtracted": report.subtracted_effects,
915        "effects_not_subtracted": report.unsubtracted_effects,
916        "legend": {
917            "units": {
918                "range": u.range_label,
919                "miss": "in",
920                "wind_speed": u.speed_label,
921            },
922            "signs": "--miss positive = impact right of aim; solved crosswind positive = \
923                      wind from the shooter's left (9 o'clock) pushing impacts right",
924        },
925    })
926}
927
928/// Render a [`WindTruingReport`] (MBA-1392).
929///
930/// ONE formatter for both front ends: the native CLI prints the returned string and the
931/// WASM terminal returns it, so the two surfaces are byte-identical by construction rather
932/// than by a replicated printer that has to be kept in sync.
933pub fn format_wind_truing_report(
934    report: &WindTruingReport,
935    units: UnitSystem,
936    output: WindTruingOutput,
937) -> String {
938    let u = WindTruingUnits::for_system(units);
939    match output {
940        WindTruingOutput::Json => {
941            match serde_json::to_string_pretty(&wind_truing_json_value(report, units)) {
942                Ok(s) => format!("{s}\n"),
943                Err(e) => format!("Error serializing JSON: {e}\n"),
944            }
945        }
946        WindTruingOutput::Csv => {
947            let mut out = String::new();
948            out.push_str(&format!(
949                "range_{},miss_right_in,miss_sigma_in,no_wind_lateral_in,solved_crosswind_{},\
950                 sensitivity_in_per_mph,residual_in,iterations,converged\n",
951                u.range_label, u.speed_label
952            ));
953            for s in &report.solutions {
954                out.push_str(&format!(
955                    "{:.1},{:+.3},{},{:+.3},{:+.3},{:.4},{:+.4},{},{}\n",
956                    u.range(s.range_m),
957                    inches(s.observed_miss_right_m),
958                    match s.sigma_m {
959                        Some(sigma) => format!("{:.3}", inches(sigma)),
960                        None => String::new(),
961                    },
962                    inches(s.no_wind_lateral_m),
963                    u.speed(s.solved_crosswind_mph),
964                    inches(s.sensitivity_m_per_mph),
965                    inches(s.residual_m),
966                    s.iterations,
967                    s.converged,
968                ));
969            }
970            out.push('\n');
971            out.push_str(&format!(
972                "effective_crosswind_{},effective_crosswind_sigma_{},inverse_variance_weighted,\
973                 called_crosswind_{},wind_call_factor\n",
974                u.speed_label, u.speed_label, u.speed_label
975            ));
976            out.push_str(&format!(
977                "{:+.3},{},{},{},{}\n",
978                u.speed(report.mean_crosswind_mph),
979                match report.mean_sigma_mph {
980                    Some(sigma) => format!("{:.3}", u.speed(sigma)),
981                    None => String::new(),
982                },
983                report.inverse_variance_weighted,
984                match report.called_crosswind_mph {
985                    Some(called) => format!("{:+.3}", u.speed(called)),
986                    None => String::new(),
987                },
988                match report.wind_call_factor {
989                    Some(factor) => format!("{factor:.4}"),
990                    None => String::new(),
991                },
992            ));
993            out
994        }
995        WindTruingOutput::Table => {
996            let mut out = String::new();
997            out.push('\n');
998            out.push_str("=== EFFECTIVE WIND TRUING (from observed horizontal miss) ===\n");
999            out.push('\n');
1000            out.push_str(&format!(
1001                "  {:>10}  {:>12}  {:>14}  {:>16}  {:>10}\n",
1002                format!("Range ({})", u.range_label),
1003                "Miss (in)",
1004                "Spin/Cor (in)",
1005                format!("Wind ({})", u.speed_label),
1006                "Resid (in)",
1007            ));
1008            out.push_str(&format!("  {}\n", "-".repeat(70)));
1009            for s in &report.solutions {
1010                out.push_str(&format!(
1011                    "  {:>10.1}  {:>+12.2}  {:>+14.2}  {:>+16.2}  {:>+10.3}\n",
1012                    u.range(s.range_m),
1013                    inches(s.observed_miss_right_m),
1014                    inches(s.no_wind_lateral_m),
1015                    u.speed(s.solved_crosswind_mph),
1016                    inches(s.residual_m),
1017                ));
1018            }
1019            out.push_str(&format!("  {}\n", "-".repeat(70)));
1020            out.push('\n');
1021            let n = report.solutions.len();
1022            out.push_str(&format!(
1023                "  Effective crosswind: {:>+8.2} {}{}\n",
1024                u.speed(report.mean_crosswind_mph),
1025                u.speed_label,
1026                // Deliberately no bare "+/- sigma" here: the propagated measurement sigma
1027                // is often far tighter than the shots themselves justify, and printing it
1028                // beside the interval below invites the reader to believe the smaller
1029                // number. How the mean was combined is still worth stating.
1030                match report.mean_sigma_mph {
1031                    Some(_) => format!("  (inverse-variance weighted over {n} observations)"),
1032                    None if n > 1 => format!("  (mean of {n} observations)"),
1033                    None => String::new(),
1034                }
1035            ));
1036            match &report.uncertainty {
1037                WindUncertaintyV1::Available(i) => {
1038                    out.push_str(&format!(
1039                        "  95% interval:        [{:>+7.2}, {:>+7.2}] {}   ({})\n",
1040                        u.speed(i.low_mph),
1041                        u.speed(i.high_mph),
1042                        u.speed_label,
1043                        match i.basis {
1044                            WindUncertaintyBasisV1::EmpiricalScatter => match i.dof {
1045                                Some(d) => format!("from shot-to-shot scatter, t with {d} dof"),
1046                                None => "from shot-to-shot scatter".to_string(),
1047                            },
1048                            WindUncertaintyBasisV1::PropagatedMeasurement =>
1049                                "from your measurement sigmas".to_string(),
1050                        }
1051                    ));
1052                    // When both estimates exist, show the one that lost so the shooter can
1053                    // see WHY the interval is as wide as it is.
1054                    if let (Some(e), Some(pr)) = (i.empirical_sigma_mph, i.propagated_sigma_mph) {
1055                        out.push_str(&format!(
1056                            "    scatter sigma {:.2} {} vs measurement sigma {:.2} {} — the wider one is reported\n",
1057                            u.speed(e),
1058                            u.speed_label,
1059                            u.speed(pr),
1060                            u.speed_label
1061                        ));
1062                    }
1063                }
1064                WindUncertaintyV1::Unavailable(f) => {
1065                    out.push_str(&format!("  95% interval:        none — {}\n", f.message));
1066                }
1067            }
1068            if let (Some(called), Some(factor)) =
1069                (report.called_crosswind_mph, report.wind_call_factor)
1070            {
1071                out.push_str(&format!(
1072                    "  Called wind:         {:>+8.2} {}   ->  wind-call correction factor {:.2}\n",
1073                    u.speed(called),
1074                    u.speed_label,
1075                    factor
1076                ));
1077                out.push_str(&format!(
1078                    "    (multiply your wind calls by {factor:.2} to match what actually hit)\n"
1079                ));
1080            }
1081            if !report.subtracted_effects.is_empty() {
1082                out.push_str(&format!(
1083                    "  Effects subtracted:  {}\n",
1084                    report.subtracted_effects.join(", ")
1085                ));
1086            }
1087            if !report.unsubtracted_effects.is_empty() {
1088                out.push_str(&format!(
1089                    "  NOT subtracted (absorbed into the solved wind): {}\n",
1090                    report.unsubtracted_effects.join(", ")
1091                ));
1092            }
1093            for s in &report.solutions {
1094                if inches(s.sensitivity_m_per_mph).abs() < MIN_WIND_SENSITIVITY_IN_PER_MPH {
1095                    out.push_str(&format!(
1096                        "  note: the observation at {:.1} {} moves only {:.2} in per mph of \
1097                         crosswind (guide: {MIN_WIND_SENSITIVITY_IN_PER_MPH:.2} in/mph); the \
1098                         wind fitted from it is weakly identified\n",
1099                        u.range(s.range_m),
1100                        u.range_label,
1101                        inches(s.sensitivity_m_per_mph).abs(),
1102                    ));
1103                }
1104                if !s.converged {
1105                    out.push_str(&format!(
1106                        "  note: the fit at {:.1} {} did not fully converge after {} iterations; \
1107                         the value shown is the best estimate\n",
1108                        u.range(s.range_m),
1109                        u.range_label,
1110                        s.iterations,
1111                    ));
1112                }
1113            }
1114            out.push('\n');
1115            out.push_str(
1116                "  Signs: --miss positive = impact RIGHT of aim. Solved wind positive = wind\n\
1117                 \x20        FROM the shooter's LEFT (9 o'clock) pushing impacts right; negative\n\
1118                 \x20        = FROM the right pushing left. Wind-FROM convention throughout\n\
1119                 \x20        (0 = headwind, as of the 0.19.0 wind-direction sign fix).\n",
1120            );
1121            out.push('\n');
1122            out
1123        }
1124    }
1125}
1126
1127#[cfg(test)]
1128mod tests {
1129    use super::*;
1130
1131    fn base_request(observations: Vec<WindObservation>) -> WindTruingRequest {
1132        WindTruingRequest {
1133            observations,
1134            muzzle_velocity_fps: 2700.0,
1135            bc: 0.475,
1136            drag_model: DragModelArg::G7,
1137            mass_gr: 168.0,
1138            diameter_in: 0.308,
1139            zero_distance_yd: 100.0,
1140            sight_height_in: 2.0,
1141            temperature_f: 59.0,
1142            pressure_inhg: 29.92,
1143            humidity_pct: 50.0,
1144            altitude_ft: 0.0,
1145            twist: TruingTwist {
1146                rate_in: 11.0,
1147                right_hand: true,
1148            },
1149            earth: None,
1150            called_crosswind_mph: None,
1151        }
1152    }
1153
1154    /// The forward model the fit inverts — used to manufacture "observed" misses for the
1155    /// round-trip tests from a KNOWN wind.
1156    fn modeled_lateral_m(request: &WindTruingRequest, crosswind_mph: f64, range_m: f64) -> f64 {
1157        modeled_miss_right_m(request, crosswind_mph, range_m).expect("forward model must solve")
1158    }
1159
1160    /// Round trip: take a KNOWN crosswind, read the lateral miss it produces at three
1161    /// ranges out of the forward model, feed those misses back in as observations, and
1162    /// recover the wind. Tolerance is 0.02 mph — three orders of magnitude tighter than
1163    /// anyone's wind call, so this pins the inversion, not just its ballpark.
1164    #[test]
1165    fn round_trip_recovers_a_known_crosswind_at_three_ranges() {
1166        let known_mph = 7.5;
1167        let ranges_m = [274.32, 457.2, 640.08]; // 300 / 500 / 700 yd
1168        let template = base_request(Vec::new());
1169        let observations = ranges_m
1170            .iter()
1171            .map(|range_m| WindObservation {
1172                range_m: *range_m,
1173                miss_right_m: modeled_lateral_m(&template, known_mph, *range_m),
1174                sigma_m: None,
1175            })
1176            .collect();
1177
1178        let report = solve_wind_truing(&base_request(observations)).expect("wind fit must solve");
1179        assert_eq!(report.solutions.len(), 3);
1180        for solution in &report.solutions {
1181            assert!(solution.converged, "{solution:?}");
1182            assert!(
1183                (solution.solved_crosswind_mph - known_mph).abs() < 0.02,
1184                "recovered {} mph at {} m, expected {known_mph}",
1185                solution.solved_crosswind_mph,
1186                solution.range_m
1187            );
1188        }
1189        assert!((report.mean_crosswind_mph - known_mph).abs() < 0.02);
1190        assert!(!report.inverse_variance_weighted);
1191        assert!(report.mean_sigma_mph.is_none());
1192    }
1193
1194    /// Sign pins, both directions. A miss to the RIGHT must solve to a POSITIVE wind
1195    /// (from the shooter's left, pushing right); a miss to the LEFT must solve NEGATIVE.
1196    /// These two assertions are the contract the help text documents.
1197    #[test]
1198    fn miss_right_solves_positive_and_miss_left_solves_negative() {
1199        let template = base_request(Vec::new());
1200        let range_m = 457.2; // 500 yd
1201        let right_miss = modeled_lateral_m(&template, 8.0, range_m);
1202        let left_miss = modeled_lateral_m(&template, -8.0, range_m);
1203        assert!(right_miss > 0.0, "a left-hand wind must push impacts right");
1204        assert!(left_miss < 0.0, "a right-hand wind must push impacts left");
1205
1206        let right = solve_wind_truing(&base_request(vec![WindObservation {
1207            range_m,
1208            miss_right_m: right_miss,
1209            sigma_m: None,
1210        }]))
1211        .expect("right-miss fit must solve");
1212        assert!(
1213            right.mean_crosswind_mph > 0.0,
1214            "right miss must solve to a positive (left-hand, right-pushing) wind, got {}",
1215            right.mean_crosswind_mph
1216        );
1217        assert!((right.mean_crosswind_mph - 8.0).abs() < 0.02);
1218
1219        let left = solve_wind_truing(&base_request(vec![WindObservation {
1220            range_m,
1221            miss_right_m: left_miss,
1222            sigma_m: None,
1223        }]))
1224        .expect("left-miss fit must solve");
1225        assert!(
1226            left.mean_crosswind_mph < 0.0,
1227            "left miss must solve to a negative (right-hand, left-pushing) wind, got {}",
1228            left.mean_crosswind_mph
1229        );
1230        assert!((left.mean_crosswind_mph + 8.0).abs() < 0.02);
1231    }
1232
1233    /// Spin-drift subtraction: a ZERO-wind trajectory still lands right of aim (right-hand
1234    /// twist). Feeding that pure spin drift in as the observed miss must solve to ~zero
1235    /// wind — the drift is attributed to spin, not to a phantom wind.
1236    #[test]
1237    fn pure_spin_drift_solves_to_zero_wind() {
1238        let template = base_request(Vec::new());
1239        let range_m = 640.08; // 700 yd
1240        let spin_only = modeled_lateral_m(&template, 0.0, range_m);
1241        assert!(
1242            spin_only > 0.05,
1243            "a 1:11 right-hand twist must drift measurably right at 700 yd, got {spin_only} m"
1244        );
1245
1246        let report = solve_wind_truing(&base_request(vec![WindObservation {
1247            range_m,
1248            miss_right_m: spin_only,
1249            sigma_m: None,
1250        }]))
1251        .expect("spin-only fit must solve");
1252        assert!(
1253            report.mean_crosswind_mph.abs() < 0.02,
1254            "pure spin drift must solve to ~0 wind, got {}",
1255            report.mean_crosswind_mph
1256        );
1257        // ... and the report must say the drift was accounted for, not silently eaten.
1258        assert!(report
1259            .solutions
1260            .iter()
1261            .all(|s| (s.no_wind_lateral_m - spin_only).abs() < 1e-9));
1262        assert!(report
1263            .subtracted_effects
1264            .iter()
1265            .any(|e| e.contains("spin drift")));
1266    }
1267
1268    /// A left-hand twist drifts the other way, so the SAME right-of-aim miss must solve to
1269    /// a stronger wind than a right-hand twist needs. Pins that the twist hand actually
1270    /// reaches the forward model rather than being cosmetic.
1271    #[test]
1272    fn twist_hand_changes_the_solved_wind() {
1273        let range_m = 640.08;
1274        let observation = WindObservation {
1275            range_m,
1276            miss_right_m: 0.25,
1277            sigma_m: None,
1278        };
1279        let right_hand = solve_wind_truing(&base_request(vec![observation])).expect("solve");
1280        let mut left = base_request(vec![observation]);
1281        left.twist.right_hand = false;
1282        let left_hand = solve_wind_truing(&left).expect("solve");
1283        assert!(
1284            left_hand.mean_crosswind_mph > right_hand.mean_crosswind_mph + 0.1,
1285            "left-hand twist ({}) must need more right-pushing wind than right-hand ({})",
1286            left_hand.mean_crosswind_mph,
1287            right_hand.mean_crosswind_mph
1288        );
1289    }
1290
1291    /// Coriolis subtraction: supplying a latitude and shot azimuth changes the zero-wind
1292    /// lateral (Coriolis is now modelled), so the same observed miss solves to a different
1293    /// wind, and the report moves Coriolis from "not subtracted" to "subtracted".
1294    #[test]
1295    fn coriolis_is_subtracted_when_latitude_and_azimuth_are_supplied() {
1296        let range_m = 914.4; // 1000 yd, where Coriolis is actually measurable
1297        let observation = WindObservation {
1298            range_m,
1299            miss_right_m: 0.30,
1300            sigma_m: None,
1301        };
1302        let without = solve_wind_truing(&base_request(vec![observation])).expect("solve");
1303        let mut with_earth = base_request(vec![observation]);
1304        with_earth.earth = Some(TruingEarthFrame {
1305            latitude_deg: 45.0,
1306            shot_azimuth_deg: 90.0, // due East, where the Coriolis lateral is largest
1307        });
1308        let with = solve_wind_truing(&with_earth).expect("solve");
1309
1310        assert!(without
1311            .unsubtracted_effects
1312            .iter()
1313            .any(|e| e.contains("Coriolis")));
1314        assert!(without.subtracted_effects.iter().all(|e| e != "Coriolis"));
1315        assert!(with.unsubtracted_effects.is_empty());
1316        assert!(with.subtracted_effects.iter().any(|e| e == "Coriolis"));
1317        assert!(
1318            (with.solutions[0].no_wind_lateral_m - without.solutions[0].no_wind_lateral_m).abs()
1319                > 1e-4,
1320            "modelling Coriolis must change the zero-wind lateral"
1321        );
1322        assert!(
1323            (with.mean_crosswind_mph - without.mean_crosswind_mph).abs() > 1e-3,
1324            "modelling Coriolis must change the solved wind"
1325        );
1326    }
1327
1328    /// The wind-call correction factor is solved / called, and its sign survives: calling
1329    /// the wrong SIDE of the wind gives a negative factor rather than a plausible-looking
1330    /// positive one.
1331    #[test]
1332    fn wind_call_factor_is_solved_over_called_and_keeps_its_sign() {
1333        let template = base_request(Vec::new());
1334        let range_m = 457.2;
1335        let miss = modeled_lateral_m(&template, 9.0, range_m);
1336        let observation = WindObservation {
1337            range_m,
1338            miss_right_m: miss,
1339            sigma_m: None,
1340        };
1341
1342        let mut under_called = base_request(vec![observation]);
1343        under_called.called_crosswind_mph = Some(6.0);
1344        let report = solve_wind_truing(&under_called).expect("solve");
1345        let factor = report.wind_call_factor.expect("factor");
1346        assert!(
1347            (factor - 9.0 / 6.0).abs() < 0.01,
1348            "expected ~1.5, got {factor}"
1349        );
1350
1351        let mut wrong_side = base_request(vec![observation]);
1352        wrong_side.called_crosswind_mph = Some(-6.0);
1353        let flipped = solve_wind_truing(&wrong_side)
1354            .expect("solve")
1355            .wind_call_factor
1356            .expect("factor");
1357        assert!(flipped < 0.0, "a wrong-side call must read negative: {flipped}");
1358    }
1359
1360    /// Sigmas: all-or-none. Every observation weighted -> inverse-variance mean with a
1361    /// reported sigma; none weighted -> plain mean; a mix is a hard error rather than a
1362    /// silent blend of weighting schemes.
1363    #[test]
1364    fn sigmas_are_all_or_none_and_drive_inverse_variance_weighting() {
1365        let template = base_request(Vec::new());
1366        let near = 274.32;
1367        let far = 640.08;
1368        let weighted = vec![
1369            WindObservation {
1370                range_m: near,
1371                miss_right_m: modeled_lateral_m(&template, 6.0, near),
1372                sigma_m: Some(0.25 * 0.0254),
1373            },
1374            WindObservation {
1375                range_m: far,
1376                miss_right_m: modeled_lateral_m(&template, 6.0, far),
1377                sigma_m: Some(0.25 * 0.0254),
1378            },
1379        ];
1380        let report = solve_wind_truing(&base_request(weighted)).expect("solve");
1381        assert!(report.inverse_variance_weighted);
1382        let sigma = report.mean_sigma_mph.expect("weighted mean sigma");
1383        assert!(sigma > 0.0 && sigma.is_finite());
1384        // The long-range observation is far more sensitive to wind, so its propagated
1385        // sigma must be the smaller of the two (it carries the most weight).
1386        let near_sigma = report.solutions[0].solved_sigma_mph.expect("sigma");
1387        let far_sigma = report.solutions[1].solved_sigma_mph.expect("sigma");
1388        assert!(far_sigma < near_sigma, "{far_sigma} !< {near_sigma}");
1389        assert!(sigma <= far_sigma + 1e-12);
1390
1391        let mixed = base_request(vec![
1392            WindObservation {
1393                range_m: near,
1394                miss_right_m: 0.1,
1395                sigma_m: Some(0.006),
1396            },
1397            WindObservation {
1398                range_m: far,
1399                miss_right_m: 0.2,
1400                sigma_m: None,
1401            },
1402        ]);
1403        let error = mixed.validate().unwrap_err();
1404        assert!(error.contains("every observed miss or on none"), "{error}");
1405    }
1406
1407    /// An unreachable miss (wrong sign, or simply not a wind effect) is rejected with a
1408    /// diagnostic naming the solvable band, instead of being clamped into a fake answer.
1409    #[test]
1410    fn an_unreachable_miss_is_rejected_with_the_solvable_band() {
1411        let error = solve_wind_truing(&base_request(vec![WindObservation {
1412            range_m: 274.32,
1413            miss_right_m: 25.0, // 25 metres right at 300 yd: no wind does that
1414            sigma_m: None,
1415        }]))
1416        .unwrap_err()
1417        .to_string();
1418        assert!(error.contains("no crosswind within"), "{error}");
1419        assert!(error.contains("check the sign of --miss"), "{error}");
1420    }
1421
1422    /// MBA-1358 / design R5-DIRECTION: `--miss` values are LINEAR inches off the target,
1423    /// not dial readings, so a scope tracking correction factor must NOT touch them. The
1424    /// structural guarantee is that no CF can reach this solver at all — there is no field
1425    /// for one — and this test pins the consequence: a windage CF applied the way the
1426    /// DIALED truing path applies it (observation x CF) would move the answer, so it must
1427    /// never be applied here.
1428    #[test]
1429    fn windage_cf_does_not_alter_the_wind_solve() {
1430        let template = base_request(Vec::new());
1431        let range_m = 457.2;
1432        let miss = modeled_lateral_m(&template, 7.0, range_m);
1433        let observation = WindObservation {
1434            range_m,
1435            miss_right_m: miss,
1436            sigma_m: None,
1437        };
1438        let solved = solve_wind_truing(&base_request(vec![observation]))
1439            .expect("solve")
1440            .mean_crosswind_mph;
1441        assert!((solved - 7.0).abs() < 0.02);
1442
1443        // What the dialed path would have done to a 0.95 CF observation. It changes the
1444        // answer materially, which is exactly why linear inputs must be left alone.
1445        let windage_cf = 0.95;
1446        let cf_applied = solve_wind_truing(&base_request(vec![WindObservation {
1447            range_m,
1448            miss_right_m: miss * windage_cf,
1449            sigma_m: None,
1450        }]))
1451        .expect("solve")
1452        .mean_crosswind_mph;
1453        assert!(
1454            (cf_applied - solved).abs() > 0.1,
1455            "a CF-scaled observation must NOT be equivalent to the linear one \
1456             ({cf_applied} vs {solved}); --miss therefore takes no CF"
1457        );
1458    }
1459
1460    /// The JSON emit helper: conditional fields are explicit `null`s (never dropped keys),
1461    /// so consumers can tell "not supplied" from "absent field".
1462    #[test]
1463    fn json_value_nulls_absent_optional_fields() {
1464        let template = base_request(Vec::new());
1465        let range_m = 457.2;
1466        let report = solve_wind_truing(&base_request(vec![WindObservation {
1467            range_m,
1468            miss_right_m: modeled_lateral_m(&template, 5.0, range_m),
1469            sigma_m: None,
1470        }]))
1471        .expect("solve");
1472        let value = wind_truing_json_value(&report, UnitSystem::Imperial);
1473        assert!(value["called_crosswind"].is_null());
1474        assert!(value["wind_call_factor"].is_null());
1475        assert!(value["effective_crosswind_sigma"].is_null());
1476        assert!(value["observations"][0]["miss_sigma_in"].is_null());
1477        assert!(value["observations"][0]["solved_crosswind_sigma"].is_null());
1478        assert_eq!(value["legend"]["units"]["wind_speed"], "mph");
1479        assert_eq!(value["legend"]["units"]["miss"], "in");
1480        assert_eq!(
1481            value["effective_crosswind"].as_f64().expect("f64").round(),
1482            5.0
1483        );
1484
1485        // Metric renders the same wind in m/s and the same ranges in meters, while the
1486        // linear miss stays in inches (the --drop-unit in precedent).
1487        let metric = wind_truing_json_value(&report, UnitSystem::Metric);
1488        assert_eq!(metric["legend"]["units"]["wind_speed"], "m/s");
1489        assert_eq!(metric["legend"]["units"]["range"], "m");
1490        assert_eq!(metric["legend"]["units"]["miss"], "in");
1491        let mps = metric["effective_crosswind"].as_f64().expect("f64");
1492        let mph = value["effective_crosswind"].as_f64().expect("f64");
1493        assert!((mps - mph * MPH_TO_MPS).abs() < 1e-12);
1494    }
1495
1496    /// Parser contract: RANGE follows the unit system, the offset and sigma are inches in
1497    /// both, and malformed tokens are rejected with a usable message.
1498    #[test]
1499    fn parse_wind_observation_units_and_errors() {
1500        let imperial = parse_wind_observation("600:8.5", UnitSystem::Imperial).expect("parse");
1501        assert!((imperial.range_m - 600.0 * 0.9144).abs() < 1e-12);
1502        assert!((imperial.miss_right_m - 8.5 * 0.0254).abs() < 1e-12);
1503        assert!(imperial.sigma_m.is_none());
1504
1505        let metric = parse_wind_observation("550:-8.5:0.75", UnitSystem::Metric).expect("parse");
1506        assert!((metric.range_m - 550.0).abs() < 1e-12);
1507        assert!((metric.miss_right_m + 8.5 * 0.0254).abs() < 1e-12);
1508        assert!((metric.sigma_m.expect("sigma") - 0.75 * 0.0254).abs() < 1e-12);
1509
1510        for bad in ["600", "600:8.5:0.1:2", "600:right", "abc:8.5", "600:nan"] {
1511            assert!(
1512                parse_wind_observation(bad, UnitSystem::Imperial).is_err(),
1513                "'{bad}' should not parse"
1514            );
1515        }
1516    }
1517
1518    /// Validation rejects degenerate requests before spending a single trajectory solve.
1519    #[test]
1520    fn validation_rejects_degenerate_requests() {
1521        assert!(base_request(Vec::new())
1522            .validate()
1523            .unwrap_err()
1524            .contains("at least one"));
1525
1526        let duplicate = base_request(vec![
1527            WindObservation {
1528                range_m: 457.2,
1529                miss_right_m: 0.2,
1530                sigma_m: None,
1531            },
1532            WindObservation {
1533                range_m: 457.2,
1534                miss_right_m: 0.3,
1535                sigma_m: None,
1536            },
1537        ]);
1538        assert!(duplicate
1539            .validate()
1540            .unwrap_err()
1541            .contains("duplicate observation range"));
1542
1543        let mut bad_twist = base_request(vec![WindObservation {
1544            range_m: 457.2,
1545            miss_right_m: 0.2,
1546            sigma_m: None,
1547        }]);
1548        bad_twist.twist.rate_in = 0.0;
1549        assert!(bad_twist
1550            .validate()
1551            .unwrap_err()
1552            .contains("twist rate must be positive"));
1553
1554        let mut zero_call = base_request(vec![WindObservation {
1555            range_m: 457.2,
1556            miss_right_m: 0.2,
1557            sigma_m: None,
1558        }]);
1559        zero_call.called_crosswind_mph = Some(0.0);
1560        assert!(zero_call.validate().unwrap_err().contains("non-zero"));
1561    }
1562
1563    /// Bridge contract: a `WindTruingRequest` deserializes from JSON with the field names
1564    /// documented on the struct, and a solved `WindTruingReport` serializes back out.
1565    #[test]
1566    fn request_deserializes_and_report_serializes() {
1567        let json = serde_json::json!({
1568            "observations": [{"range_m": 457.2, "miss_right_m": 0.315, "sigma_m": null}],
1569            "muzzle_velocity_fps": 2700.0, "bc": 0.243, "drag_model": "g7",
1570            "mass_gr": 168.0, "diameter_in": 0.308, "zero_distance_yd": 100.0,
1571            "sight_height_in": 2.0, "temperature_f": 59.0, "pressure_inhg": 29.92,
1572            "humidity_pct": 50.0, "altitude_ft": 0.0,
1573            "twist": {"rate_in": 11.0, "right_hand": true},
1574            "earth": null, "called_crosswind_mph": null
1575        });
1576        let req: WindTruingRequest =
1577            serde_json::from_value(json).expect("request deserializes");
1578        let report = solve_wind_truing(&req).expect("solves");
1579        let out = serde_json::to_value(&report).expect("report serializes");
1580        assert!(out["mean_crosswind_mph"].is_number());
1581        assert!(out["solutions"].as_array().unwrap().len() == 1);
1582    }
1583
1584    // ---- interval on the combined crosswind (wind uncertainty model) ----------------
1585
1586    /// Build a solution carrying only the fields the estimator reads.
1587    fn sol(solved_crosswind_mph: f64, sigma_m: Option<f64>, solved_sigma_mph: Option<f64>) -> WindTruingSolution {
1588        WindTruingSolution {
1589            range_m: 500.0,
1590            observed_miss_right_m: 0.3,
1591            sigma_m,
1592            solved_crosswind_mph,
1593            modeled_miss_right_m: 0.3,
1594            residual_m: 0.0,
1595            no_wind_lateral_m: 0.02,
1596            sensitivity_m_per_mph: 0.05,
1597            solved_sigma_mph,
1598            iterations: 3,
1599            converged: true,
1600        }
1601    }
1602
1603    #[test]
1604    fn interval_uses_scatter_when_no_sigmas_supplied() {
1605        // Three shots that disagree; no measurement sigma anywhere. The old code reported
1606        // a bare mean here -- this is the case the model exists to cover.
1607        let sols = vec![sol(6.0, None, None), sol(8.0, None, None), sol(7.0, None, None)];
1608        let got = build_wind_uncertainty(&sols, 7.0, None);
1609        let WindUncertaintyV1::Available(i) = got else {
1610            panic!("expected an interval, got {got:?}");
1611        };
1612        assert_eq!(i.basis, WindUncertaintyBasisV1::EmpiricalScatter);
1613        assert_eq!(i.dof, Some(2));
1614        assert!(i.propagated_sigma_mph.is_none());
1615        // sample SD of {6,8,7} = 1.0, so SE = 1/sqrt(3) = 0.57735
1616        let se = i.empirical_sigma_mph.expect("scatter sigma");
1617        assert!((se - 1.0 / 3f64.sqrt()).abs() < 1e-12, "se was {se}");
1618        // t(0.975, 2) = 4.302652730
1619        let half = 4.302_652_730 * se;
1620        assert!((i.low_mph - (7.0 - half)).abs() < 1e-9);
1621        assert!((i.high_mph - (7.0 + half)).abs() < 1e-9);
1622        assert!(i.low_mph < 7.0 && i.high_mph > 7.0);
1623    }
1624
1625    #[test]
1626    fn optimistic_supplied_sigmas_lose_to_observed_scatter() {
1627        // The shooter claims a very tight measurement sigma, but their own shots disagree
1628        // far more than that. Reporting the claim would be false confidence, so the wider
1629        // scatter-based interval must win.
1630        let sols = vec![
1631            sol(4.0, Some(0.01), Some(0.02)),
1632            sol(9.0, Some(0.01), Some(0.02)),
1633            sol(6.5, Some(0.01), Some(0.02)),
1634        ];
1635        let propagated = Some(0.02 / 3f64.sqrt()); // what inverse-variance weighting yields
1636        let got = build_wind_uncertainty(&sols, 6.5, propagated);
1637        let WindUncertaintyV1::Available(i) = got else { panic!("expected an interval") };
1638        assert_eq!(i.basis, WindUncertaintyBasisV1::EmpiricalScatter);
1639        let e = i.empirical_sigma_mph.expect("scatter");
1640        let pr = i.propagated_sigma_mph.expect("propagated");
1641        assert!(e > pr, "scatter {e} should exceed propagated {pr}");
1642        assert!((i.sigma_mph - e).abs() < 1e-12, "the wider estimate must drive the interval");
1643        // both remain visible so the shooter can see why
1644        assert!(i.propagated_sigma_mph.is_some());
1645    }
1646
1647    #[test]
1648    fn tight_scatter_lets_supplied_sigmas_win() {
1649        // The converse: shots agree closely but the shooter's stated measurement error is
1650        // large. The interval must not be narrower than the measurement supports.
1651        let sols = vec![
1652            sol(7.00, Some(2.0), Some(1.5)),
1653            sol(7.01, Some(2.0), Some(1.5)),
1654            sol(6.99, Some(2.0), Some(1.5)),
1655        ];
1656        let propagated = Some(1.5 / 3f64.sqrt());
1657        let got = build_wind_uncertainty(&sols, 7.0, propagated);
1658        let WindUncertaintyV1::Available(i) = got else { panic!("expected an interval") };
1659        assert_eq!(i.basis, WindUncertaintyBasisV1::PropagatedMeasurement);
1660        assert_eq!(i.dof, None, "a supplied sigma is treated as known, so normal not t");
1661        let half = NORMAL_95_TWO_SIDED_Z * i.sigma_mph;
1662        assert!((i.high_mph - (7.0 + half)).abs() < 1e-9);
1663    }
1664
1665    #[test]
1666    fn single_observation_without_sigma_is_explained_not_omitted() {
1667        let sols = vec![sol(7.0, None, None)];
1668        let got = build_wind_uncertainty(&sols, 7.0, None);
1669        let WindUncertaintyV1::Unavailable(f) = got else {
1670            panic!("one shot with no sigma cannot yield an interval");
1671        };
1672        assert_eq!(f.code, WindUncertaintyFailureCodeV1::SingleObservation);
1673        assert!(!f.message.is_empty());
1674    }
1675
1676    #[test]
1677    fn single_observation_with_sigma_still_gets_an_interval() {
1678        let sols = vec![sol(7.0, Some(0.1), Some(2.0))];
1679        let got = build_wind_uncertainty(&sols, 7.0, Some(2.0));
1680        let WindUncertaintyV1::Available(i) = got else { panic!("expected an interval") };
1681        assert_eq!(i.basis, WindUncertaintyBasisV1::PropagatedMeasurement);
1682        assert!(i.empirical_sigma_mph.is_none(), "one shot has no scatter");
1683    }
1684
1685    #[test]
1686    fn identical_observations_without_sigma_report_zero_spread_honestly() {
1687        // Zero scatter is not certainty -- it means the observations were not distinct.
1688        let sols = vec![sol(7.0, None, None), sol(7.0, None, None)];
1689        let got = build_wind_uncertainty(&sols, 7.0, None);
1690        let WindUncertaintyV1::Unavailable(f) = got else {
1691            panic!("zero spread must not be reported as a zero-width interval");
1692        };
1693        assert_eq!(f.code, WindUncertaintyFailureCodeV1::NoUsableEstimate);
1694    }
1695
1696    #[test]
1697    fn t_multiplier_table_matches_published_values() {
1698        // Spot-check the generated table against published t(0.975, nu).
1699        for (dof, want) in [(1usize, 12.706205), (2, 4.302653), (5, 2.570582), (10, 2.228139), (30, 2.042272)] {
1700            let got = T_95_TWO_SIDED[dof - 1];
1701            assert!((got - want).abs() < 5e-6, "dof {dof}: {got} vs {want}");
1702        }
1703        // The interval widens as dof shrinks -- a two-shot fit must not look as tight as a
1704        // ten-shot one. Both operands are constants, so these are checked at compile time.
1705        const { assert!(T_95_TWO_SIDED[0] > T_95_TWO_SIDED[9]) };
1706        const { assert!(T_95_TWO_SIDED[29] > NORMAL_95_TWO_SIDED_Z) };
1707    }
1708
1709    #[test]
1710    fn uncertainty_is_always_present_in_the_serialized_report() {
1711        let sols = vec![sol(6.0, None, None), sol(8.0, None, None)];
1712        let u = build_wind_uncertainty(&sols, 7.0, None);
1713        let v = serde_json::to_value(&u).expect("serializes");
1714        assert_eq!(v["status"], "available");
1715        assert!(v["detail"]["low_mph"].is_number());
1716        assert_eq!(v["detail"]["basis"], "empirical_scatter");
1717    }
1718}