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