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/// The complete wind-truing result: one fit per observation plus the combined answer.
335#[derive(Debug, Clone, Serialize)]
336pub struct WindTruingReport {
337    /// Per-observation fits, in the order the observations were supplied.
338    pub solutions: Vec<WindTruingSolution>,
339    /// Combined effective crosswind, mph, signed.
340    pub mean_crosswind_mph: f64,
341    /// One-sigma uncertainty of `mean_crosswind_mph`, mph; `Some` only when every
342    /// observation carried a sigma.
343    pub mean_sigma_mph: Option<f64>,
344    /// `true` when the mean is inverse-variance weighted (all sigmas supplied), `false`
345    /// when it is the plain arithmetic mean.
346    pub inverse_variance_weighted: bool,
347    /// The wind the shooter called, mph, if supplied.
348    pub called_crosswind_mph: Option<f64>,
349    /// `mean_crosswind_mph / called_crosswind_mph`: >1 means the shooter under-called the
350    /// wind, <1 means they over-called it, negative means they called the wrong side.
351    pub wind_call_factor: Option<f64>,
352    /// Lateral effects the model actually accounted for, so they are NOT in the solved wind.
353    pub subtracted_effects: Vec<String>,
354    /// Lateral effects the model had no data for, which therefore ARE absorbed into the
355    /// solved wind. Empty means everything this model knows about was subtracted.
356    pub unsubtracted_effects: Vec<String>,
357}
358
359/// Wind conditions describing a signed full-value crosswind (MBA-1392).
360///
361/// Uses the engine's wind-FROM convention: `PI/2` is a wind FROM the shooter's RIGHT
362/// (pushing impacts LEFT) and `3*PI/2` is FROM the LEFT (pushing impacts RIGHT). The
363/// signed input follows the DEFLECTION, so positive maps to `3*PI/2`. The pair is
364/// continuous through zero — both directions give the zero vector at zero speed — so the
365/// root find sees a smooth function across the sign change.
366fn crosswind_conditions(signed_mph: f64) -> WindConditions {
367    WindConditions {
368        speed: signed_mph.abs() * MPH_TO_MPS,
369        direction: if signed_mph < 0.0 {
370            std::f64::consts::FRAC_PI_2
371        } else {
372            3.0 * std::f64::consts::FRAC_PI_2
373        },
374        vertical_speed: 0.0,
375    }
376}
377
378/// The FORWARD direction of [`solve_wind_truing`]: the lateral miss (meters, positive =
379/// right of the line of sight) this request's load / atmosphere / twist / earth frame
380/// predicts at `range_m` under a signed `crosswind_mph` (MBA-1392).
381///
382/// `request.observations` and `request.called_crosswind_mph` are ignored — only the model
383/// half is used — and no validation is run, so a caller that has not validated its request
384/// will simply see the solver's own error. Public because "what miss does N mph produce?"
385/// is the question a wind-call drill actually asks, and because it lets any caller check
386/// that the inversion round-trips on its own model rather than trusting it to.
387pub fn modeled_miss_right_m(
388    request: &WindTruingRequest,
389    crosswind_mph: f64,
390    range_m: f64,
391) -> Result<f64, Box<dyn Error>> {
392    let no_bc_segments: Option<Vec<BCSegmentData>> = None;
393    let env = TruingEnvironment {
394        wind: crosswind_conditions(crosswind_mph),
395        twist: Some(request.twist),
396        earth: request.earth,
397    };
398    let sample = crate::truing::solve_trajectory_sample(
399        request.muzzle_velocity_fps,
400        request.bc,
401        request.drag_model,
402        request.mass_gr,
403        request.diameter_in,
404        request.zero_distance_yd,
405        range_m / 0.9144,
406        request.sight_height_in,
407        request.temperature_f,
408        request.pressure_inhg,
409        request.humidity_pct,
410        request.altitude_ft,
411        &no_bc_segments,
412        &env,
413        true, // interpolate: land exactly on the requested range
414    )?;
415    Ok(sample.lateral_m)
416}
417
418/// Back-solve the effective crosswind from every observed miss in `request` (MBA-1392).
419///
420/// Each observation is fitted independently with a bracketed root find on the constant
421/// crosswind speed, against the real forward trajectory model (the truing core's own
422/// solver assembly, zero-angle solve and atmosphere — the same one the drop-based truing
423/// commands use, now sampled on the lateral axis and carrying spin drift, and optionally
424/// Coriolis). See [`modeled_miss_right_m`] for that forward direction on its own.
425///
426/// Errors on an invalid request, on any trajectory-solver failure, and when an observed
427/// miss cannot be produced by any crosswind inside `+/-`[`MAX_SOLVABLE_CROSSWIND_MPH`] —
428/// which in practice means the miss was entered with the wrong sign, or is not a wind
429/// effect at all.
430pub fn solve_wind_truing(request: &WindTruingRequest) -> Result<WindTruingReport, Box<dyn Error>> {
431    request.validate()?;
432
433    // No BC5D velocity-banded segments on this path: the wind fit exposes one scalar BC,
434    // matching the scalar-BC truing model (a banded schedule has no single BC to pair
435    // with the fitted wind), and keeps the command entirely offline.
436    let no_bc_segments: Option<Vec<BCSegmentData>> = None;
437
438    let environment = |crosswind_mph: f64| TruingEnvironment {
439        wind: crosswind_conditions(crosswind_mph),
440        twist: Some(request.twist),
441        earth: request.earth,
442    };
443
444    // Lateral (McCoy z, positive = right) at `range_yd` for a candidate crosswind.
445    let lateral_at = |crosswind_mph: f64, range_yd: f64| -> Result<f64, Box<dyn Error>> {
446        let sample = crate::truing::solve_trajectory_sample(
447            request.muzzle_velocity_fps,
448            request.bc,
449            request.drag_model,
450            request.mass_gr,
451            request.diameter_in,
452            request.zero_distance_yd,
453            range_yd,
454            request.sight_height_in,
455            request.temperature_f,
456            request.pressure_inhg,
457            request.humidity_pct,
458            request.altitude_ft,
459            &no_bc_segments,
460            &environment(crosswind_mph),
461            true, // interpolate: land exactly on the observation range
462        )?;
463        Ok(sample.lateral_m)
464    };
465
466    let mut solutions = Vec::with_capacity(request.observations.len());
467    for observation in &request.observations {
468        let range_yd = observation.range_m / 0.9144;
469        solutions.push(solve_one_observation(observation, range_yd, &lateral_at)?);
470    }
471
472    // Combine. Inverse-variance weighting only when every observation carried a sigma
473    // (all-or-none, enforced by `validate`), otherwise the plain arithmetic mean. A
474    // supplied sigma that could not be expressed in wind units is an error, NOT a quiet
475    // demotion to unit weights: the caller asked for a weighted answer.
476    let inverse_variance_weighted = solutions.iter().all(|s| s.sigma_m.is_some());
477    if inverse_variance_weighted && solutions.iter().any(|s| s.solved_sigma_mph.is_none()) {
478        return Err(
479            "an observation does not move with crosswind at all, so its measurement sigma \
480             cannot be expressed in wind units — drop that observation or its sigma"
481                .into(),
482        );
483    }
484    let (mean_crosswind_mph, mean_sigma_mph) = if inverse_variance_weighted {
485        let mut weight_sum = 0.0;
486        let mut weighted = 0.0;
487        for solution in &solutions {
488            let sigma = solution
489                .solved_sigma_mph
490                .expect("checked by inverse_variance_weighted");
491            let weight = 1.0 / (sigma * sigma);
492            weight_sum += weight;
493            weighted += weight * solution.solved_crosswind_mph;
494        }
495        if weight_sum > 0.0 && weight_sum.is_finite() {
496            (weighted / weight_sum, Some((1.0 / weight_sum).sqrt()))
497        } else {
498            return Err(
499                "observed-miss sigmas produced a degenerate weighting (check that every \
500                 sigma is positive and that the observations move with wind at all)"
501                    .into(),
502            );
503        }
504    } else {
505        let sum: f64 = solutions.iter().map(|s| s.solved_crosswind_mph).sum();
506        (sum / solutions.len() as f64, None)
507    };
508
509    let wind_call_factor = request
510        .called_crosswind_mph
511        .map(|called| mean_crosswind_mph / called);
512
513    // Spin drift is always modelled (the twist rate is required for exactly that reason);
514    // Coriolis only when an earth frame was supplied. Anything not listed as subtracted
515    // is, by construction, still inside the solved wind.
516    let mut subtracted_effects = vec!["spin drift".to_string()];
517    let mut unsubtracted_effects = Vec::new();
518    if request.earth.is_some() {
519        subtracted_effects.push("Coriolis".to_string());
520    } else {
521        unsubtracted_effects
522            .push("Coriolis (supply --latitude and --shot-direction to subtract it)".to_string());
523    }
524
525    Ok(WindTruingReport {
526        solutions,
527        mean_crosswind_mph,
528        mean_sigma_mph,
529        inverse_variance_weighted,
530        called_crosswind_mph: request.called_crosswind_mph,
531        wind_call_factor,
532        subtracted_effects,
533        unsubtracted_effects,
534    })
535}
536
537/// Fit one observation. Bracketed false position (Illinois), mirroring the bracketing and
538/// best-estimate-at-the-cap discipline of
539/// [`crate::truing::calculate_true_velocity_local`]: a verified sign change over the full
540/// solvable band, then an iteration that can never leave that bracket.
541fn solve_one_observation(
542    observation: &WindObservation,
543    range_yd: f64,
544    lateral_at: &impl Fn(f64, f64) -> Result<f64, Box<dyn Error>>,
545) -> Result<WindTruingSolution, Box<dyn Error>> {
546    let target = observation.miss_right_m;
547    let residual = |crosswind_mph: f64| -> Result<f64, Box<dyn Error>> {
548        Ok(lateral_at(crosswind_mph, range_yd)? - target)
549    };
550
551    let mut low = -MAX_SOLVABLE_CROSSWIND_MPH;
552    let mut high = MAX_SOLVABLE_CROSSWIND_MPH;
553    let mut f_low = residual(low)?;
554    let mut f_high = residual(high)?;
555    if f_low > 0.0 || f_high < 0.0 {
556        return Err(format!(
557            "no crosswind within +/-{MAX_SOLVABLE_CROSSWIND_MPH:.0} mph reproduces a {:.2} in \
558             miss at {range_yd:.0} yd (that band spans {:.2} to {:.2} in of deflection) — check \
559             the sign of --miss (positive = impact RIGHT of aim), the twist hand, and the load",
560            target / 0.0254,
561            (f_low + target) / 0.0254,
562            (f_high + target) / 0.0254,
563        )
564        .into());
565    }
566
567    let mut solved = 0.0;
568    let mut f_solved = 0.0;
569    let mut iterations = 0u32;
570    let mut converged = false;
571    while iterations < WIND_SOLVE_MAX_ITERATIONS {
572        iterations += 1;
573        let denom = f_high - f_low;
574        let mut candidate = if denom.abs() > f64::MIN_POSITIVE {
575            high - f_high * (high - low) / denom
576        } else {
577            0.5 * (low + high)
578        };
579        // Never step outside the bracket (that is the whole point of keeping one).
580        if !candidate.is_finite() || candidate <= low || candidate >= high {
581            candidate = 0.5 * (low + high);
582        }
583        let f = residual(candidate)?;
584        solved = candidate;
585        f_solved = f;
586        if f.abs() <= WIND_SOLVE_TOLERANCE_M || (high - low) <= WIND_SOLVE_MIN_BRACKET_MPH {
587            converged = true;
588            break;
589        }
590        // Illinois: halve the retained endpoint's function value so one side cannot stall.
591        if f < 0.0 {
592            low = candidate;
593            f_low = f;
594            f_high *= 0.5;
595        } else {
596            high = candidate;
597            f_high = f;
598            f_low *= 0.5;
599        }
600    }
601
602    // How hard the observation actually pushes back on the wind, measured at the solution.
603    let plus = lateral_at(solved + WIND_SENSITIVITY_STEP_MPH, range_yd)?;
604    let minus = lateral_at(solved - WIND_SENSITIVITY_STEP_MPH, range_yd)?;
605    let sensitivity_m_per_mph = (plus - minus) / (2.0 * WIND_SENSITIVITY_STEP_MPH);
606
607    // The lateral the model produces with NO wind: spin drift, plus Coriolis when the
608    // earth frame was supplied. This is what the fit accounted for instead of wind.
609    let no_wind_lateral_m = lateral_at(0.0, range_yd)?;
610
611    let solved_sigma_mph = observation.sigma_m.and_then(|sigma| {
612        let slope = sensitivity_m_per_mph.abs();
613        (slope > 0.0).then_some(sigma / slope)
614    });
615
616    Ok(WindTruingSolution {
617        range_m: observation.range_m,
618        observed_miss_right_m: target,
619        sigma_m: observation.sigma_m,
620        solved_crosswind_mph: solved,
621        modeled_miss_right_m: target + f_solved,
622        residual_m: f_solved,
623        no_wind_lateral_m,
624        sensitivity_m_per_mph,
625        solved_sigma_mph,
626        iterations,
627        converged,
628    })
629}
630
631/// Which rendering [`format_wind_truing_report`] should produce. Front-end-agnostic so the
632/// native CLI's `OutputFormat` and the WASM terminal's `--output` string map onto ONE
633/// formatter and cannot drift apart.
634#[derive(Debug, Clone, Copy, PartialEq, Eq)]
635pub enum WindTruingOutput {
636    Table,
637    Json,
638    Csv,
639}
640
641/// Display units for a wind-truing rendering. Ranges follow the unit system; the observed
642/// miss is always inches (a linear tape measurement, exactly like `--drop-unit in`).
643struct WindTruingUnits {
644    range_label: &'static str,
645    speed_label: &'static str,
646    range_scale: f64,
647    speed_scale: f64,
648}
649
650impl WindTruingUnits {
651    fn for_system(units: UnitSystem) -> Self {
652        match units {
653            UnitSystem::Imperial => Self {
654                range_label: "yd",
655                speed_label: "mph",
656                range_scale: 1.0 / 0.9144,
657                speed_scale: 1.0,
658            },
659            UnitSystem::Metric => Self {
660                range_label: "m",
661                speed_label: "m/s",
662                range_scale: 1.0,
663                speed_scale: MPH_TO_MPS,
664            },
665        }
666    }
667
668    fn range(&self, range_m: f64) -> f64 {
669        range_m * self.range_scale
670    }
671
672    fn speed(&self, mph: f64) -> f64 {
673        mph * self.speed_scale
674    }
675}
676
677/// Meters to inches, for the linear miss columns.
678fn inches(meters: f64) -> f64 {
679    meters / 0.0254
680}
681
682/// The wind-truing report as a JSON document (MBA-1392).
683///
684/// Split out from [`format_wind_truing_report`] so the conditional fields (the optional
685/// sigma, called wind and correction factor, which are `null` when absent) are testable on
686/// the host — `wasm.rs` is wasm32-gated, so a formatter that only existed inside it could
687/// never be asserted against natively. Same reason `drag_coefficient_json_value` exists.
688pub fn wind_truing_json_value(report: &WindTruingReport, units: UnitSystem) -> serde_json::Value {
689    let u = WindTruingUnits::for_system(units);
690    let observations: Vec<serde_json::Value> = report
691        .solutions
692        .iter()
693        .map(|s| {
694            serde_json::json!({
695                format!("range_{}", u.range_label): u.range(s.range_m),
696                "miss_right_in": inches(s.observed_miss_right_m),
697                "miss_sigma_in": s.sigma_m.map(inches),
698                "no_wind_lateral_in": inches(s.no_wind_lateral_m),
699                "solved_crosswind": u.speed(s.solved_crosswind_mph),
700                "solved_crosswind_sigma": s.solved_sigma_mph.map(|v| u.speed(v)),
701                "sensitivity_in_per_mph": inches(s.sensitivity_m_per_mph),
702                "residual_in": inches(s.residual_m),
703                "iterations": s.iterations,
704                "converged": s.converged,
705            })
706        })
707        .collect();
708
709    serde_json::json!({
710        "effective_crosswind": u.speed(report.mean_crosswind_mph),
711        "effective_crosswind_sigma": report.mean_sigma_mph.map(|v| u.speed(v)),
712        "inverse_variance_weighted": report.inverse_variance_weighted,
713        "called_crosswind": report.called_crosswind_mph.map(|v| u.speed(v)),
714        "wind_call_factor": report.wind_call_factor,
715        "observations": observations,
716        "effects_subtracted": report.subtracted_effects,
717        "effects_not_subtracted": report.unsubtracted_effects,
718        "legend": {
719            "units": {
720                "range": u.range_label,
721                "miss": "in",
722                "wind_speed": u.speed_label,
723            },
724            "signs": "--miss positive = impact right of aim; solved crosswind positive = \
725                      wind from the shooter's left (9 o'clock) pushing impacts right",
726        },
727    })
728}
729
730/// Render a [`WindTruingReport`] (MBA-1392).
731///
732/// ONE formatter for both front ends: the native CLI prints the returned string and the
733/// WASM terminal returns it, so the two surfaces are byte-identical by construction rather
734/// than by a replicated printer that has to be kept in sync.
735pub fn format_wind_truing_report(
736    report: &WindTruingReport,
737    units: UnitSystem,
738    output: WindTruingOutput,
739) -> String {
740    let u = WindTruingUnits::for_system(units);
741    match output {
742        WindTruingOutput::Json => {
743            match serde_json::to_string_pretty(&wind_truing_json_value(report, units)) {
744                Ok(s) => format!("{s}\n"),
745                Err(e) => format!("Error serializing JSON: {e}\n"),
746            }
747        }
748        WindTruingOutput::Csv => {
749            let mut out = String::new();
750            out.push_str(&format!(
751                "range_{},miss_right_in,miss_sigma_in,no_wind_lateral_in,solved_crosswind_{},\
752                 sensitivity_in_per_mph,residual_in,iterations,converged\n",
753                u.range_label, u.speed_label
754            ));
755            for s in &report.solutions {
756                out.push_str(&format!(
757                    "{:.1},{:+.3},{},{:+.3},{:+.3},{:.4},{:+.4},{},{}\n",
758                    u.range(s.range_m),
759                    inches(s.observed_miss_right_m),
760                    match s.sigma_m {
761                        Some(sigma) => format!("{:.3}", inches(sigma)),
762                        None => String::new(),
763                    },
764                    inches(s.no_wind_lateral_m),
765                    u.speed(s.solved_crosswind_mph),
766                    inches(s.sensitivity_m_per_mph),
767                    inches(s.residual_m),
768                    s.iterations,
769                    s.converged,
770                ));
771            }
772            out.push('\n');
773            out.push_str(&format!(
774                "effective_crosswind_{},effective_crosswind_sigma_{},inverse_variance_weighted,\
775                 called_crosswind_{},wind_call_factor\n",
776                u.speed_label, u.speed_label, u.speed_label
777            ));
778            out.push_str(&format!(
779                "{:+.3},{},{},{},{}\n",
780                u.speed(report.mean_crosswind_mph),
781                match report.mean_sigma_mph {
782                    Some(sigma) => format!("{:.3}", u.speed(sigma)),
783                    None => String::new(),
784                },
785                report.inverse_variance_weighted,
786                match report.called_crosswind_mph {
787                    Some(called) => format!("{:+.3}", u.speed(called)),
788                    None => String::new(),
789                },
790                match report.wind_call_factor {
791                    Some(factor) => format!("{factor:.4}"),
792                    None => String::new(),
793                },
794            ));
795            out
796        }
797        WindTruingOutput::Table => {
798            let mut out = String::new();
799            out.push('\n');
800            out.push_str("=== EFFECTIVE WIND TRUING (from observed horizontal miss) ===\n");
801            out.push('\n');
802            out.push_str(&format!(
803                "  {:>10}  {:>12}  {:>14}  {:>16}  {:>10}\n",
804                format!("Range ({})", u.range_label),
805                "Miss (in)",
806                "Spin/Cor (in)",
807                format!("Wind ({})", u.speed_label),
808                "Resid (in)",
809            ));
810            out.push_str(&format!("  {}\n", "-".repeat(70)));
811            for s in &report.solutions {
812                out.push_str(&format!(
813                    "  {:>10.1}  {:>+12.2}  {:>+14.2}  {:>+16.2}  {:>+10.3}\n",
814                    u.range(s.range_m),
815                    inches(s.observed_miss_right_m),
816                    inches(s.no_wind_lateral_m),
817                    u.speed(s.solved_crosswind_mph),
818                    inches(s.residual_m),
819                ));
820            }
821            out.push_str(&format!("  {}\n", "-".repeat(70)));
822            out.push('\n');
823            let n = report.solutions.len();
824            out.push_str(&format!(
825                "  Effective crosswind: {:>+8.2} {}{}\n",
826                u.speed(report.mean_crosswind_mph),
827                u.speed_label,
828                match report.mean_sigma_mph {
829                    Some(sigma) => format!(
830                        "  +/- {:.2} {} (inverse-variance weighted over {n} observations)",
831                        u.speed(sigma),
832                        u.speed_label
833                    ),
834                    None if n > 1 => format!("  (mean of {n} observations)"),
835                    None => String::new(),
836                }
837            ));
838            if let (Some(called), Some(factor)) =
839                (report.called_crosswind_mph, report.wind_call_factor)
840            {
841                out.push_str(&format!(
842                    "  Called wind:         {:>+8.2} {}   ->  wind-call correction factor {:.2}\n",
843                    u.speed(called),
844                    u.speed_label,
845                    factor
846                ));
847                out.push_str(&format!(
848                    "    (multiply your wind calls by {factor:.2} to match what actually hit)\n"
849                ));
850            }
851            if !report.subtracted_effects.is_empty() {
852                out.push_str(&format!(
853                    "  Effects subtracted:  {}\n",
854                    report.subtracted_effects.join(", ")
855                ));
856            }
857            if !report.unsubtracted_effects.is_empty() {
858                out.push_str(&format!(
859                    "  NOT subtracted (absorbed into the solved wind): {}\n",
860                    report.unsubtracted_effects.join(", ")
861                ));
862            }
863            for s in &report.solutions {
864                if inches(s.sensitivity_m_per_mph).abs() < MIN_WIND_SENSITIVITY_IN_PER_MPH {
865                    out.push_str(&format!(
866                        "  note: the observation at {:.1} {} moves only {:.2} in per mph of \
867                         crosswind (guide: {MIN_WIND_SENSITIVITY_IN_PER_MPH:.2} in/mph); the \
868                         wind fitted from it is weakly identified\n",
869                        u.range(s.range_m),
870                        u.range_label,
871                        inches(s.sensitivity_m_per_mph).abs(),
872                    ));
873                }
874                if !s.converged {
875                    out.push_str(&format!(
876                        "  note: the fit at {:.1} {} did not fully converge after {} iterations; \
877                         the value shown is the best estimate\n",
878                        u.range(s.range_m),
879                        u.range_label,
880                        s.iterations,
881                    ));
882                }
883            }
884            out.push('\n');
885            out.push_str(
886                "  Signs: --miss positive = impact RIGHT of aim. Solved wind positive = wind\n\
887                 \x20        FROM the shooter's LEFT (9 o'clock) pushing impacts right; negative\n\
888                 \x20        = FROM the right pushing left. Wind-FROM convention throughout\n\
889                 \x20        (0 = headwind, as of the 0.19.0 wind-direction sign fix).\n",
890            );
891            out.push('\n');
892            out
893        }
894    }
895}
896
897#[cfg(test)]
898mod tests {
899    use super::*;
900
901    fn base_request(observations: Vec<WindObservation>) -> WindTruingRequest {
902        WindTruingRequest {
903            observations,
904            muzzle_velocity_fps: 2700.0,
905            bc: 0.475,
906            drag_model: DragModelArg::G7,
907            mass_gr: 168.0,
908            diameter_in: 0.308,
909            zero_distance_yd: 100.0,
910            sight_height_in: 2.0,
911            temperature_f: 59.0,
912            pressure_inhg: 29.92,
913            humidity_pct: 50.0,
914            altitude_ft: 0.0,
915            twist: TruingTwist {
916                rate_in: 11.0,
917                right_hand: true,
918            },
919            earth: None,
920            called_crosswind_mph: None,
921        }
922    }
923
924    /// The forward model the fit inverts — used to manufacture "observed" misses for the
925    /// round-trip tests from a KNOWN wind.
926    fn modeled_lateral_m(request: &WindTruingRequest, crosswind_mph: f64, range_m: f64) -> f64 {
927        modeled_miss_right_m(request, crosswind_mph, range_m).expect("forward model must solve")
928    }
929
930    /// Round trip: take a KNOWN crosswind, read the lateral miss it produces at three
931    /// ranges out of the forward model, feed those misses back in as observations, and
932    /// recover the wind. Tolerance is 0.02 mph — three orders of magnitude tighter than
933    /// anyone's wind call, so this pins the inversion, not just its ballpark.
934    #[test]
935    fn round_trip_recovers_a_known_crosswind_at_three_ranges() {
936        let known_mph = 7.5;
937        let ranges_m = [274.32, 457.2, 640.08]; // 300 / 500 / 700 yd
938        let template = base_request(Vec::new());
939        let observations = ranges_m
940            .iter()
941            .map(|range_m| WindObservation {
942                range_m: *range_m,
943                miss_right_m: modeled_lateral_m(&template, known_mph, *range_m),
944                sigma_m: None,
945            })
946            .collect();
947
948        let report = solve_wind_truing(&base_request(observations)).expect("wind fit must solve");
949        assert_eq!(report.solutions.len(), 3);
950        for solution in &report.solutions {
951            assert!(solution.converged, "{solution:?}");
952            assert!(
953                (solution.solved_crosswind_mph - known_mph).abs() < 0.02,
954                "recovered {} mph at {} m, expected {known_mph}",
955                solution.solved_crosswind_mph,
956                solution.range_m
957            );
958        }
959        assert!((report.mean_crosswind_mph - known_mph).abs() < 0.02);
960        assert!(!report.inverse_variance_weighted);
961        assert!(report.mean_sigma_mph.is_none());
962    }
963
964    /// Sign pins, both directions. A miss to the RIGHT must solve to a POSITIVE wind
965    /// (from the shooter's left, pushing right); a miss to the LEFT must solve NEGATIVE.
966    /// These two assertions are the contract the help text documents.
967    #[test]
968    fn miss_right_solves_positive_and_miss_left_solves_negative() {
969        let template = base_request(Vec::new());
970        let range_m = 457.2; // 500 yd
971        let right_miss = modeled_lateral_m(&template, 8.0, range_m);
972        let left_miss = modeled_lateral_m(&template, -8.0, range_m);
973        assert!(right_miss > 0.0, "a left-hand wind must push impacts right");
974        assert!(left_miss < 0.0, "a right-hand wind must push impacts left");
975
976        let right = solve_wind_truing(&base_request(vec![WindObservation {
977            range_m,
978            miss_right_m: right_miss,
979            sigma_m: None,
980        }]))
981        .expect("right-miss fit must solve");
982        assert!(
983            right.mean_crosswind_mph > 0.0,
984            "right miss must solve to a positive (left-hand, right-pushing) wind, got {}",
985            right.mean_crosswind_mph
986        );
987        assert!((right.mean_crosswind_mph - 8.0).abs() < 0.02);
988
989        let left = solve_wind_truing(&base_request(vec![WindObservation {
990            range_m,
991            miss_right_m: left_miss,
992            sigma_m: None,
993        }]))
994        .expect("left-miss fit must solve");
995        assert!(
996            left.mean_crosswind_mph < 0.0,
997            "left miss must solve to a negative (right-hand, left-pushing) wind, got {}",
998            left.mean_crosswind_mph
999        );
1000        assert!((left.mean_crosswind_mph + 8.0).abs() < 0.02);
1001    }
1002
1003    /// Spin-drift subtraction: a ZERO-wind trajectory still lands right of aim (right-hand
1004    /// twist). Feeding that pure spin drift in as the observed miss must solve to ~zero
1005    /// wind — the drift is attributed to spin, not to a phantom wind.
1006    #[test]
1007    fn pure_spin_drift_solves_to_zero_wind() {
1008        let template = base_request(Vec::new());
1009        let range_m = 640.08; // 700 yd
1010        let spin_only = modeled_lateral_m(&template, 0.0, range_m);
1011        assert!(
1012            spin_only > 0.05,
1013            "a 1:11 right-hand twist must drift measurably right at 700 yd, got {spin_only} m"
1014        );
1015
1016        let report = solve_wind_truing(&base_request(vec![WindObservation {
1017            range_m,
1018            miss_right_m: spin_only,
1019            sigma_m: None,
1020        }]))
1021        .expect("spin-only fit must solve");
1022        assert!(
1023            report.mean_crosswind_mph.abs() < 0.02,
1024            "pure spin drift must solve to ~0 wind, got {}",
1025            report.mean_crosswind_mph
1026        );
1027        // ... and the report must say the drift was accounted for, not silently eaten.
1028        assert!(report
1029            .solutions
1030            .iter()
1031            .all(|s| (s.no_wind_lateral_m - spin_only).abs() < 1e-9));
1032        assert!(report
1033            .subtracted_effects
1034            .iter()
1035            .any(|e| e.contains("spin drift")));
1036    }
1037
1038    /// A left-hand twist drifts the other way, so the SAME right-of-aim miss must solve to
1039    /// a stronger wind than a right-hand twist needs. Pins that the twist hand actually
1040    /// reaches the forward model rather than being cosmetic.
1041    #[test]
1042    fn twist_hand_changes_the_solved_wind() {
1043        let range_m = 640.08;
1044        let observation = WindObservation {
1045            range_m,
1046            miss_right_m: 0.25,
1047            sigma_m: None,
1048        };
1049        let right_hand = solve_wind_truing(&base_request(vec![observation])).expect("solve");
1050        let mut left = base_request(vec![observation]);
1051        left.twist.right_hand = false;
1052        let left_hand = solve_wind_truing(&left).expect("solve");
1053        assert!(
1054            left_hand.mean_crosswind_mph > right_hand.mean_crosswind_mph + 0.1,
1055            "left-hand twist ({}) must need more right-pushing wind than right-hand ({})",
1056            left_hand.mean_crosswind_mph,
1057            right_hand.mean_crosswind_mph
1058        );
1059    }
1060
1061    /// Coriolis subtraction: supplying a latitude and shot azimuth changes the zero-wind
1062    /// lateral (Coriolis is now modelled), so the same observed miss solves to a different
1063    /// wind, and the report moves Coriolis from "not subtracted" to "subtracted".
1064    #[test]
1065    fn coriolis_is_subtracted_when_latitude_and_azimuth_are_supplied() {
1066        let range_m = 914.4; // 1000 yd, where Coriolis is actually measurable
1067        let observation = WindObservation {
1068            range_m,
1069            miss_right_m: 0.30,
1070            sigma_m: None,
1071        };
1072        let without = solve_wind_truing(&base_request(vec![observation])).expect("solve");
1073        let mut with_earth = base_request(vec![observation]);
1074        with_earth.earth = Some(TruingEarthFrame {
1075            latitude_deg: 45.0,
1076            shot_azimuth_deg: 90.0, // due East, where the Coriolis lateral is largest
1077        });
1078        let with = solve_wind_truing(&with_earth).expect("solve");
1079
1080        assert!(without
1081            .unsubtracted_effects
1082            .iter()
1083            .any(|e| e.contains("Coriolis")));
1084        assert!(without.subtracted_effects.iter().all(|e| e != "Coriolis"));
1085        assert!(with.unsubtracted_effects.is_empty());
1086        assert!(with.subtracted_effects.iter().any(|e| e == "Coriolis"));
1087        assert!(
1088            (with.solutions[0].no_wind_lateral_m - without.solutions[0].no_wind_lateral_m).abs()
1089                > 1e-4,
1090            "modelling Coriolis must change the zero-wind lateral"
1091        );
1092        assert!(
1093            (with.mean_crosswind_mph - without.mean_crosswind_mph).abs() > 1e-3,
1094            "modelling Coriolis must change the solved wind"
1095        );
1096    }
1097
1098    /// The wind-call correction factor is solved / called, and its sign survives: calling
1099    /// the wrong SIDE of the wind gives a negative factor rather than a plausible-looking
1100    /// positive one.
1101    #[test]
1102    fn wind_call_factor_is_solved_over_called_and_keeps_its_sign() {
1103        let template = base_request(Vec::new());
1104        let range_m = 457.2;
1105        let miss = modeled_lateral_m(&template, 9.0, range_m);
1106        let observation = WindObservation {
1107            range_m,
1108            miss_right_m: miss,
1109            sigma_m: None,
1110        };
1111
1112        let mut under_called = base_request(vec![observation]);
1113        under_called.called_crosswind_mph = Some(6.0);
1114        let report = solve_wind_truing(&under_called).expect("solve");
1115        let factor = report.wind_call_factor.expect("factor");
1116        assert!(
1117            (factor - 9.0 / 6.0).abs() < 0.01,
1118            "expected ~1.5, got {factor}"
1119        );
1120
1121        let mut wrong_side = base_request(vec![observation]);
1122        wrong_side.called_crosswind_mph = Some(-6.0);
1123        let flipped = solve_wind_truing(&wrong_side)
1124            .expect("solve")
1125            .wind_call_factor
1126            .expect("factor");
1127        assert!(flipped < 0.0, "a wrong-side call must read negative: {flipped}");
1128    }
1129
1130    /// Sigmas: all-or-none. Every observation weighted -> inverse-variance mean with a
1131    /// reported sigma; none weighted -> plain mean; a mix is a hard error rather than a
1132    /// silent blend of weighting schemes.
1133    #[test]
1134    fn sigmas_are_all_or_none_and_drive_inverse_variance_weighting() {
1135        let template = base_request(Vec::new());
1136        let near = 274.32;
1137        let far = 640.08;
1138        let weighted = vec![
1139            WindObservation {
1140                range_m: near,
1141                miss_right_m: modeled_lateral_m(&template, 6.0, near),
1142                sigma_m: Some(0.25 * 0.0254),
1143            },
1144            WindObservation {
1145                range_m: far,
1146                miss_right_m: modeled_lateral_m(&template, 6.0, far),
1147                sigma_m: Some(0.25 * 0.0254),
1148            },
1149        ];
1150        let report = solve_wind_truing(&base_request(weighted)).expect("solve");
1151        assert!(report.inverse_variance_weighted);
1152        let sigma = report.mean_sigma_mph.expect("weighted mean sigma");
1153        assert!(sigma > 0.0 && sigma.is_finite());
1154        // The long-range observation is far more sensitive to wind, so its propagated
1155        // sigma must be the smaller of the two (it carries the most weight).
1156        let near_sigma = report.solutions[0].solved_sigma_mph.expect("sigma");
1157        let far_sigma = report.solutions[1].solved_sigma_mph.expect("sigma");
1158        assert!(far_sigma < near_sigma, "{far_sigma} !< {near_sigma}");
1159        assert!(sigma <= far_sigma + 1e-12);
1160
1161        let mixed = base_request(vec![
1162            WindObservation {
1163                range_m: near,
1164                miss_right_m: 0.1,
1165                sigma_m: Some(0.006),
1166            },
1167            WindObservation {
1168                range_m: far,
1169                miss_right_m: 0.2,
1170                sigma_m: None,
1171            },
1172        ]);
1173        let error = mixed.validate().unwrap_err();
1174        assert!(error.contains("every observed miss or on none"), "{error}");
1175    }
1176
1177    /// An unreachable miss (wrong sign, or simply not a wind effect) is rejected with a
1178    /// diagnostic naming the solvable band, instead of being clamped into a fake answer.
1179    #[test]
1180    fn an_unreachable_miss_is_rejected_with_the_solvable_band() {
1181        let error = solve_wind_truing(&base_request(vec![WindObservation {
1182            range_m: 274.32,
1183            miss_right_m: 25.0, // 25 metres right at 300 yd: no wind does that
1184            sigma_m: None,
1185        }]))
1186        .unwrap_err()
1187        .to_string();
1188        assert!(error.contains("no crosswind within"), "{error}");
1189        assert!(error.contains("check the sign of --miss"), "{error}");
1190    }
1191
1192    /// MBA-1358 / design R5-DIRECTION: `--miss` values are LINEAR inches off the target,
1193    /// not dial readings, so a scope tracking correction factor must NOT touch them. The
1194    /// structural guarantee is that no CF can reach this solver at all — there is no field
1195    /// for one — and this test pins the consequence: a windage CF applied the way the
1196    /// DIALED truing path applies it (observation x CF) would move the answer, so it must
1197    /// never be applied here.
1198    #[test]
1199    fn windage_cf_does_not_alter_the_wind_solve() {
1200        let template = base_request(Vec::new());
1201        let range_m = 457.2;
1202        let miss = modeled_lateral_m(&template, 7.0, range_m);
1203        let observation = WindObservation {
1204            range_m,
1205            miss_right_m: miss,
1206            sigma_m: None,
1207        };
1208        let solved = solve_wind_truing(&base_request(vec![observation]))
1209            .expect("solve")
1210            .mean_crosswind_mph;
1211        assert!((solved - 7.0).abs() < 0.02);
1212
1213        // What the dialed path would have done to a 0.95 CF observation. It changes the
1214        // answer materially, which is exactly why linear inputs must be left alone.
1215        let windage_cf = 0.95;
1216        let cf_applied = solve_wind_truing(&base_request(vec![WindObservation {
1217            range_m,
1218            miss_right_m: miss * windage_cf,
1219            sigma_m: None,
1220        }]))
1221        .expect("solve")
1222        .mean_crosswind_mph;
1223        assert!(
1224            (cf_applied - solved).abs() > 0.1,
1225            "a CF-scaled observation must NOT be equivalent to the linear one \
1226             ({cf_applied} vs {solved}); --miss therefore takes no CF"
1227        );
1228    }
1229
1230    /// The JSON emit helper: conditional fields are explicit `null`s (never dropped keys),
1231    /// so consumers can tell "not supplied" from "absent field".
1232    #[test]
1233    fn json_value_nulls_absent_optional_fields() {
1234        let template = base_request(Vec::new());
1235        let range_m = 457.2;
1236        let report = solve_wind_truing(&base_request(vec![WindObservation {
1237            range_m,
1238            miss_right_m: modeled_lateral_m(&template, 5.0, range_m),
1239            sigma_m: None,
1240        }]))
1241        .expect("solve");
1242        let value = wind_truing_json_value(&report, UnitSystem::Imperial);
1243        assert!(value["called_crosswind"].is_null());
1244        assert!(value["wind_call_factor"].is_null());
1245        assert!(value["effective_crosswind_sigma"].is_null());
1246        assert!(value["observations"][0]["miss_sigma_in"].is_null());
1247        assert!(value["observations"][0]["solved_crosswind_sigma"].is_null());
1248        assert_eq!(value["legend"]["units"]["wind_speed"], "mph");
1249        assert_eq!(value["legend"]["units"]["miss"], "in");
1250        assert_eq!(
1251            value["effective_crosswind"].as_f64().expect("f64").round(),
1252            5.0
1253        );
1254
1255        // Metric renders the same wind in m/s and the same ranges in meters, while the
1256        // linear miss stays in inches (the --drop-unit in precedent).
1257        let metric = wind_truing_json_value(&report, UnitSystem::Metric);
1258        assert_eq!(metric["legend"]["units"]["wind_speed"], "m/s");
1259        assert_eq!(metric["legend"]["units"]["range"], "m");
1260        assert_eq!(metric["legend"]["units"]["miss"], "in");
1261        let mps = metric["effective_crosswind"].as_f64().expect("f64");
1262        let mph = value["effective_crosswind"].as_f64().expect("f64");
1263        assert!((mps - mph * MPH_TO_MPS).abs() < 1e-12);
1264    }
1265
1266    /// Parser contract: RANGE follows the unit system, the offset and sigma are inches in
1267    /// both, and malformed tokens are rejected with a usable message.
1268    #[test]
1269    fn parse_wind_observation_units_and_errors() {
1270        let imperial = parse_wind_observation("600:8.5", UnitSystem::Imperial).expect("parse");
1271        assert!((imperial.range_m - 600.0 * 0.9144).abs() < 1e-12);
1272        assert!((imperial.miss_right_m - 8.5 * 0.0254).abs() < 1e-12);
1273        assert!(imperial.sigma_m.is_none());
1274
1275        let metric = parse_wind_observation("550:-8.5:0.75", UnitSystem::Metric).expect("parse");
1276        assert!((metric.range_m - 550.0).abs() < 1e-12);
1277        assert!((metric.miss_right_m + 8.5 * 0.0254).abs() < 1e-12);
1278        assert!((metric.sigma_m.expect("sigma") - 0.75 * 0.0254).abs() < 1e-12);
1279
1280        for bad in ["600", "600:8.5:0.1:2", "600:right", "abc:8.5", "600:nan"] {
1281            assert!(
1282                parse_wind_observation(bad, UnitSystem::Imperial).is_err(),
1283                "'{bad}' should not parse"
1284            );
1285        }
1286    }
1287
1288    /// Validation rejects degenerate requests before spending a single trajectory solve.
1289    #[test]
1290    fn validation_rejects_degenerate_requests() {
1291        assert!(base_request(Vec::new())
1292            .validate()
1293            .unwrap_err()
1294            .contains("at least one"));
1295
1296        let duplicate = base_request(vec![
1297            WindObservation {
1298                range_m: 457.2,
1299                miss_right_m: 0.2,
1300                sigma_m: None,
1301            },
1302            WindObservation {
1303                range_m: 457.2,
1304                miss_right_m: 0.3,
1305                sigma_m: None,
1306            },
1307        ]);
1308        assert!(duplicate
1309            .validate()
1310            .unwrap_err()
1311            .contains("duplicate observation range"));
1312
1313        let mut bad_twist = base_request(vec![WindObservation {
1314            range_m: 457.2,
1315            miss_right_m: 0.2,
1316            sigma_m: None,
1317        }]);
1318        bad_twist.twist.rate_in = 0.0;
1319        assert!(bad_twist
1320            .validate()
1321            .unwrap_err()
1322            .contains("twist rate must be positive"));
1323
1324        let mut zero_call = base_request(vec![WindObservation {
1325            range_m: 457.2,
1326            miss_right_m: 0.2,
1327            sigma_m: None,
1328        }]);
1329        zero_call.called_crosswind_mph = Some(0.0);
1330        assert!(zero_call.validate().unwrap_err().contains("non-zero"));
1331    }
1332
1333    /// Bridge contract: a `WindTruingRequest` deserializes from JSON with the field names
1334    /// documented on the struct, and a solved `WindTruingReport` serializes back out.
1335    #[test]
1336    fn request_deserializes_and_report_serializes() {
1337        let json = serde_json::json!({
1338            "observations": [{"range_m": 457.2, "miss_right_m": 0.315, "sigma_m": null}],
1339            "muzzle_velocity_fps": 2700.0, "bc": 0.243, "drag_model": "g7",
1340            "mass_gr": 168.0, "diameter_in": 0.308, "zero_distance_yd": 100.0,
1341            "sight_height_in": 2.0, "temperature_f": 59.0, "pressure_inhg": 29.92,
1342            "humidity_pct": 50.0, "altitude_ft": 0.0,
1343            "twist": {"rate_in": 11.0, "right_hand": true},
1344            "earth": null, "called_crosswind_mph": null
1345        });
1346        let req: WindTruingRequest =
1347            serde_json::from_value(json).expect("request deserializes");
1348        let report = solve_wind_truing(&req).expect("solves");
1349        let out = serde_json::to_value(&report).expect("report serializes");
1350        assert!(out["mean_crosswind_mph"].is_number());
1351        assert!(out["solutions"].as_array().unwrap().len() == 1);
1352    }
1353}