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