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 crate::cli_api::UnitSystem;
44use crate::truing::{
45    DragModelArg, TruingEarthFrame, TruingEnvironment, TruingTwist, TRUING_BC_MAX, TRUING_BC_MIN,
46    TRUING_MV_MAX_FPS, TRUING_MV_MIN_FPS,
47};
48use crate::{BCSegmentData, WindConditions};
49
50/// Miles per hour to meters per second (exact, by definition of the international mile).
51/// `pub` so the front ends convert `--called-wind` with the same factor this module
52/// renders with, rather than each re-typing the literal.
53pub const MPH_TO_MPS: f64 = 0.44704;
54
55/// Widest crosswind the solver will bracket, in mph (signed, so the bracket is
56/// `-100..=+100`). Comfortably past any wind a rifle shooter reports; a miss that needs
57/// more than this is a data-entry or sign error, not a wind call, and is rejected with a
58/// diagnostic rather than solved into a fantasy number.
59pub const MAX_SOLVABLE_CROSSWIND_MPH: f64 = 100.0;
60
61/// Convergence tolerance on the modelled-minus-observed lateral miss, in meters
62/// (0.01 mm — four orders of magnitude finer than anyone can measure a group centre).
63/// `pub` because it is the published meaning of a converged
64/// [`WindTruingSolution::residual_m`].
65pub const WIND_SOLVE_TOLERANCE_M: f64 = 1.0e-5;
66
67/// Bracket width (mph) at which the root find stops regardless of residual: the two ends
68/// are numerically the same wind, so further bisection cannot improve the answer.
69const WIND_SOLVE_MIN_BRACKET_MPH: f64 = 1.0e-9;
70
71/// Iteration cap for the per-observation root find. Lateral deflection is monotone and
72/// near-linear in crosswind speed (the Didion lag-time relation), so the bracketed
73/// false-position iteration below normally converges in well under ten evaluations; this
74/// is the runaway guard, not the expected count.
75const WIND_SOLVE_MAX_ITERATIONS: u32 = 60;
76
77/// Central-difference step (mph) used to measure how strongly the observation constrains
78/// the wind. Large enough that the trajectory integrator's own sampling noise does not
79/// dominate the difference, small enough to stay in the locally-linear regime.
80const WIND_SENSITIVITY_STEP_MPH: f64 = 0.5;
81
82/// Guide value for the wind-truing validity note: below this many inches of lateral
83/// movement per mph of crosswind, the observation barely constrains the wind and the
84/// number solved from it is weakly identified. Same spirit as the MV-calibration window
85/// (MBA-1405) — a stated band the report checks each observation against, not a hard gate.
86pub const MIN_WIND_SENSITIVITY_IN_PER_MPH: f64 = 0.25;
87
88/// A single observed horizontal miss used to back-solve effective wind (MBA-1392).
89///
90/// SI throughout; front ends convert their display units once at the boundary (see
91/// [`parse_wind_observation`]).
92#[derive(Debug, Clone, Copy, PartialEq)]
93pub struct WindObservation {
94    /// Range at which the group centre was measured, meters.
95    pub range_m: f64,
96    /// Signed horizontal miss of the group centre, meters. POSITIVE = RIGHT of aim.
97    pub miss_right_m: f64,
98    /// Optional one-standard-deviation measurement error of `miss_right_m`, meters.
99    /// Supply it on every observation or on none — see [`WindTruingRequest::validate`].
100    pub sigma_m: Option<f64>,
101}
102
103/// Parse a `--miss RANGE:RIGHT[:SIGMA]` token.
104///
105/// `RANGE` is in the caller's distance units (yards imperial / meters metric); `RIGHT` and
106/// `SIGMA` are LINEAR INCHES in both unit systems, matching the existing `--drop-unit in`
107/// contract (a tape measurement off the target is reported in inches whatever the range
108/// unit is). Returns a user-facing error string on malformed input.
109pub fn parse_wind_observation(s: &str, units: UnitSystem) -> Result<WindObservation, String> {
110    let parts: Vec<&str> = s.split(':').collect();
111    if parts.len() != 2 && parts.len() != 3 {
112        return Err(format!(
113            "invalid --miss '{s}': expected RANGE:RIGHT_IN[:SIGMA] (e.g. 600:8.5 or 600:8.5:0.75)"
114        ));
115    }
116    let range: f64 = parts[0]
117        .trim()
118        .parse()
119        .map_err(|_| format!("invalid --miss range '{}' in '{s}'", parts[0]))?;
120    let miss_in: f64 = parts[1]
121        .trim()
122        .parse()
123        .map_err(|_| format!("invalid --miss offset '{}' in '{s}'", parts[1]))?;
124    let sigma_in: Option<f64> = match parts.get(2) {
125        Some(token) => Some(
126            token
127                .trim()
128                .parse()
129                .map_err(|_| format!("invalid --miss sigma '{token}' in '{s}'"))?,
130        ),
131        None => None,
132    };
133    if !range.is_finite() || !miss_in.is_finite() || sigma_in.is_some_and(|v| !v.is_finite()) {
134        return Err(format!("invalid --miss '{s}': values must be finite"));
135    }
136    let range_m = match units {
137        UnitSystem::Imperial => range * 0.9144,
138        UnitSystem::Metric => range,
139    };
140    Ok(WindObservation {
141        range_m,
142        miss_right_m: miss_in * 0.0254,
143        sigma_m: sigma_in.map(|v| v * 0.0254),
144    })
145}
146
147/// Everything the wind fit needs: the observed misses plus the load, rifle, atmosphere and
148/// the opt-in earth frame (MBA-1392).
149///
150/// Imperial units for the load/atmosphere fields, matching the truing core's historical
151/// internal convention; the observations themselves are SI. Unlike the drop-based truing
152/// commands this carries a KNOWN muzzle velocity — wind is the unknown being fitted, so
153/// velocity is an input, not an output.
154#[derive(Debug, Clone)]
155pub struct WindTruingRequest {
156    /// One or more observed horizontal misses. Ranges must be distinct.
157    pub observations: Vec<WindObservation>,
158    /// Known muzzle velocity, feet/second (true up first with `true-velocity` if unsure).
159    pub muzzle_velocity_fps: f64,
160    /// Scalar ballistic coefficient for `drag_model`.
161    pub bc: f64,
162    pub drag_model: DragModelArg,
163    /// Bullet mass in grains.
164    pub mass_gr: f64,
165    /// Bullet diameter in inches.
166    pub diameter_in: f64,
167    /// Zero distance in yards.
168    pub zero_distance_yd: f64,
169    /// Sight height over bore in inches.
170    pub sight_height_in: f64,
171    /// Ambient temperature in degrees Fahrenheit.
172    pub temperature_f: f64,
173    /// Station pressure in inches of mercury.
174    pub pressure_inhg: f64,
175    /// Relative humidity in percent (0 through 100).
176    pub humidity_pct: f64,
177    /// Altitude in feet.
178    pub altitude_ft: f64,
179    /// Barrel twist. REQUIRED: spin drift is a lateral effect of the same order as a
180    /// light wind at long range, so without it the fit would silently report spin drift
181    /// as wind.
182    pub twist: TruingTwist,
183    /// Latitude + shot azimuth. `None` leaves Coriolis unmodelled and absorbed into the
184    /// solved wind (the report names it as unsubtracted).
185    pub earth: Option<TruingEarthFrame>,
186    /// The wind the shooter CALLED, mph, in the same signed convention as the solved
187    /// value. `Some` adds a wind-call correction factor (solved / called) to the report.
188    pub called_crosswind_mph: Option<f64>,
189}
190
191impl WindTruingRequest {
192    /// Validate the whole request before any (expensive) trajectory work begins.
193    ///
194    /// Mirrors [`crate::truing::TruingModelInputsV1::validate`] plus the observation-set
195    /// rules from [`crate::truing::validate_truing_observations`]: finite positive ranges,
196    /// finite misses, no duplicate ranges. Sigmas are all-or-none — a half-weighted set
197    /// would silently mix an inverse-variance mean with unit weights.
198    pub fn validate(&self) -> Result<(), String> {
199        if self.observations.is_empty() {
200            return Err("at least one observed horizontal miss is required".to_string());
201        }
202        if !self.muzzle_velocity_fps.is_finite()
203            || !(TRUING_MV_MIN_FPS..=TRUING_MV_MAX_FPS).contains(&self.muzzle_velocity_fps)
204        {
205            return Err(format!(
206                "muzzle velocity must be finite and within {TRUING_MV_MIN_FPS:.0}..={TRUING_MV_MAX_FPS:.0} fps"
207            ));
208        }
209        if !self.bc.is_finite() || !(TRUING_BC_MIN..=TRUING_BC_MAX).contains(&self.bc) {
210            return Err(format!(
211                "ballistic coefficient must be finite and within {TRUING_BC_MIN:.2}..={TRUING_BC_MAX:.1}"
212            ));
213        }
214        for (name, value) in [
215            ("bullet mass", self.mass_gr),
216            ("bullet diameter", self.diameter_in),
217            ("zero distance", self.zero_distance_yd),
218            ("sight height", self.sight_height_in),
219            ("pressure", self.pressure_inhg),
220            ("twist rate", self.twist.rate_in),
221        ] {
222            if !value.is_finite() || value <= 0.0 {
223                return Err(format!("{name} must be positive and finite"));
224            }
225        }
226        if !self.temperature_f.is_finite() {
227            return Err("temperature must be finite".to_string());
228        }
229        if !self.humidity_pct.is_finite() || !(0.0..=100.0).contains(&self.humidity_pct) {
230            return Err("humidity must be finite and within 0..=100 percent".to_string());
231        }
232        if !self.altitude_ft.is_finite() {
233            return Err("altitude must be finite".to_string());
234        }
235        if let Some(earth) = self.earth {
236            if !earth.latitude_deg.is_finite() || !(-90.0..=90.0).contains(&earth.latitude_deg) {
237                return Err("latitude must be finite and within -90..=90 degrees".to_string());
238            }
239            if !earth.shot_azimuth_deg.is_finite() {
240                return Err("shot azimuth must be finite".to_string());
241            }
242        }
243        if let Some(called) = self.called_crosswind_mph {
244            if !called.is_finite() || called == 0.0 {
245                return Err(
246                    "the called wind must be finite and non-zero (a zero call has no \
247                     correction factor)"
248                        .to_string(),
249                );
250            }
251        }
252        for observation in &self.observations {
253            if !observation.range_m.is_finite() || observation.range_m <= 0.0 {
254                return Err(format!(
255                    "observation range must be a positive finite distance (got {})",
256                    observation.range_m
257                ));
258            }
259            if !observation.miss_right_m.is_finite() {
260                return Err("observed horizontal miss must be finite".to_string());
261            }
262            if observation
263                .sigma_m
264                .is_some_and(|sigma| !sigma.is_finite() || sigma <= 0.0)
265            {
266                return Err("an observed-miss sigma must be positive and finite".to_string());
267            }
268        }
269        for i in 0..self.observations.len() {
270            for j in (i + 1)..self.observations.len() {
271                if (self.observations[i].range_m - self.observations[j].range_m).abs() < 1e-6 {
272                    return Err(format!(
273                        "duplicate observation range ({:.3} m): each observed miss must be at a \
274                         distinct range",
275                        self.observations[i].range_m
276                    ));
277                }
278            }
279        }
280        let with_sigma = self
281            .observations
282            .iter()
283            .filter(|o| o.sigma_m.is_some())
284            .count();
285        if with_sigma != 0 && with_sigma != self.observations.len() {
286            return Err(
287                "supply a sigma on every observed miss or on none: mixing weighted and \
288                 unweighted observations would silently combine inverse-variance weights \
289                 with unit weights"
290                    .to_string(),
291            );
292        }
293        Ok(())
294    }
295}
296
297/// The wind fitted from ONE observed miss (MBA-1392).
298#[derive(Debug, Clone, Copy, PartialEq)]
299pub struct WindTruingSolution {
300    /// Observation range, meters.
301    pub range_m: f64,
302    /// The observed miss that was fitted, meters (positive = right).
303    pub observed_miss_right_m: f64,
304    /// The supplied measurement sigma, meters, if any.
305    pub sigma_m: Option<f64>,
306    /// The fitted constant crosswind, mph, signed (positive = from the left, pushing right).
307    pub solved_crosswind_mph: f64,
308    /// The model's lateral miss at `solved_crosswind_mph`, meters — equals the observed
309    /// miss to within [`WIND_SOLVE_TOLERANCE_M`] on a converged solve.
310    pub modeled_miss_right_m: f64,
311    /// `modeled_miss_right_m - observed_miss_right_m`, meters.
312    pub residual_m: f64,
313    /// The model's lateral miss with ZERO wind, meters: the part of the observation
314    /// attributed to spin drift (always) and Coriolis (when an earth frame was supplied)
315    /// rather than to wind.
316    pub no_wind_lateral_m: f64,
317    /// How far the impact moves per mph of crosswind at the solution, meters/mph — the
318    /// identifiability measure behind the report's weak-signal note.
319    pub sensitivity_m_per_mph: f64,
320    /// The observation sigma propagated into wind units, mph
321    /// (`sigma_m / |sensitivity_m_per_mph|`); `None` when no sigma was supplied.
322    pub solved_sigma_mph: Option<f64>,
323    /// Root-find iterations actually run.
324    pub iterations: u32,
325    /// Whether the root find hit its tolerance (`false` = the reported value is the best
326    /// estimate at the iteration cap).
327    pub converged: bool,
328}
329
330/// The complete wind-truing result: one fit per observation plus the combined answer.
331#[derive(Debug, Clone)]
332pub struct WindTruingReport {
333    /// Per-observation fits, in the order the observations were supplied.
334    pub solutions: Vec<WindTruingSolution>,
335    /// Combined effective crosswind, mph, signed.
336    pub mean_crosswind_mph: f64,
337    /// One-sigma uncertainty of `mean_crosswind_mph`, mph; `Some` only when every
338    /// observation carried a sigma.
339    pub mean_sigma_mph: Option<f64>,
340    /// `true` when the mean is inverse-variance weighted (all sigmas supplied), `false`
341    /// when it is the plain arithmetic mean.
342    pub inverse_variance_weighted: bool,
343    /// The wind the shooter called, mph, if supplied.
344    pub called_crosswind_mph: Option<f64>,
345    /// `mean_crosswind_mph / called_crosswind_mph`: >1 means the shooter under-called the
346    /// wind, <1 means they over-called it, negative means they called the wrong side.
347    pub wind_call_factor: Option<f64>,
348    /// Lateral effects the model actually accounted for, so they are NOT in the solved wind.
349    pub subtracted_effects: Vec<String>,
350    /// Lateral effects the model had no data for, which therefore ARE absorbed into the
351    /// solved wind. Empty means everything this model knows about was subtracted.
352    pub unsubtracted_effects: Vec<String>,
353}
354
355/// Wind conditions describing a signed full-value crosswind (MBA-1392).
356///
357/// Uses the engine's wind-FROM convention: `PI/2` is a wind FROM the shooter's RIGHT
358/// (pushing impacts LEFT) and `3*PI/2` is FROM the LEFT (pushing impacts RIGHT). The
359/// signed input follows the DEFLECTION, so positive maps to `3*PI/2`. The pair is
360/// continuous through zero — both directions give the zero vector at zero speed — so the
361/// root find sees a smooth function across the sign change.
362fn crosswind_conditions(signed_mph: f64) -> WindConditions {
363    WindConditions {
364        speed: signed_mph.abs() * MPH_TO_MPS,
365        direction: if signed_mph < 0.0 {
366            std::f64::consts::FRAC_PI_2
367        } else {
368            3.0 * std::f64::consts::FRAC_PI_2
369        },
370        vertical_speed: 0.0,
371    }
372}
373
374/// The FORWARD direction of [`solve_wind_truing`]: the lateral miss (meters, positive =
375/// right of the line of sight) this request's load / atmosphere / twist / earth frame
376/// predicts at `range_m` under a signed `crosswind_mph` (MBA-1392).
377///
378/// `request.observations` and `request.called_crosswind_mph` are ignored — only the model
379/// half is used — and no validation is run, so a caller that has not validated its request
380/// will simply see the solver's own error. Public because "what miss does N mph produce?"
381/// is the question a wind-call drill actually asks, and because it lets any caller check
382/// that the inversion round-trips on its own model rather than trusting it to.
383pub fn modeled_miss_right_m(
384    request: &WindTruingRequest,
385    crosswind_mph: f64,
386    range_m: f64,
387) -> Result<f64, Box<dyn Error>> {
388    let no_bc_segments: Option<Vec<BCSegmentData>> = None;
389    let env = TruingEnvironment {
390        wind: crosswind_conditions(crosswind_mph),
391        twist: Some(request.twist),
392        earth: request.earth,
393    };
394    let sample = crate::truing::solve_trajectory_sample(
395        request.muzzle_velocity_fps,
396        request.bc,
397        request.drag_model,
398        request.mass_gr,
399        request.diameter_in,
400        request.zero_distance_yd,
401        range_m / 0.9144,
402        request.sight_height_in,
403        request.temperature_f,
404        request.pressure_inhg,
405        request.humidity_pct,
406        request.altitude_ft,
407        &no_bc_segments,
408        &env,
409        true, // interpolate: land exactly on the requested range
410    )?;
411    Ok(sample.lateral_m)
412}
413
414/// Back-solve the effective crosswind from every observed miss in `request` (MBA-1392).
415///
416/// Each observation is fitted independently with a bracketed root find on the constant
417/// crosswind speed, against the real forward trajectory model (the truing core's own
418/// solver assembly, zero-angle solve and atmosphere — the same one the drop-based truing
419/// commands use, now sampled on the lateral axis and carrying spin drift, and optionally
420/// Coriolis). See [`modeled_miss_right_m`] for that forward direction on its own.
421///
422/// Errors on an invalid request, on any trajectory-solver failure, and when an observed
423/// miss cannot be produced by any crosswind inside `+/-`[`MAX_SOLVABLE_CROSSWIND_MPH`] —
424/// which in practice means the miss was entered with the wrong sign, or is not a wind
425/// effect at all.
426pub fn solve_wind_truing(request: &WindTruingRequest) -> Result<WindTruingReport, Box<dyn Error>> {
427    request.validate()?;
428
429    // No BC5D velocity-banded segments on this path: the wind fit exposes one scalar BC,
430    // matching the scalar-BC truing model (a banded schedule has no single BC to pair
431    // with the fitted wind), and keeps the command entirely offline.
432    let no_bc_segments: Option<Vec<BCSegmentData>> = None;
433
434    let environment = |crosswind_mph: f64| TruingEnvironment {
435        wind: crosswind_conditions(crosswind_mph),
436        twist: Some(request.twist),
437        earth: request.earth,
438    };
439
440    // Lateral (McCoy z, positive = right) at `range_yd` for a candidate crosswind.
441    let lateral_at = |crosswind_mph: f64, range_yd: f64| -> Result<f64, Box<dyn Error>> {
442        let sample = crate::truing::solve_trajectory_sample(
443            request.muzzle_velocity_fps,
444            request.bc,
445            request.drag_model,
446            request.mass_gr,
447            request.diameter_in,
448            request.zero_distance_yd,
449            range_yd,
450            request.sight_height_in,
451            request.temperature_f,
452            request.pressure_inhg,
453            request.humidity_pct,
454            request.altitude_ft,
455            &no_bc_segments,
456            &environment(crosswind_mph),
457            true, // interpolate: land exactly on the observation range
458        )?;
459        Ok(sample.lateral_m)
460    };
461
462    let mut solutions = Vec::with_capacity(request.observations.len());
463    for observation in &request.observations {
464        let range_yd = observation.range_m / 0.9144;
465        solutions.push(solve_one_observation(observation, range_yd, &lateral_at)?);
466    }
467
468    // Combine. Inverse-variance weighting only when every observation carried a sigma
469    // (all-or-none, enforced by `validate`), otherwise the plain arithmetic mean. A
470    // supplied sigma that could not be expressed in wind units is an error, NOT a quiet
471    // demotion to unit weights: the caller asked for a weighted answer.
472    let inverse_variance_weighted = solutions.iter().all(|s| s.sigma_m.is_some());
473    if inverse_variance_weighted && solutions.iter().any(|s| s.solved_sigma_mph.is_none()) {
474        return Err(
475            "an observation does not move with crosswind at all, so its measurement sigma \
476             cannot be expressed in wind units — drop that observation or its sigma"
477                .into(),
478        );
479    }
480    let (mean_crosswind_mph, mean_sigma_mph) = if inverse_variance_weighted {
481        let mut weight_sum = 0.0;
482        let mut weighted = 0.0;
483        for solution in &solutions {
484            let sigma = solution
485                .solved_sigma_mph
486                .expect("checked by inverse_variance_weighted");
487            let weight = 1.0 / (sigma * sigma);
488            weight_sum += weight;
489            weighted += weight * solution.solved_crosswind_mph;
490        }
491        if weight_sum > 0.0 && weight_sum.is_finite() {
492            (weighted / weight_sum, Some((1.0 / weight_sum).sqrt()))
493        } else {
494            return Err(
495                "observed-miss sigmas produced a degenerate weighting (check that every \
496                 sigma is positive and that the observations move with wind at all)"
497                    .into(),
498            );
499        }
500    } else {
501        let sum: f64 = solutions.iter().map(|s| s.solved_crosswind_mph).sum();
502        (sum / solutions.len() as f64, None)
503    };
504
505    let wind_call_factor = request
506        .called_crosswind_mph
507        .map(|called| mean_crosswind_mph / called);
508
509    // Spin drift is always modelled (the twist rate is required for exactly that reason);
510    // Coriolis only when an earth frame was supplied. Anything not listed as subtracted
511    // is, by construction, still inside the solved wind.
512    let mut subtracted_effects = vec!["spin drift".to_string()];
513    let mut unsubtracted_effects = Vec::new();
514    if request.earth.is_some() {
515        subtracted_effects.push("Coriolis".to_string());
516    } else {
517        unsubtracted_effects
518            .push("Coriolis (supply --latitude and --shot-direction to subtract it)".to_string());
519    }
520
521    Ok(WindTruingReport {
522        solutions,
523        mean_crosswind_mph,
524        mean_sigma_mph,
525        inverse_variance_weighted,
526        called_crosswind_mph: request.called_crosswind_mph,
527        wind_call_factor,
528        subtracted_effects,
529        unsubtracted_effects,
530    })
531}
532
533/// Fit one observation. Bracketed false position (Illinois), mirroring the bracketing and
534/// best-estimate-at-the-cap discipline of
535/// [`crate::truing::calculate_true_velocity_local`]: a verified sign change over the full
536/// solvable band, then an iteration that can never leave that bracket.
537fn solve_one_observation(
538    observation: &WindObservation,
539    range_yd: f64,
540    lateral_at: &impl Fn(f64, f64) -> Result<f64, Box<dyn Error>>,
541) -> Result<WindTruingSolution, Box<dyn Error>> {
542    let target = observation.miss_right_m;
543    let residual = |crosswind_mph: f64| -> Result<f64, Box<dyn Error>> {
544        Ok(lateral_at(crosswind_mph, range_yd)? - target)
545    };
546
547    let mut low = -MAX_SOLVABLE_CROSSWIND_MPH;
548    let mut high = MAX_SOLVABLE_CROSSWIND_MPH;
549    let mut f_low = residual(low)?;
550    let mut f_high = residual(high)?;
551    if f_low > 0.0 || f_high < 0.0 {
552        return Err(format!(
553            "no crosswind within +/-{MAX_SOLVABLE_CROSSWIND_MPH:.0} mph reproduces a {:.2} in \
554             miss at {range_yd:.0} yd (that band spans {:.2} to {:.2} in of deflection) — check \
555             the sign of --miss (positive = impact RIGHT of aim), the twist hand, and the load",
556            target / 0.0254,
557            (f_low + target) / 0.0254,
558            (f_high + target) / 0.0254,
559        )
560        .into());
561    }
562
563    let mut solved = 0.0;
564    let mut f_solved = 0.0;
565    let mut iterations = 0u32;
566    let mut converged = false;
567    while iterations < WIND_SOLVE_MAX_ITERATIONS {
568        iterations += 1;
569        let denom = f_high - f_low;
570        let mut candidate = if denom.abs() > f64::MIN_POSITIVE {
571            high - f_high * (high - low) / denom
572        } else {
573            0.5 * (low + high)
574        };
575        // Never step outside the bracket (that is the whole point of keeping one).
576        if !candidate.is_finite() || candidate <= low || candidate >= high {
577            candidate = 0.5 * (low + high);
578        }
579        let f = residual(candidate)?;
580        solved = candidate;
581        f_solved = f;
582        if f.abs() <= WIND_SOLVE_TOLERANCE_M || (high - low) <= WIND_SOLVE_MIN_BRACKET_MPH {
583            converged = true;
584            break;
585        }
586        // Illinois: halve the retained endpoint's function value so one side cannot stall.
587        if f < 0.0 {
588            low = candidate;
589            f_low = f;
590            f_high *= 0.5;
591        } else {
592            high = candidate;
593            f_high = f;
594            f_low *= 0.5;
595        }
596    }
597
598    // How hard the observation actually pushes back on the wind, measured at the solution.
599    let plus = lateral_at(solved + WIND_SENSITIVITY_STEP_MPH, range_yd)?;
600    let minus = lateral_at(solved - WIND_SENSITIVITY_STEP_MPH, range_yd)?;
601    let sensitivity_m_per_mph = (plus - minus) / (2.0 * WIND_SENSITIVITY_STEP_MPH);
602
603    // The lateral the model produces with NO wind: spin drift, plus Coriolis when the
604    // earth frame was supplied. This is what the fit accounted for instead of wind.
605    let no_wind_lateral_m = lateral_at(0.0, range_yd)?;
606
607    let solved_sigma_mph = observation.sigma_m.and_then(|sigma| {
608        let slope = sensitivity_m_per_mph.abs();
609        (slope > 0.0).then_some(sigma / slope)
610    });
611
612    Ok(WindTruingSolution {
613        range_m: observation.range_m,
614        observed_miss_right_m: target,
615        sigma_m: observation.sigma_m,
616        solved_crosswind_mph: solved,
617        modeled_miss_right_m: target + f_solved,
618        residual_m: f_solved,
619        no_wind_lateral_m,
620        sensitivity_m_per_mph,
621        solved_sigma_mph,
622        iterations,
623        converged,
624    })
625}
626
627/// Which rendering [`format_wind_truing_report`] should produce. Front-end-agnostic so the
628/// native CLI's `OutputFormat` and the WASM terminal's `--output` string map onto ONE
629/// formatter and cannot drift apart.
630#[derive(Debug, Clone, Copy, PartialEq, Eq)]
631pub enum WindTruingOutput {
632    Table,
633    Json,
634    Csv,
635}
636
637/// Display units for a wind-truing rendering. Ranges follow the unit system; the observed
638/// miss is always inches (a linear tape measurement, exactly like `--drop-unit in`).
639struct WindTruingUnits {
640    range_label: &'static str,
641    speed_label: &'static str,
642    range_scale: f64,
643    speed_scale: f64,
644}
645
646impl WindTruingUnits {
647    fn for_system(units: UnitSystem) -> Self {
648        match units {
649            UnitSystem::Imperial => Self {
650                range_label: "yd",
651                speed_label: "mph",
652                range_scale: 1.0 / 0.9144,
653                speed_scale: 1.0,
654            },
655            UnitSystem::Metric => Self {
656                range_label: "m",
657                speed_label: "m/s",
658                range_scale: 1.0,
659                speed_scale: MPH_TO_MPS,
660            },
661        }
662    }
663
664    fn range(&self, range_m: f64) -> f64 {
665        range_m * self.range_scale
666    }
667
668    fn speed(&self, mph: f64) -> f64 {
669        mph * self.speed_scale
670    }
671}
672
673/// Meters to inches, for the linear miss columns.
674fn inches(meters: f64) -> f64 {
675    meters / 0.0254
676}
677
678/// The wind-truing report as a JSON document (MBA-1392).
679///
680/// Split out from [`format_wind_truing_report`] so the conditional fields (the optional
681/// sigma, called wind and correction factor, which are `null` when absent) are testable on
682/// the host — `wasm.rs` is wasm32-gated, so a formatter that only existed inside it could
683/// never be asserted against natively. Same reason `drag_coefficient_json_value` exists.
684pub fn wind_truing_json_value(report: &WindTruingReport, units: UnitSystem) -> serde_json::Value {
685    let u = WindTruingUnits::for_system(units);
686    let observations: Vec<serde_json::Value> = report
687        .solutions
688        .iter()
689        .map(|s| {
690            serde_json::json!({
691                format!("range_{}", u.range_label): u.range(s.range_m),
692                "miss_right_in": inches(s.observed_miss_right_m),
693                "miss_sigma_in": s.sigma_m.map(inches),
694                "no_wind_lateral_in": inches(s.no_wind_lateral_m),
695                "solved_crosswind": u.speed(s.solved_crosswind_mph),
696                "solved_crosswind_sigma": s.solved_sigma_mph.map(|v| u.speed(v)),
697                "sensitivity_in_per_mph": inches(s.sensitivity_m_per_mph),
698                "residual_in": inches(s.residual_m),
699                "iterations": s.iterations,
700                "converged": s.converged,
701            })
702        })
703        .collect();
704
705    serde_json::json!({
706        "effective_crosswind": u.speed(report.mean_crosswind_mph),
707        "effective_crosswind_sigma": report.mean_sigma_mph.map(|v| u.speed(v)),
708        "inverse_variance_weighted": report.inverse_variance_weighted,
709        "called_crosswind": report.called_crosswind_mph.map(|v| u.speed(v)),
710        "wind_call_factor": report.wind_call_factor,
711        "observations": observations,
712        "effects_subtracted": report.subtracted_effects,
713        "effects_not_subtracted": report.unsubtracted_effects,
714        "legend": {
715            "units": {
716                "range": u.range_label,
717                "miss": "in",
718                "wind_speed": u.speed_label,
719            },
720            "signs": "--miss positive = impact right of aim; solved crosswind positive = \
721                      wind from the shooter's left (9 o'clock) pushing impacts right",
722        },
723    })
724}
725
726/// Render a [`WindTruingReport`] (MBA-1392).
727///
728/// ONE formatter for both front ends: the native CLI prints the returned string and the
729/// WASM terminal returns it, so the two surfaces are byte-identical by construction rather
730/// than by a replicated printer that has to be kept in sync.
731pub fn format_wind_truing_report(
732    report: &WindTruingReport,
733    units: UnitSystem,
734    output: WindTruingOutput,
735) -> String {
736    let u = WindTruingUnits::for_system(units);
737    match output {
738        WindTruingOutput::Json => {
739            match serde_json::to_string_pretty(&wind_truing_json_value(report, units)) {
740                Ok(s) => format!("{s}\n"),
741                Err(e) => format!("Error serializing JSON: {e}\n"),
742            }
743        }
744        WindTruingOutput::Csv => {
745            let mut out = String::new();
746            out.push_str(&format!(
747                "range_{},miss_right_in,miss_sigma_in,no_wind_lateral_in,solved_crosswind_{},\
748                 sensitivity_in_per_mph,residual_in,iterations,converged\n",
749                u.range_label, u.speed_label
750            ));
751            for s in &report.solutions {
752                out.push_str(&format!(
753                    "{:.1},{:+.3},{},{:+.3},{:+.3},{:.4},{:+.4},{},{}\n",
754                    u.range(s.range_m),
755                    inches(s.observed_miss_right_m),
756                    match s.sigma_m {
757                        Some(sigma) => format!("{:.3}", inches(sigma)),
758                        None => String::new(),
759                    },
760                    inches(s.no_wind_lateral_m),
761                    u.speed(s.solved_crosswind_mph),
762                    inches(s.sensitivity_m_per_mph),
763                    inches(s.residual_m),
764                    s.iterations,
765                    s.converged,
766                ));
767            }
768            out.push('\n');
769            out.push_str(&format!(
770                "effective_crosswind_{},effective_crosswind_sigma_{},inverse_variance_weighted,\
771                 called_crosswind_{},wind_call_factor\n",
772                u.speed_label, u.speed_label, u.speed_label
773            ));
774            out.push_str(&format!(
775                "{:+.3},{},{},{},{}\n",
776                u.speed(report.mean_crosswind_mph),
777                match report.mean_sigma_mph {
778                    Some(sigma) => format!("{:.3}", u.speed(sigma)),
779                    None => String::new(),
780                },
781                report.inverse_variance_weighted,
782                match report.called_crosswind_mph {
783                    Some(called) => format!("{:+.3}", u.speed(called)),
784                    None => String::new(),
785                },
786                match report.wind_call_factor {
787                    Some(factor) => format!("{factor:.4}"),
788                    None => String::new(),
789                },
790            ));
791            out
792        }
793        WindTruingOutput::Table => {
794            let mut out = String::new();
795            out.push('\n');
796            out.push_str("=== EFFECTIVE WIND TRUING (from observed horizontal miss) ===\n");
797            out.push('\n');
798            out.push_str(&format!(
799                "  {:>10}  {:>12}  {:>14}  {:>16}  {:>10}\n",
800                format!("Range ({})", u.range_label),
801                "Miss (in)",
802                "Spin/Cor (in)",
803                format!("Wind ({})", u.speed_label),
804                "Resid (in)",
805            ));
806            out.push_str(&format!("  {}\n", "-".repeat(70)));
807            for s in &report.solutions {
808                out.push_str(&format!(
809                    "  {:>10.1}  {:>+12.2}  {:>+14.2}  {:>+16.2}  {:>+10.3}\n",
810                    u.range(s.range_m),
811                    inches(s.observed_miss_right_m),
812                    inches(s.no_wind_lateral_m),
813                    u.speed(s.solved_crosswind_mph),
814                    inches(s.residual_m),
815                ));
816            }
817            out.push_str(&format!("  {}\n", "-".repeat(70)));
818            out.push('\n');
819            let n = report.solutions.len();
820            out.push_str(&format!(
821                "  Effective crosswind: {:>+8.2} {}{}\n",
822                u.speed(report.mean_crosswind_mph),
823                u.speed_label,
824                match report.mean_sigma_mph {
825                    Some(sigma) => format!(
826                        "  +/- {:.2} {} (inverse-variance weighted over {n} observations)",
827                        u.speed(sigma),
828                        u.speed_label
829                    ),
830                    None if n > 1 => format!("  (mean of {n} observations)"),
831                    None => String::new(),
832                }
833            ));
834            if let (Some(called), Some(factor)) =
835                (report.called_crosswind_mph, report.wind_call_factor)
836            {
837                out.push_str(&format!(
838                    "  Called wind:         {:>+8.2} {}   ->  wind-call correction factor {:.2}\n",
839                    u.speed(called),
840                    u.speed_label,
841                    factor
842                ));
843                out.push_str(&format!(
844                    "    (multiply your wind calls by {factor:.2} to match what actually hit)\n"
845                ));
846            }
847            if !report.subtracted_effects.is_empty() {
848                out.push_str(&format!(
849                    "  Effects subtracted:  {}\n",
850                    report.subtracted_effects.join(", ")
851                ));
852            }
853            if !report.unsubtracted_effects.is_empty() {
854                out.push_str(&format!(
855                    "  NOT subtracted (absorbed into the solved wind): {}\n",
856                    report.unsubtracted_effects.join(", ")
857                ));
858            }
859            for s in &report.solutions {
860                if inches(s.sensitivity_m_per_mph).abs() < MIN_WIND_SENSITIVITY_IN_PER_MPH {
861                    out.push_str(&format!(
862                        "  note: the observation at {:.1} {} moves only {:.2} in per mph of \
863                         crosswind (guide: {MIN_WIND_SENSITIVITY_IN_PER_MPH:.2} in/mph); the \
864                         wind fitted from it is weakly identified\n",
865                        u.range(s.range_m),
866                        u.range_label,
867                        inches(s.sensitivity_m_per_mph).abs(),
868                    ));
869                }
870                if !s.converged {
871                    out.push_str(&format!(
872                        "  note: the fit at {:.1} {} did not fully converge after {} iterations; \
873                         the value shown is the best estimate\n",
874                        u.range(s.range_m),
875                        u.range_label,
876                        s.iterations,
877                    ));
878                }
879            }
880            out.push('\n');
881            out.push_str(
882                "  Signs: --miss positive = impact RIGHT of aim. Solved wind positive = wind\n\
883                 \x20        FROM the shooter's LEFT (9 o'clock) pushing impacts right; negative\n\
884                 \x20        = FROM the right pushing left. Wind-FROM convention throughout\n\
885                 \x20        (0 = headwind, as of the 0.19.0 wind-direction sign fix).\n",
886            );
887            out.push('\n');
888            out
889        }
890    }
891}
892
893#[cfg(test)]
894mod tests {
895    use super::*;
896
897    fn base_request(observations: Vec<WindObservation>) -> WindTruingRequest {
898        WindTruingRequest {
899            observations,
900            muzzle_velocity_fps: 2700.0,
901            bc: 0.475,
902            drag_model: DragModelArg::G7,
903            mass_gr: 168.0,
904            diameter_in: 0.308,
905            zero_distance_yd: 100.0,
906            sight_height_in: 2.0,
907            temperature_f: 59.0,
908            pressure_inhg: 29.92,
909            humidity_pct: 50.0,
910            altitude_ft: 0.0,
911            twist: TruingTwist {
912                rate_in: 11.0,
913                right_hand: true,
914            },
915            earth: None,
916            called_crosswind_mph: None,
917        }
918    }
919
920    /// The forward model the fit inverts — used to manufacture "observed" misses for the
921    /// round-trip tests from a KNOWN wind.
922    fn modeled_lateral_m(request: &WindTruingRequest, crosswind_mph: f64, range_m: f64) -> f64 {
923        modeled_miss_right_m(request, crosswind_mph, range_m).expect("forward model must solve")
924    }
925
926    /// Round trip: take a KNOWN crosswind, read the lateral miss it produces at three
927    /// ranges out of the forward model, feed those misses back in as observations, and
928    /// recover the wind. Tolerance is 0.02 mph — three orders of magnitude tighter than
929    /// anyone's wind call, so this pins the inversion, not just its ballpark.
930    #[test]
931    fn round_trip_recovers_a_known_crosswind_at_three_ranges() {
932        let known_mph = 7.5;
933        let ranges_m = [274.32, 457.2, 640.08]; // 300 / 500 / 700 yd
934        let template = base_request(Vec::new());
935        let observations = ranges_m
936            .iter()
937            .map(|range_m| WindObservation {
938                range_m: *range_m,
939                miss_right_m: modeled_lateral_m(&template, known_mph, *range_m),
940                sigma_m: None,
941            })
942            .collect();
943
944        let report = solve_wind_truing(&base_request(observations)).expect("wind fit must solve");
945        assert_eq!(report.solutions.len(), 3);
946        for solution in &report.solutions {
947            assert!(solution.converged, "{solution:?}");
948            assert!(
949                (solution.solved_crosswind_mph - known_mph).abs() < 0.02,
950                "recovered {} mph at {} m, expected {known_mph}",
951                solution.solved_crosswind_mph,
952                solution.range_m
953            );
954        }
955        assert!((report.mean_crosswind_mph - known_mph).abs() < 0.02);
956        assert!(!report.inverse_variance_weighted);
957        assert!(report.mean_sigma_mph.is_none());
958    }
959
960    /// Sign pins, both directions. A miss to the RIGHT must solve to a POSITIVE wind
961    /// (from the shooter's left, pushing right); a miss to the LEFT must solve NEGATIVE.
962    /// These two assertions are the contract the help text documents.
963    #[test]
964    fn miss_right_solves_positive_and_miss_left_solves_negative() {
965        let template = base_request(Vec::new());
966        let range_m = 457.2; // 500 yd
967        let right_miss = modeled_lateral_m(&template, 8.0, range_m);
968        let left_miss = modeled_lateral_m(&template, -8.0, range_m);
969        assert!(right_miss > 0.0, "a left-hand wind must push impacts right");
970        assert!(left_miss < 0.0, "a right-hand wind must push impacts left");
971
972        let right = solve_wind_truing(&base_request(vec![WindObservation {
973            range_m,
974            miss_right_m: right_miss,
975            sigma_m: None,
976        }]))
977        .expect("right-miss fit must solve");
978        assert!(
979            right.mean_crosswind_mph > 0.0,
980            "right miss must solve to a positive (left-hand, right-pushing) wind, got {}",
981            right.mean_crosswind_mph
982        );
983        assert!((right.mean_crosswind_mph - 8.0).abs() < 0.02);
984
985        let left = solve_wind_truing(&base_request(vec![WindObservation {
986            range_m,
987            miss_right_m: left_miss,
988            sigma_m: None,
989        }]))
990        .expect("left-miss fit must solve");
991        assert!(
992            left.mean_crosswind_mph < 0.0,
993            "left miss must solve to a negative (right-hand, left-pushing) wind, got {}",
994            left.mean_crosswind_mph
995        );
996        assert!((left.mean_crosswind_mph + 8.0).abs() < 0.02);
997    }
998
999    /// Spin-drift subtraction: a ZERO-wind trajectory still lands right of aim (right-hand
1000    /// twist). Feeding that pure spin drift in as the observed miss must solve to ~zero
1001    /// wind — the drift is attributed to spin, not to a phantom wind.
1002    #[test]
1003    fn pure_spin_drift_solves_to_zero_wind() {
1004        let template = base_request(Vec::new());
1005        let range_m = 640.08; // 700 yd
1006        let spin_only = modeled_lateral_m(&template, 0.0, range_m);
1007        assert!(
1008            spin_only > 0.05,
1009            "a 1:11 right-hand twist must drift measurably right at 700 yd, got {spin_only} m"
1010        );
1011
1012        let report = solve_wind_truing(&base_request(vec![WindObservation {
1013            range_m,
1014            miss_right_m: spin_only,
1015            sigma_m: None,
1016        }]))
1017        .expect("spin-only fit must solve");
1018        assert!(
1019            report.mean_crosswind_mph.abs() < 0.02,
1020            "pure spin drift must solve to ~0 wind, got {}",
1021            report.mean_crosswind_mph
1022        );
1023        // ... and the report must say the drift was accounted for, not silently eaten.
1024        assert!(report
1025            .solutions
1026            .iter()
1027            .all(|s| (s.no_wind_lateral_m - spin_only).abs() < 1e-9));
1028        assert!(report
1029            .subtracted_effects
1030            .iter()
1031            .any(|e| e.contains("spin drift")));
1032    }
1033
1034    /// A left-hand twist drifts the other way, so the SAME right-of-aim miss must solve to
1035    /// a stronger wind than a right-hand twist needs. Pins that the twist hand actually
1036    /// reaches the forward model rather than being cosmetic.
1037    #[test]
1038    fn twist_hand_changes_the_solved_wind() {
1039        let range_m = 640.08;
1040        let observation = WindObservation {
1041            range_m,
1042            miss_right_m: 0.25,
1043            sigma_m: None,
1044        };
1045        let right_hand = solve_wind_truing(&base_request(vec![observation])).expect("solve");
1046        let mut left = base_request(vec![observation]);
1047        left.twist.right_hand = false;
1048        let left_hand = solve_wind_truing(&left).expect("solve");
1049        assert!(
1050            left_hand.mean_crosswind_mph > right_hand.mean_crosswind_mph + 0.1,
1051            "left-hand twist ({}) must need more right-pushing wind than right-hand ({})",
1052            left_hand.mean_crosswind_mph,
1053            right_hand.mean_crosswind_mph
1054        );
1055    }
1056
1057    /// Coriolis subtraction: supplying a latitude and shot azimuth changes the zero-wind
1058    /// lateral (Coriolis is now modelled), so the same observed miss solves to a different
1059    /// wind, and the report moves Coriolis from "not subtracted" to "subtracted".
1060    #[test]
1061    fn coriolis_is_subtracted_when_latitude_and_azimuth_are_supplied() {
1062        let range_m = 914.4; // 1000 yd, where Coriolis is actually measurable
1063        let observation = WindObservation {
1064            range_m,
1065            miss_right_m: 0.30,
1066            sigma_m: None,
1067        };
1068        let without = solve_wind_truing(&base_request(vec![observation])).expect("solve");
1069        let mut with_earth = base_request(vec![observation]);
1070        with_earth.earth = Some(TruingEarthFrame {
1071            latitude_deg: 45.0,
1072            shot_azimuth_deg: 90.0, // due East, where the Coriolis lateral is largest
1073        });
1074        let with = solve_wind_truing(&with_earth).expect("solve");
1075
1076        assert!(without
1077            .unsubtracted_effects
1078            .iter()
1079            .any(|e| e.contains("Coriolis")));
1080        assert!(without.subtracted_effects.iter().all(|e| e != "Coriolis"));
1081        assert!(with.unsubtracted_effects.is_empty());
1082        assert!(with.subtracted_effects.iter().any(|e| e == "Coriolis"));
1083        assert!(
1084            (with.solutions[0].no_wind_lateral_m - without.solutions[0].no_wind_lateral_m).abs()
1085                > 1e-4,
1086            "modelling Coriolis must change the zero-wind lateral"
1087        );
1088        assert!(
1089            (with.mean_crosswind_mph - without.mean_crosswind_mph).abs() > 1e-3,
1090            "modelling Coriolis must change the solved wind"
1091        );
1092    }
1093
1094    /// The wind-call correction factor is solved / called, and its sign survives: calling
1095    /// the wrong SIDE of the wind gives a negative factor rather than a plausible-looking
1096    /// positive one.
1097    #[test]
1098    fn wind_call_factor_is_solved_over_called_and_keeps_its_sign() {
1099        let template = base_request(Vec::new());
1100        let range_m = 457.2;
1101        let miss = modeled_lateral_m(&template, 9.0, range_m);
1102        let observation = WindObservation {
1103            range_m,
1104            miss_right_m: miss,
1105            sigma_m: None,
1106        };
1107
1108        let mut under_called = base_request(vec![observation]);
1109        under_called.called_crosswind_mph = Some(6.0);
1110        let report = solve_wind_truing(&under_called).expect("solve");
1111        let factor = report.wind_call_factor.expect("factor");
1112        assert!(
1113            (factor - 9.0 / 6.0).abs() < 0.01,
1114            "expected ~1.5, got {factor}"
1115        );
1116
1117        let mut wrong_side = base_request(vec![observation]);
1118        wrong_side.called_crosswind_mph = Some(-6.0);
1119        let flipped = solve_wind_truing(&wrong_side)
1120            .expect("solve")
1121            .wind_call_factor
1122            .expect("factor");
1123        assert!(flipped < 0.0, "a wrong-side call must read negative: {flipped}");
1124    }
1125
1126    /// Sigmas: all-or-none. Every observation weighted -> inverse-variance mean with a
1127    /// reported sigma; none weighted -> plain mean; a mix is a hard error rather than a
1128    /// silent blend of weighting schemes.
1129    #[test]
1130    fn sigmas_are_all_or_none_and_drive_inverse_variance_weighting() {
1131        let template = base_request(Vec::new());
1132        let near = 274.32;
1133        let far = 640.08;
1134        let weighted = vec![
1135            WindObservation {
1136                range_m: near,
1137                miss_right_m: modeled_lateral_m(&template, 6.0, near),
1138                sigma_m: Some(0.25 * 0.0254),
1139            },
1140            WindObservation {
1141                range_m: far,
1142                miss_right_m: modeled_lateral_m(&template, 6.0, far),
1143                sigma_m: Some(0.25 * 0.0254),
1144            },
1145        ];
1146        let report = solve_wind_truing(&base_request(weighted)).expect("solve");
1147        assert!(report.inverse_variance_weighted);
1148        let sigma = report.mean_sigma_mph.expect("weighted mean sigma");
1149        assert!(sigma > 0.0 && sigma.is_finite());
1150        // The long-range observation is far more sensitive to wind, so its propagated
1151        // sigma must be the smaller of the two (it carries the most weight).
1152        let near_sigma = report.solutions[0].solved_sigma_mph.expect("sigma");
1153        let far_sigma = report.solutions[1].solved_sigma_mph.expect("sigma");
1154        assert!(far_sigma < near_sigma, "{far_sigma} !< {near_sigma}");
1155        assert!(sigma <= far_sigma + 1e-12);
1156
1157        let mixed = base_request(vec![
1158            WindObservation {
1159                range_m: near,
1160                miss_right_m: 0.1,
1161                sigma_m: Some(0.006),
1162            },
1163            WindObservation {
1164                range_m: far,
1165                miss_right_m: 0.2,
1166                sigma_m: None,
1167            },
1168        ]);
1169        let error = mixed.validate().unwrap_err();
1170        assert!(error.contains("every observed miss or on none"), "{error}");
1171    }
1172
1173    /// An unreachable miss (wrong sign, or simply not a wind effect) is rejected with a
1174    /// diagnostic naming the solvable band, instead of being clamped into a fake answer.
1175    #[test]
1176    fn an_unreachable_miss_is_rejected_with_the_solvable_band() {
1177        let error = solve_wind_truing(&base_request(vec![WindObservation {
1178            range_m: 274.32,
1179            miss_right_m: 25.0, // 25 metres right at 300 yd: no wind does that
1180            sigma_m: None,
1181        }]))
1182        .unwrap_err()
1183        .to_string();
1184        assert!(error.contains("no crosswind within"), "{error}");
1185        assert!(error.contains("check the sign of --miss"), "{error}");
1186    }
1187
1188    /// MBA-1358 / design R5-DIRECTION: `--miss` values are LINEAR inches off the target,
1189    /// not dial readings, so a scope tracking correction factor must NOT touch them. The
1190    /// structural guarantee is that no CF can reach this solver at all — there is no field
1191    /// for one — and this test pins the consequence: a windage CF applied the way the
1192    /// DIALED truing path applies it (observation x CF) would move the answer, so it must
1193    /// never be applied here.
1194    #[test]
1195    fn windage_cf_does_not_alter_the_wind_solve() {
1196        let template = base_request(Vec::new());
1197        let range_m = 457.2;
1198        let miss = modeled_lateral_m(&template, 7.0, range_m);
1199        let observation = WindObservation {
1200            range_m,
1201            miss_right_m: miss,
1202            sigma_m: None,
1203        };
1204        let solved = solve_wind_truing(&base_request(vec![observation]))
1205            .expect("solve")
1206            .mean_crosswind_mph;
1207        assert!((solved - 7.0).abs() < 0.02);
1208
1209        // What the dialed path would have done to a 0.95 CF observation. It changes the
1210        // answer materially, which is exactly why linear inputs must be left alone.
1211        let windage_cf = 0.95;
1212        let cf_applied = solve_wind_truing(&base_request(vec![WindObservation {
1213            range_m,
1214            miss_right_m: miss * windage_cf,
1215            sigma_m: None,
1216        }]))
1217        .expect("solve")
1218        .mean_crosswind_mph;
1219        assert!(
1220            (cf_applied - solved).abs() > 0.1,
1221            "a CF-scaled observation must NOT be equivalent to the linear one \
1222             ({cf_applied} vs {solved}); --miss therefore takes no CF"
1223        );
1224    }
1225
1226    /// The JSON emit helper: conditional fields are explicit `null`s (never dropped keys),
1227    /// so consumers can tell "not supplied" from "absent field".
1228    #[test]
1229    fn json_value_nulls_absent_optional_fields() {
1230        let template = base_request(Vec::new());
1231        let range_m = 457.2;
1232        let report = solve_wind_truing(&base_request(vec![WindObservation {
1233            range_m,
1234            miss_right_m: modeled_lateral_m(&template, 5.0, range_m),
1235            sigma_m: None,
1236        }]))
1237        .expect("solve");
1238        let value = wind_truing_json_value(&report, UnitSystem::Imperial);
1239        assert!(value["called_crosswind"].is_null());
1240        assert!(value["wind_call_factor"].is_null());
1241        assert!(value["effective_crosswind_sigma"].is_null());
1242        assert!(value["observations"][0]["miss_sigma_in"].is_null());
1243        assert!(value["observations"][0]["solved_crosswind_sigma"].is_null());
1244        assert_eq!(value["legend"]["units"]["wind_speed"], "mph");
1245        assert_eq!(value["legend"]["units"]["miss"], "in");
1246        assert_eq!(
1247            value["effective_crosswind"].as_f64().expect("f64").round(),
1248            5.0
1249        );
1250
1251        // Metric renders the same wind in m/s and the same ranges in meters, while the
1252        // linear miss stays in inches (the --drop-unit in precedent).
1253        let metric = wind_truing_json_value(&report, UnitSystem::Metric);
1254        assert_eq!(metric["legend"]["units"]["wind_speed"], "m/s");
1255        assert_eq!(metric["legend"]["units"]["range"], "m");
1256        assert_eq!(metric["legend"]["units"]["miss"], "in");
1257        let mps = metric["effective_crosswind"].as_f64().expect("f64");
1258        let mph = value["effective_crosswind"].as_f64().expect("f64");
1259        assert!((mps - mph * MPH_TO_MPS).abs() < 1e-12);
1260    }
1261
1262    /// Parser contract: RANGE follows the unit system, the offset and sigma are inches in
1263    /// both, and malformed tokens are rejected with a usable message.
1264    #[test]
1265    fn parse_wind_observation_units_and_errors() {
1266        let imperial = parse_wind_observation("600:8.5", UnitSystem::Imperial).expect("parse");
1267        assert!((imperial.range_m - 600.0 * 0.9144).abs() < 1e-12);
1268        assert!((imperial.miss_right_m - 8.5 * 0.0254).abs() < 1e-12);
1269        assert!(imperial.sigma_m.is_none());
1270
1271        let metric = parse_wind_observation("550:-8.5:0.75", UnitSystem::Metric).expect("parse");
1272        assert!((metric.range_m - 550.0).abs() < 1e-12);
1273        assert!((metric.miss_right_m + 8.5 * 0.0254).abs() < 1e-12);
1274        assert!((metric.sigma_m.expect("sigma") - 0.75 * 0.0254).abs() < 1e-12);
1275
1276        for bad in ["600", "600:8.5:0.1:2", "600:right", "abc:8.5", "600:nan"] {
1277            assert!(
1278                parse_wind_observation(bad, UnitSystem::Imperial).is_err(),
1279                "'{bad}' should not parse"
1280            );
1281        }
1282    }
1283
1284    /// Validation rejects degenerate requests before spending a single trajectory solve.
1285    #[test]
1286    fn validation_rejects_degenerate_requests() {
1287        assert!(base_request(Vec::new())
1288            .validate()
1289            .unwrap_err()
1290            .contains("at least one"));
1291
1292        let duplicate = base_request(vec![
1293            WindObservation {
1294                range_m: 457.2,
1295                miss_right_m: 0.2,
1296                sigma_m: None,
1297            },
1298            WindObservation {
1299                range_m: 457.2,
1300                miss_right_m: 0.3,
1301                sigma_m: None,
1302            },
1303        ]);
1304        assert!(duplicate
1305            .validate()
1306            .unwrap_err()
1307            .contains("duplicate observation range"));
1308
1309        let mut bad_twist = base_request(vec![WindObservation {
1310            range_m: 457.2,
1311            miss_right_m: 0.2,
1312            sigma_m: None,
1313        }]);
1314        bad_twist.twist.rate_in = 0.0;
1315        assert!(bad_twist
1316            .validate()
1317            .unwrap_err()
1318            .contains("twist rate must be positive"));
1319
1320        let mut zero_call = base_request(vec![WindObservation {
1321            range_m: 457.2,
1322            miss_right_m: 0.2,
1323            sigma_m: None,
1324        }]);
1325        zero_call.called_crosswind_mph = Some(0.0);
1326        assert!(zero_call.validate().unwrap_err().contains("non-zero"));
1327    }
1328}