Skip to main content

ballistics_engine/
wind_scenarios.rs

1//! MBA-1349: robust hold corridors across a bounded set of NAMED segmented-wind scenarios.
2//!
3//! Field users usually have several concrete plausible wind calls — a low call and a high
4//! call, or two different downrange patterns — rather than a distribution they actually
5//! believe. A nominal trajectory hides that ambiguity; Monte Carlo demands a probability
6//! model they do not have. This module takes the middle path: solve a handful of named
7//! scenarios exactly, report every one of them, and report the corridor they span.
8//!
9//! # No probabilities, anywhere
10//!
11//! Nothing here assigns a likelihood to a scenario, interpolates between scenarios, or
12//! folds the finite set into a standard deviation. The corridor is the range spanned by
13//! the hypotheses the user supplied, at the ranges they asked about — no more, and
14//! deliberately no more. A three-scenario corridor is not a confidence interval, and this
15//! module never presents it as one. Statistical dispersion remains `monte-carlo --wez`'s
16//! job.
17//!
18//! # What a "hold" is here
19//!
20//! The same convention the rest of the 0.31.0 reticle work uses (see [`crate::reticle`]):
21//! angular milliradians the shooter must apply, with **elevation positive = hold UP** (the
22//! bullet fell below the line of sight) and **windage positive = hold RIGHT** (the wind
23//! pushed the bullet right).
24//!
25//! # Determinism
26//!
27//! Scenarios are sorted by name before anything is solved, so no result — corridor,
28//! minimax hold, or which scenario is named as the worst case — can depend on the order
29//! they appeared in the file. Every tie-break downstream is therefore over a fixed order.
30//!
31//! # The zero is solved ONCE, in calm air
32//!
33//! A rifle has one zero; the scenarios are hypotheses about the shot, not about the
34//! zeroing session. Re-zeroing per scenario would fold each hypothesis into its own datum
35//! and shrink the corridor toward nothing, which is exactly the ambiguity this command
36//! exists to show. So the muzzle angle is solved once against
37//! [`WindConditions::default()`] and reused verbatim by every scenario.
38
39use std::error::Error;
40use std::fmt;
41
42use serde::{Deserialize, Serialize};
43
44use crate::cli_api::{AtmosphericConditions, BallisticInputs, TrajectorySolver, UnitSystem};
45use crate::drag_model::DragModel;
46use crate::trajectory_observation::{bracket_param, Bracket};
47use crate::wind::{validate_wind_segments, WindSegment};
48use crate::WindConditions;
49
50/// Schema version carried by [`WindScenarioSetV1`] and enforced on load.
51pub const WIND_SCENARIO_SET_VERSION: u32 = 1;
52
53/// Schema version carried by [`RobustHoldReportV1`].
54pub const ROBUST_HOLD_REPORT_VERSION: u32 = 1;
55
56/// Most scenarios one run will accept.
57///
58/// Each scenario is a full trajectory solve, and the point of the feature is a handful of
59/// concrete calls a person can actually reason about — past this, the honest tool is a
60/// statistical one. Enforced BEFORE any solving so an oversized set costs nothing.
61pub const MAX_WIND_SCENARIOS: usize = 8;
62
63/// Most ranges one run will sample. Same rationale: a bounded, checkable table, not a
64/// swept surface.
65pub const MAX_CORRIDOR_RANGES: usize = 64;
66
67/// Slack (meters) allowed when deciding whether a miss is inside a target.
68///
69/// Boundary contact COUNTS AS A FIT — a hold that puts the worst-case impact exactly on the
70/// edge of the target is, by the geometry, still on the target. This epsilon exists only so
71/// that a case constructed to sit exactly on the boundary is not thrown off it by the last
72/// bit of a floating-point division; it is a nanometer, far below any physical meaning.
73pub const TARGET_FIT_EPSILON_M: f64 = 1.0e-9;
74
75/// Trajectory sample spacing used for every scenario, meters (~1 yard).
76///
77/// The requested ranges are read by linear interpolation between neighbouring samples;
78/// at this spacing that is far below the resolution any hold is read to.
79const SAMPLE_INTERVAL_M: f64 = 0.9144;
80
81/// One named wind hypothesis: a label plus the downrange segments that define it.
82#[derive(Debug, Clone, PartialEq)]
83pub struct NamedWindScenario {
84    /// Human label, carried through the whole report so provenance is never lost.
85    pub name: String,
86    /// Downrange wind segments, in the same representation
87    /// [`crate::wind::parse_wind_segment_str`] produces.
88    pub segments: Vec<WindSegment>,
89}
90
91/// A bounded, named set of wind hypotheses (MBA-1349).
92#[derive(Debug, Clone, PartialEq)]
93pub struct WindScenarioSetV1 {
94    /// Must equal [`WIND_SCENARIO_SET_VERSION`]; anything else is rejected rather than
95    /// best-effort parsed.
96    pub version: u32,
97    pub scenarios: Vec<NamedWindScenario>,
98    /// Which scenario is the user's own best guess, if they named one. Used only as the
99    /// comparison baseline for the minimax hold — it is never weighted, and never treated
100    /// as more likely.
101    pub nominal: Option<String>,
102}
103
104/// The target a corridor is judged against.
105#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
106#[serde(tag = "shape", rename_all = "snake_case")]
107pub enum TargetSpec {
108    /// A rectangle centered on the point of aim.
109    Rect { width_m: f64, height_m: f64 },
110    /// A circle centered on the point of aim.
111    Circle { diameter_m: f64 },
112}
113
114/// Which norm the minimax hold minimizes.
115///
116/// This is not cosmetic: the two target shapes have genuinely different optimal holds, and
117/// picking one silently would give a rectangle the circle's answer (or vice versa).
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub enum CorridorMetric {
120    /// Per-axis (L-infinity). The two axes are independent for a rectangle, so the optimum
121    /// is the per-axis MIDPOINT OF THE EXTREMES on each axis separately, and the objective
122    /// it minimizes is `max over scenarios of max(|Δelevation|, |Δwindage|)`.
123    ///
124    /// This is also the default when no target was given: with no shape to judge against,
125    /// treating the axes independently is the assumption that adds the least.
126    Rectangular,
127    /// Euclidean (L2). For a circle the optimum is the center of the MINIMUM ENCLOSING
128    /// CIRCLE of the scenario holds, and the objective is the largest Euclidean distance
129    /// from the hold to any scenario — i.e. that circle's radius.
130    Circular,
131}
132
133impl TargetSpec {
134    /// The metric this shape implies.
135    pub fn metric(self) -> CorridorMetric {
136        match self {
137            TargetSpec::Rect { .. } => CorridorMetric::Rectangular,
138            TargetSpec::Circle { .. } => CorridorMetric::Circular,
139        }
140    }
141}
142
143/// The rifle, load and atmosphere every scenario is solved with. SI throughout.
144#[derive(Debug, Clone, PartialEq)]
145pub struct CorridorLoad {
146    pub muzzle_velocity_mps: f64,
147    pub bc: f64,
148    pub drag_model: DragModel,
149    pub mass_kg: f64,
150    pub diameter_m: f64,
151    pub bullet_length_m: f64,
152    pub zero_distance_m: f64,
153    pub sight_height_m: f64,
154    pub temperature_c: f64,
155    pub pressure_hpa: f64,
156    /// Relative humidity, percent (0 through 100).
157    pub humidity_pct: f64,
158    pub altitude_m: f64,
159}
160
161/// One robust-hold run.
162#[derive(Debug, Clone, PartialEq)]
163pub struct RobustHoldRequest {
164    pub scenarios: WindScenarioSetV1,
165    /// Ranges to report, meters. Order is preserved in the report; duplicates are rejected.
166    pub ranges_m: Vec<f64>,
167    pub target: Option<TargetSpec>,
168    pub load: CorridorLoad,
169}
170
171/// Why a robust-hold run was rejected. Typed, and every variant is raised BEFORE any
172/// trajectory is solved except [`Self::SolveFailed`].
173#[derive(Debug, Clone, PartialEq)]
174pub enum WindScenarioError {
175    /// The set declared a version this build does not implement.
176    UnsupportedVersion { version: u32, expected: u32 },
177    /// No scenarios at all.
178    NoScenarios,
179    /// More scenarios than [`MAX_WIND_SCENARIOS`].
180    TooManyScenarios { count: usize, max: usize },
181    /// A scenario carried an empty or whitespace-only name.
182    EmptyScenarioName { index: usize },
183    /// Two scenarios shared a name, so the report could not keep their provenance apart.
184    DuplicateScenarioName { name: String },
185    /// A scenario carried no wind segments.
186    NoSegments { name: String },
187    /// A segment token could not be parsed.
188    MalformedSegment {
189        scenario: String,
190        index: usize,
191        message: String,
192    },
193    /// A segment parsed but violated [`crate::wind::validate_wind_segments`].
194    InvalidSegment { scenario: String, message: String },
195    /// `nominal` named a scenario that is not in the set.
196    UnknownNominal { name: String, available: Vec<String> },
197    /// No ranges were requested.
198    NoRanges,
199    /// More ranges than [`MAX_CORRIDOR_RANGES`].
200    TooManyRanges { count: usize, max: usize },
201    /// A requested range was not finite and positive.
202    InvalidRange { value: f64 },
203    /// The same range was requested twice.
204    DuplicateRange { value: f64 },
205    /// A target dimension was not finite and positive.
206    InvalidTarget { message: String },
207    /// A load or atmosphere value was not usable.
208    InvalidLoad { field: &'static str, value: f64 },
209    /// The scenario set could not be decoded at all.
210    MalformedDocument { message: String },
211    /// A scenario's trajectory could not be solved, or did not reach a requested range.
212    SolveFailed { scenario: String, message: String },
213}
214
215impl fmt::Display for WindScenarioError {
216    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
217        match self {
218            WindScenarioError::UnsupportedVersion { version, expected } => write!(
219                f,
220                "unsupported wind scenario set version {version}; this build implements {expected}"
221            ),
222            WindScenarioError::NoScenarios => {
223                write!(f, "the scenario set contains no scenarios")
224            }
225            WindScenarioError::TooManyScenarios { count, max } => write!(
226                f,
227                "{count} scenarios exceeds the limit of {max}: a hold corridor is meant to \
228                 span a handful of concrete wind calls, not a swept distribution"
229            ),
230            WindScenarioError::EmptyScenarioName { index } => {
231                write!(f, "scenario #{index} has an empty name")
232            }
233            WindScenarioError::DuplicateScenarioName { name } => write!(
234                f,
235                "two scenarios are both named '{name}'; names carry provenance through the \
236                 whole report and must be unique"
237            ),
238            WindScenarioError::NoSegments { name } => {
239                write!(f, "scenario '{name}' has no wind segments")
240            }
241            WindScenarioError::MalformedSegment {
242                scenario,
243                index,
244                message,
245            } => write!(f, "scenario '{scenario}' segment #{index}: {message}"),
246            WindScenarioError::InvalidSegment { scenario, message } => {
247                write!(f, "scenario '{scenario}': {message}")
248            }
249            WindScenarioError::UnknownNominal { name, available } => write!(
250                f,
251                "nominal scenario '{name}' is not in the set (available: {})",
252                available.join(", ")
253            ),
254            WindScenarioError::NoRanges => write!(f, "no ranges were requested"),
255            WindScenarioError::TooManyRanges { count, max } => {
256                write!(f, "{count} ranges exceeds the limit of {max}")
257            }
258            WindScenarioError::InvalidRange { value } => write!(
259                f,
260                "range {value} must be finite and greater than zero"
261            ),
262            WindScenarioError::DuplicateRange { value } => {
263                write!(f, "range {value} was requested more than once")
264            }
265            WindScenarioError::InvalidTarget { message } => write!(f, "invalid target: {message}"),
266            WindScenarioError::InvalidLoad { field, value } => {
267                write!(f, "{field} ({value}) must be finite and greater than zero")
268            }
269            WindScenarioError::MalformedDocument { message } => {
270                write!(f, "malformed wind scenario set: {message}")
271            }
272            WindScenarioError::SolveFailed { scenario, message } => {
273                write!(f, "scenario '{scenario}' could not be solved: {message}")
274            }
275        }
276    }
277}
278
279impl Error for WindScenarioError {}
280
281/// One scenario's hold at one range.
282#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
283pub struct ScenarioHold {
284    pub name: String,
285    /// Milliradians the shooter holds UP.
286    pub elevation_mil: f64,
287    /// Milliradians the shooter holds RIGHT.
288    pub windage_mil: f64,
289}
290
291/// The corridor and chosen hold at one range.
292#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
293pub struct CorridorRow {
294    pub range_m: f64,
295    /// Every scenario, in sorted-by-name order.
296    pub scenarios: Vec<ScenarioHold>,
297    pub elevation_min_mil: f64,
298    pub elevation_max_mil: f64,
299    pub windage_min_mil: f64,
300    pub windage_max_mil: f64,
301    /// The hold that minimizes the worst case under the active metric.
302    pub minimax_elevation_mil: f64,
303    pub minimax_windage_mil: f64,
304    /// Worst-case deviation from the minimax hold to any scenario, under the active
305    /// metric: the larger per-axis half-span for [`CorridorMetric::Rectangular`], the
306    /// minimum-enclosing-circle radius for [`CorridorMetric::Circular`].
307    pub worst_case_miss_mil: f64,
308    /// Per-axis worst cases from the same hold, always reported: these are what the
309    /// rectangular fit check uses, and they stay meaningful under either metric.
310    pub worst_case_elevation_miss_mil: f64,
311    pub worst_case_windage_miss_mil: f64,
312    /// Which scenario is furthest from the minimax hold under the active metric. Ties
313    /// resolve to the first in sorted-by-name order.
314    pub worst_case_scenario: String,
315    /// The same worst-case measure computed from the DESIGNATED NOMINAL scenario's hold,
316    /// when one was named. Present so the report can show what the minimax buys.
317    #[serde(default, skip_serializing_if = "Option::is_none")]
318    pub nominal_worst_case_miss_mil: Option<f64>,
319    /// Whether the whole corridor is inside the target when held at the minimax hold.
320    /// `None` when no target was supplied. Boundary contact counts as a fit.
321    #[serde(default, skip_serializing_if = "Option::is_none")]
322    pub fits_target: Option<bool>,
323}
324
325/// A complete robust-hold report (MBA-1349).
326#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
327pub struct RobustHoldReportV1 {
328    /// Always [`ROBUST_HOLD_REPORT_VERSION`].
329    pub version: u32,
330    /// The requested ranges, meters, in the order they were asked for.
331    pub ranges_m: Vec<f64>,
332    /// Scenario names in the internal sorted order — the order every row's
333    /// [`CorridorRow::scenarios`] uses.
334    pub scenario_names: Vec<String>,
335    #[serde(default, skip_serializing_if = "Option::is_none")]
336    pub nominal: Option<String>,
337    #[serde(default, skip_serializing_if = "Option::is_none")]
338    pub target: Option<TargetSpec>,
339    /// `"rectangular"` or `"circular"` — which norm the minimax holds minimize.
340    pub metric: String,
341    pub rows: Vec<CorridorRow>,
342}
343
344/// Decode a wind scenario set from JSON TEXT (MBA-1349).
345///
346/// Text, not a path: this module stays fs-free so it compiles for wasm32, exactly like
347/// [`crate::drag_file`] and [`crate::truing_dsf`]. File reading belongs to the front end.
348///
349/// Segments are written as the SAME `SPEED:ANGLE:UNTIL[:VERTICAL]` tokens `--wind-segment`
350/// takes, so there is one wind grammar to learn and one parser to trust:
351///
352/// ```json
353/// {"version": 1, "nominal": "low",
354///  "scenarios": [
355///    {"name": "low",  "segments": ["5:90:400", "3:90:800"]},
356///    {"name": "high", "segments": ["12:90:400", "9:270:800"]}]}
357/// ```
358///
359/// `units` selects how SPEED and UNTIL are read (mph/yards imperial, m/s and meters
360/// metric), matching `--wind-segment` exactly.
361pub fn parse_wind_scenario_set(
362    text: &str,
363    units: UnitSystem,
364) -> Result<WindScenarioSetV1, WindScenarioError> {
365    #[derive(Deserialize)]
366    struct WireScenario {
367        name: String,
368        segments: Vec<String>,
369    }
370    #[derive(Deserialize)]
371    struct WireSet {
372        version: u32,
373        scenarios: Vec<WireScenario>,
374        #[serde(default)]
375        nominal: Option<String>,
376    }
377
378    let wire: WireSet =
379        serde_json::from_str(text).map_err(|e| WindScenarioError::MalformedDocument {
380            message: e.to_string(),
381        })?;
382    // Version FIRST: a future document may legitimately carry shapes this build does not
383    // know, and must get the stable version error rather than a confusing field error.
384    if wire.version != WIND_SCENARIO_SET_VERSION {
385        return Err(WindScenarioError::UnsupportedVersion {
386            version: wire.version,
387            expected: WIND_SCENARIO_SET_VERSION,
388        });
389    }
390    // The cap is checked before any segment is parsed, so an oversized document costs one
391    // JSON decode and nothing more.
392    if wire.scenarios.len() > MAX_WIND_SCENARIOS {
393        return Err(WindScenarioError::TooManyScenarios {
394            count: wire.scenarios.len(),
395            max: MAX_WIND_SCENARIOS,
396        });
397    }
398
399    let imperial = matches!(units, UnitSystem::Imperial);
400    let mut scenarios = Vec::with_capacity(wire.scenarios.len());
401    for (index, scenario) in wire.scenarios.into_iter().enumerate() {
402        if scenario.name.trim().is_empty() {
403            return Err(WindScenarioError::EmptyScenarioName { index });
404        }
405        let mut segments = Vec::with_capacity(scenario.segments.len());
406        for (segment_index, token) in scenario.segments.iter().enumerate() {
407            let segment = crate::wind::parse_wind_segment_str(token, imperial).map_err(|e| {
408                WindScenarioError::MalformedSegment {
409                    scenario: scenario.name.clone(),
410                    index: segment_index,
411                    message: e,
412                }
413            })?;
414            segments.push(segment);
415        }
416        scenarios.push(NamedWindScenario {
417            name: scenario.name,
418            segments,
419        });
420    }
421
422    Ok(WindScenarioSetV1 {
423        version: WIND_SCENARIO_SET_VERSION,
424        scenarios,
425        nominal: wire.nominal,
426    })
427}
428
429/// Parse a `rect:WxH` or `circle:D` target specification.
430///
431/// Dimensions are inches (imperial) or centimeters (metric), matching `mpbr --vital-zone`.
432pub fn parse_target_spec(spec: &str, units: UnitSystem) -> Result<TargetSpec, WindScenarioError> {
433    let invalid = |message: String| WindScenarioError::InvalidTarget { message };
434    let to_m = |value: f64| match units {
435        UnitSystem::Imperial => value * 0.0254,
436        UnitSystem::Metric => value * 0.01,
437    };
438    let (shape, rest) = spec.split_once(':').ok_or_else(|| {
439        invalid(format!(
440            "'{spec}': expected rect:WIDTHxHEIGHT or circle:DIAMETER"
441        ))
442    })?;
443    let positive = |token: &str, what: &str| -> Result<f64, WindScenarioError> {
444        let value: f64 = token
445            .trim()
446            .parse()
447            .map_err(|_| invalid(format!("'{spec}': {what} '{token}' is not a number")))?;
448        if !value.is_finite() || value <= 0.0 {
449            return Err(invalid(format!(
450                "'{spec}': {what} must be finite and greater than zero"
451            )));
452        }
453        Ok(value)
454    };
455    match shape.trim().to_lowercase().as_str() {
456        "rect" | "rectangle" => {
457            let (w, h) = rest
458                .split_once(['x', 'X'])
459                .ok_or_else(|| invalid(format!("'{spec}': expected rect:WIDTHxHEIGHT")))?;
460            Ok(TargetSpec::Rect {
461                width_m: to_m(positive(w, "width")?),
462                height_m: to_m(positive(h, "height")?),
463            })
464        }
465        "circle" | "circ" => Ok(TargetSpec::Circle {
466            diameter_m: to_m(positive(rest, "diameter")?),
467        }),
468        other => Err(invalid(format!(
469            "'{spec}': unknown shape '{other}' (expected rect or circle)"
470        ))),
471    }
472}
473
474impl RobustHoldRequest {
475    /// Validate everything cheap BEFORE any trajectory work begins.
476    ///
477    /// That ordering is the point of the ticket's cap criterion: an oversized or malformed
478    /// request must cost a validation pass, never eight full solves.
479    pub fn validate(&self) -> Result<(), WindScenarioError> {
480        if self.scenarios.version != WIND_SCENARIO_SET_VERSION {
481            return Err(WindScenarioError::UnsupportedVersion {
482                version: self.scenarios.version,
483                expected: WIND_SCENARIO_SET_VERSION,
484            });
485        }
486        if self.scenarios.scenarios.is_empty() {
487            return Err(WindScenarioError::NoScenarios);
488        }
489        if self.scenarios.scenarios.len() > MAX_WIND_SCENARIOS {
490            return Err(WindScenarioError::TooManyScenarios {
491                count: self.scenarios.scenarios.len(),
492                max: MAX_WIND_SCENARIOS,
493            });
494        }
495        let mut seen: Vec<&str> = Vec::with_capacity(self.scenarios.scenarios.len());
496        for (index, scenario) in self.scenarios.scenarios.iter().enumerate() {
497            if scenario.name.trim().is_empty() {
498                return Err(WindScenarioError::EmptyScenarioName { index });
499            }
500            if seen.contains(&scenario.name.as_str()) {
501                return Err(WindScenarioError::DuplicateScenarioName {
502                    name: scenario.name.clone(),
503                });
504            }
505            seen.push(&scenario.name);
506            if scenario.segments.is_empty() {
507                return Err(WindScenarioError::NoSegments {
508                    name: scenario.name.clone(),
509                });
510            }
511            validate_wind_segments(&scenario.segments).map_err(|e| {
512                WindScenarioError::InvalidSegment {
513                    scenario: scenario.name.clone(),
514                    message: format!(
515                        "segment #{} is invalid ({:?} violates {:?})",
516                        e.index, e.field, e.rule
517                    ),
518                }
519            })?;
520        }
521        if let Some(nominal) = self.scenarios.nominal.as_deref() {
522            if !self
523                .scenarios
524                .scenarios
525                .iter()
526                .any(|scenario| scenario.name == nominal)
527            {
528                return Err(WindScenarioError::UnknownNominal {
529                    name: nominal.to_string(),
530                    available: self
531                        .scenarios
532                        .scenarios
533                        .iter()
534                        .map(|scenario| scenario.name.clone())
535                        .collect(),
536                });
537            }
538        }
539
540        if self.ranges_m.is_empty() {
541            return Err(WindScenarioError::NoRanges);
542        }
543        if self.ranges_m.len() > MAX_CORRIDOR_RANGES {
544            return Err(WindScenarioError::TooManyRanges {
545                count: self.ranges_m.len(),
546                max: MAX_CORRIDOR_RANGES,
547            });
548        }
549        let mut seen_ranges: Vec<f64> = Vec::with_capacity(self.ranges_m.len());
550        for &range in &self.ranges_m {
551            if !range.is_finite() || range <= 0.0 {
552                return Err(WindScenarioError::InvalidRange { value: range });
553            }
554            if seen_ranges.contains(&range) {
555                return Err(WindScenarioError::DuplicateRange { value: range });
556            }
557            seen_ranges.push(range);
558        }
559
560        if let Some(target) = self.target {
561            let bad = |message: &str| WindScenarioError::InvalidTarget {
562                message: message.to_string(),
563            };
564            match target {
565                TargetSpec::Rect { width_m, height_m } => {
566                    if !width_m.is_finite() || width_m <= 0.0 {
567                        return Err(bad("width must be finite and greater than zero"));
568                    }
569                    if !height_m.is_finite() || height_m <= 0.0 {
570                        return Err(bad("height must be finite and greater than zero"));
571                    }
572                }
573                TargetSpec::Circle { diameter_m } => {
574                    if !diameter_m.is_finite() || diameter_m <= 0.0 {
575                        return Err(bad("diameter must be finite and greater than zero"));
576                    }
577                }
578            }
579        }
580
581        let load = &self.load;
582        for (field, value) in [
583            ("muzzle velocity", load.muzzle_velocity_mps),
584            ("ballistic coefficient", load.bc),
585            ("bullet mass", load.mass_kg),
586            ("bullet diameter", load.diameter_m),
587            ("bullet length", load.bullet_length_m),
588            ("zero distance", load.zero_distance_m),
589        ] {
590            if !value.is_finite() || value <= 0.0 {
591                return Err(WindScenarioError::InvalidLoad { field, value });
592            }
593        }
594        for (field, value) in [
595            ("sight height", load.sight_height_m),
596            ("temperature", load.temperature_c),
597            ("pressure", load.pressure_hpa),
598            ("humidity", load.humidity_pct),
599            ("altitude", load.altitude_m),
600        ] {
601            if !value.is_finite() {
602                return Err(WindScenarioError::InvalidLoad { field, value });
603            }
604        }
605        Ok(())
606    }
607}
608
609/// Solve one robust-hold run (MBA-1349).
610///
611/// Every scenario gets exactly ONE trajectory solve through the existing segmented-wind
612/// machinery, sampled at roughly one-yard intervals and read at each requested range by
613/// linear interpolation. The muzzle angle is solved once in calm air (see the module header) and
614/// reused, so the corridor reflects only the wind hypotheses.
615pub fn solve_robust_hold(
616    request: &RobustHoldRequest,
617) -> Result<RobustHoldReportV1, WindScenarioError> {
618    request.validate()?;
619
620    // Determinism: sort by name once, up front. Everything downstream — row order, the
621    // corridor extremes, the worst-case scenario tie-break — is then over a fixed order,
622    // so the caller's ordering cannot influence any output.
623    let mut scenarios = request.scenarios.scenarios.clone();
624    scenarios.sort_by(|a, b| a.name.cmp(&b.name));
625    let scenario_names: Vec<String> = scenarios
626        .iter()
627        .map(|scenario| scenario.name.clone())
628        .collect();
629
630    let load = &request.load;
631    let atmosphere = AtmosphericConditions {
632        temperature: load.temperature_c,
633        pressure: load.pressure_hpa,
634        humidity: load.humidity_pct,
635        altitude: load.altitude_m,
636    };
637    let base_inputs = BallisticInputs {
638        bc_value: load.bc,
639        bc_type: load.drag_model,
640        bullet_mass: load.mass_kg,
641        muzzle_velocity: load.muzzle_velocity_mps,
642        bullet_diameter: load.diameter_m,
643        bullet_length: load.bullet_length_m,
644        sight_height: load.sight_height_m,
645        use_rk4: true,
646        ..Default::default()
647    };
648    let zero_angle = crate::cli_api::calculate_zero_angle_with_conditions(
649        base_inputs.clone(),
650        load.zero_distance_m,
651        load.sight_height_m,
652        WindConditions::default(),
653        atmosphere.clone(),
654    )
655    .map_err(|e| WindScenarioError::SolveFailed {
656        scenario: "<zero>".to_string(),
657        message: e.to_string(),
658    })?;
659
660    let furthest_m = request
661        .ranges_m
662        .iter()
663        .fold(0.0_f64, |acc, &range| acc.max(range));
664    let max_range_m = furthest_m * 1.02;
665
666    // Per scenario: one solve, then the hold at each requested range.
667    let mut per_scenario: Vec<Vec<(f64, f64)>> = Vec::with_capacity(scenarios.len());
668    for scenario in &scenarios {
669        let mut inputs = base_inputs.clone();
670        inputs.muzzle_angle = zero_angle;
671        inputs.enable_trajectory_sampling = true;
672        inputs.sample_interval = SAMPLE_INTERVAL_M;
673
674        let mut solver =
675            TrajectorySolver::new(inputs, WindConditions::default(), atmosphere.clone());
676        solver.set_max_range(max_range_m);
677        solver.set_time_step(0.001);
678        solver.set_wind_segments(scenario.segments.clone());
679        let result = solver.solve().map_err(|e| WindScenarioError::SolveFailed {
680            scenario: scenario.name.clone(),
681            message: e.to_string(),
682        })?;
683        let samples = result.sampled_points.unwrap_or_default();
684        if samples.len() < 2 {
685            return Err(WindScenarioError::SolveFailed {
686                scenario: scenario.name.clone(),
687                message: "the trajectory produced too few sampled points".to_string(),
688            });
689        }
690        let mut holds = Vec::with_capacity(request.ranges_m.len());
691        for &range_m in &request.ranges_m {
692            let hold = interpolate_hold_mil(&samples, range_m).ok_or_else(|| {
693                WindScenarioError::SolveFailed {
694                    scenario: scenario.name.clone(),
695                    message: format!(
696                        "the trajectory does not reach {range_m:.1} m (it was sampled to {:.1} m)",
697                        samples.last().map_or(0.0, |s| s.distance_m)
698                    ),
699                }
700            })?;
701            holds.push(hold);
702        }
703        per_scenario.push(holds);
704    }
705
706    let metric = request
707        .target
708        .map_or(CorridorMetric::Rectangular, TargetSpec::metric);
709    let nominal_index = request
710        .scenarios
711        .nominal
712        .as_deref()
713        .and_then(|name| scenarios.iter().position(|s| s.name == name));
714
715    let mut rows = Vec::with_capacity(request.ranges_m.len());
716    for (range_index, &range_m) in request.ranges_m.iter().enumerate() {
717        let points: Vec<(f64, f64)> = per_scenario
718            .iter()
719            .map(|holds| holds[range_index])
720            .collect();
721
722        let (elevation_min_mil, elevation_max_mil) = span(points.iter().map(|p| p.0));
723        let (windage_min_mil, windage_max_mil) = span(points.iter().map(|p| p.1));
724
725        let (minimax_elevation_mil, minimax_windage_mil) = match metric {
726            CorridorMetric::Rectangular => (
727                0.5 * (elevation_min_mil + elevation_max_mil),
728                0.5 * (windage_min_mil + windage_max_mil),
729            ),
730            CorridorMetric::Circular => minimum_enclosing_circle_center(&points),
731        };
732        let hold = (minimax_elevation_mil, minimax_windage_mil);
733
734        let worst_case_miss_mil = worst_case(&points, hold, metric);
735        let worst_case_elevation_miss_mil = points
736            .iter()
737            .fold(0.0_f64, |acc, p| acc.max((p.0 - hold.0).abs()));
738        let worst_case_windage_miss_mil = points
739            .iter()
740            .fold(0.0_f64, |acc, p| acc.max((p.1 - hold.1).abs()));
741        // First in sorted-by-name order among ties.
742        let worst_index = points
743            .iter()
744            .enumerate()
745            .fold((0usize, f64::NEG_INFINITY), |(best, best_d), (i, p)| {
746                let d = deviation(*p, hold, metric);
747                if d > best_d {
748                    (i, d)
749                } else {
750                    (best, best_d)
751                }
752            })
753            .0;
754
755        let nominal_worst_case_miss_mil =
756            nominal_index.map(|index| worst_case(&points, points[index], metric));
757
758        let fits_target = request.target.map(|target| {
759            let to_linear_m = |mil: f64| mil / 1000.0 * range_m;
760            match target {
761                TargetSpec::Rect { width_m, height_m } => {
762                    to_linear_m(worst_case_elevation_miss_mil)
763                        <= height_m / 2.0 + TARGET_FIT_EPSILON_M
764                        && to_linear_m(worst_case_windage_miss_mil)
765                            <= width_m / 2.0 + TARGET_FIT_EPSILON_M
766                }
767                TargetSpec::Circle { diameter_m } => {
768                    to_linear_m(worst_case_miss_mil) <= diameter_m / 2.0 + TARGET_FIT_EPSILON_M
769                }
770            }
771        });
772
773        rows.push(CorridorRow {
774            range_m,
775            scenarios: scenario_names
776                .iter()
777                .zip(&points)
778                .map(|(name, point)| ScenarioHold {
779                    name: name.clone(),
780                    elevation_mil: point.0,
781                    windage_mil: point.1,
782                })
783                .collect(),
784            elevation_min_mil,
785            elevation_max_mil,
786            windage_min_mil,
787            windage_max_mil,
788            minimax_elevation_mil,
789            minimax_windage_mil,
790            worst_case_miss_mil,
791            worst_case_elevation_miss_mil,
792            worst_case_windage_miss_mil,
793            worst_case_scenario: scenario_names[worst_index].clone(),
794            nominal_worst_case_miss_mil,
795            fits_target,
796        });
797    }
798
799    Ok(RobustHoldReportV1 {
800        version: ROBUST_HOLD_REPORT_VERSION,
801        ranges_m: request.ranges_m.clone(),
802        scenario_names,
803        nominal: request.scenarios.nominal.clone(),
804        target: request.target,
805        metric: match metric {
806            CorridorMetric::Rectangular => "rectangular".to_string(),
807            CorridorMetric::Circular => "circular".to_string(),
808        },
809        rows,
810    })
811}
812
813/// Linearly interpolate `(elevation_mil, windage_mil)` at `range_m`.
814///
815/// `TrajectorySample::drop_m` is positive BELOW the line of sight and `wind_drift_m`
816/// positive to the RIGHT, so both map straight onto the hold convention. Milliradians use
817/// the small-angle definition (1 mil subtends 1/1000 of the range) — the same conversion
818/// every other 0.31.0 surface uses.
819fn interpolate_hold_mil(
820    samples: &[crate::trajectory_sampling::TrajectorySample],
821    range_m: f64,
822) -> Option<(f64, f64)> {
823    if !range_m.is_finite() || range_m <= 0.0 {
824        return None;
825    }
826    let Bracket::Inside { lo, t } =
827        bracket_param(samples.len(), |i| samples[i].distance_m, range_m)
828    else {
829        return None;
830    };
831    let hi = lo + 1;
832    let lerp = |a: f64, b: f64| a + (b - a) * t;
833    Some((
834        lerp(samples[lo].drop_m, samples[hi].drop_m) / range_m * 1000.0,
835        lerp(samples[lo].wind_drift_m, samples[hi].wind_drift_m) / range_m * 1000.0,
836    ))
837}
838
839fn span(values: impl Iterator<Item = f64>) -> (f64, f64) {
840    let mut lo = f64::INFINITY;
841    let mut hi = f64::NEG_INFINITY;
842    for value in values {
843        if value < lo {
844            lo = value;
845        }
846        if value > hi {
847            hi = value;
848        }
849    }
850    (lo, hi)
851}
852
853/// Distance from `hold` to `point` under `metric`.
854fn deviation(point: (f64, f64), hold: (f64, f64), metric: CorridorMetric) -> f64 {
855    let de = (point.0 - hold.0).abs();
856    let dw = (point.1 - hold.1).abs();
857    match metric {
858        CorridorMetric::Rectangular => de.max(dw),
859        CorridorMetric::Circular => (de * de + dw * dw).sqrt(),
860    }
861}
862
863/// Largest deviation from `hold` to any point, under `metric`.
864fn worst_case(points: &[(f64, f64)], hold: (f64, f64), metric: CorridorMetric) -> f64 {
865    points
866        .iter()
867        .fold(0.0_f64, |acc, &p| acc.max(deviation(p, hold, metric)))
868}
869
870/// Center of the minimum enclosing circle of a small point set.
871///
872/// Exhaustive over the candidate circles a minimum enclosing circle can be — one point,
873/// the diameter of a pair, or the circumcircle of a triple — and the smallest candidate
874/// that contains every point wins. With at most [`MAX_WIND_SCENARIOS`] points that is
875/// under a hundred candidates, so the exact brute force is both cheaper to reason about
876/// and *deterministic*, unlike the usual randomized Welzl construction. Determinism
877/// matters here: the ticket requires that reordering scenarios cannot change the chosen
878/// hold.
879fn minimum_enclosing_circle_center(points: &[(f64, f64)]) -> (f64, f64) {
880    let n = points.len();
881    if n == 0 {
882        return (0.0, 0.0);
883    }
884    if n == 1 {
885        return points[0];
886    }
887    // Slack when testing containment, in the same milliradian units as the holds: a
888    // circumcircle's own defining points must count as inside despite rounding.
889    const CONTAINS_EPSILON: f64 = 1.0e-9;
890    let mut best: Option<((f64, f64), f64)> = None;
891    let mut consider = |center: (f64, f64), radius: f64| {
892        if !center.0.is_finite() || !center.1.is_finite() || !radius.is_finite() {
893            return;
894        }
895        if points
896            .iter()
897            .any(|p| deviation(*p, center, CorridorMetric::Circular) > radius + CONTAINS_EPSILON)
898        {
899            return;
900        }
901        match best {
902            Some((_, best_radius)) if best_radius <= radius => {}
903            _ => best = Some((center, radius)),
904        }
905    };
906
907    for i in 0..n {
908        for j in (i + 1)..n {
909            let center = (
910                0.5 * (points[i].0 + points[j].0),
911                0.5 * (points[i].1 + points[j].1),
912            );
913            let radius = deviation(points[i], center, CorridorMetric::Circular);
914            consider(center, radius);
915        }
916    }
917    for i in 0..n {
918        for j in (i + 1)..n {
919            for k in (j + 1)..n {
920                if let Some(center) = circumcenter(points[i], points[j], points[k]) {
921                    let radius = deviation(points[i], center, CorridorMetric::Circular);
922                    consider(center, radius);
923                }
924            }
925        }
926    }
927    // Degenerate fallback: every point coincident, or all candidates non-finite.
928    best.map_or(points[0], |(center, _)| center)
929}
930
931/// Circumcenter of three points, or `None` when they are collinear.
932fn circumcenter(a: (f64, f64), b: (f64, f64), c: (f64, f64)) -> Option<(f64, f64)> {
933    let d = 2.0 * (a.0 * (b.1 - c.1) + b.0 * (c.1 - a.1) + c.0 * (a.1 - b.1));
934    if d.abs() < 1.0e-15 {
935        return None;
936    }
937    let a2 = a.0 * a.0 + a.1 * a.1;
938    let b2 = b.0 * b.0 + b.1 * b.1;
939    let c2 = c.0 * c.0 + c.1 * c.1;
940    Some((
941        (a2 * (b.1 - c.1) + b2 * (c.1 - a.1) + c2 * (a.1 - b.1)) / d,
942        (a2 * (c.0 - b.0) + b2 * (a.0 - c.0) + c2 * (b.0 - a.0)) / d,
943    ))
944}
945
946/// Rendering shape for the robust-hold report.
947#[derive(Debug, Clone, Copy, PartialEq, Eq)]
948pub enum RobustHoldFormat {
949    Table,
950    Json,
951}
952
953/// Render a robust-hold report, identically on every surface (MBA-1349).
954///
955/// THE formatter: the native CLI prints what this returns. The command is native-only this
956/// train, but the sharing discipline is set up now so the WASM follow-up is a dispatch
957/// entry rather than a second copy of the format strings — the divergence the `recoil` CSV
958/// header suffered (MBA-1418) is exactly what that costs.
959///
960/// Returned strings are newline-terminated.
961pub fn format_robust_hold_report(
962    report: &RobustHoldReportV1,
963    format: RobustHoldFormat,
964    units: UnitSystem,
965) -> String {
966    let (dist_unit, size_unit) = match units {
967        UnitSystem::Imperial => ("yd", "in"),
968        UnitSystem::Metric => ("m", "cm"),
969    };
970    let range_display = |range_m: f64| match units {
971        UnitSystem::Imperial => range_m / 0.9144,
972        UnitSystem::Metric => range_m,
973    };
974    let size_display = |value_m: f64| match units {
975        UnitSystem::Imperial => value_m / 0.0254,
976        UnitSystem::Metric => value_m * 100.0,
977    };
978
979    match format {
980        RobustHoldFormat::Json => {
981            // The versioned wire shape IS the report struct: one definition, so the table
982            // below and any machine consumer cannot describe different numbers.
983            format!(
984                "{}\n",
985                serde_json::to_string_pretty(report)
986                    .unwrap_or_else(|_| "{\"error\":\"serialization failed\"}".to_string())
987            )
988        }
989        RobustHoldFormat::Table => {
990            let mut out = String::new();
991            out.push_str("Robust Hold Corridor\n");
992            out.push_str("====================\n\n");
993            out.push_str(&format!(
994                "Scenarios ({}): {}\n",
995                report.scenario_names.len(),
996                report.scenario_names.join(", ")
997            ));
998            if let Some(nominal) = &report.nominal {
999                out.push_str(&format!("Nominal:        {nominal}\n"));
1000            }
1001            match report.target {
1002                Some(TargetSpec::Rect { width_m, height_m }) => out.push_str(&format!(
1003                    "Target:         rectangle {:.1} x {:.1} {} (per-axis / L-inf metric)\n",
1004                    size_display(width_m),
1005                    size_display(height_m),
1006                    size_unit
1007                )),
1008                Some(TargetSpec::Circle { diameter_m }) => out.push_str(&format!(
1009                    "Target:         circle {:.1} {} across (Euclidean / L2 metric)\n",
1010                    size_display(diameter_m),
1011                    size_unit
1012                )),
1013                None => out.push_str(
1014                    "Target:         none — the minimax hold uses the per-axis metric\n",
1015                ),
1016            }
1017            out.push_str(
1018                "\nHolds are milliradians: elevation positive = hold UP, windage positive = \
1019                 hold RIGHT.\nThe corridor is the span of the scenarios you supplied. It is \
1020                 NOT a probability interval.\n\n",
1021            );
1022
1023            for row in &report.rows {
1024                out.push_str(&format!(
1025                    "--- {:.0} {} ---\n",
1026                    range_display(row.range_m),
1027                    dist_unit
1028                ));
1029                out.push_str("  Scenario              Elev(mil)  Wind(mil)\n");
1030                for hold in &row.scenarios {
1031                    out.push_str(&format!(
1032                        "  {:<20}  {:>9.3}  {:>9.3}\n",
1033                        hold.name, hold.elevation_mil, hold.windage_mil
1034                    ));
1035                }
1036                out.push_str(&format!(
1037                    "  Corridor              {:>9.3}  {:>9.3}   (min)\n",
1038                    row.elevation_min_mil, row.windage_min_mil
1039                ));
1040                out.push_str(&format!(
1041                    "                        {:>9.3}  {:>9.3}   (max)\n",
1042                    row.elevation_max_mil, row.windage_max_mil
1043                ));
1044                out.push_str(&format!(
1045                    "  Minimax hold          {:>9.3}  {:>9.3}\n",
1046                    row.minimax_elevation_mil, row.minimax_windage_mil
1047                ));
1048                out.push_str(&format!(
1049                    "  Worst case from it    {:>9.3} mil ({:.2} {}), scenario '{}'\n",
1050                    row.worst_case_miss_mil,
1051                    size_display(row.worst_case_miss_mil / 1000.0 * row.range_m),
1052                    size_unit,
1053                    row.worst_case_scenario
1054                ));
1055                if let Some(nominal_worst) = row.nominal_worst_case_miss_mil {
1056                    out.push_str(&format!(
1057                        "  Holding the nominal   {:>9.3} mil ({:.2} {})\n",
1058                        nominal_worst,
1059                        size_display(nominal_worst / 1000.0 * row.range_m),
1060                        size_unit
1061                    ));
1062                }
1063                if let Some(fits) = row.fits_target {
1064                    out.push_str(&format!(
1065                        "  Fits target           {}\n",
1066                        if fits {
1067                            "yes — one hold covers every scenario"
1068                        } else {
1069                            "NO — no single hold covers every scenario here"
1070                        }
1071                    ));
1072                }
1073                out.push('\n');
1074            }
1075            out
1076        }
1077    }
1078}
1079
1080#[cfg(test)]
1081mod tests {
1082    use super::*;
1083
1084    fn segments(tokens: &[&str]) -> Vec<WindSegment> {
1085        tokens
1086            .iter()
1087            .map(|token| crate::wind::parse_wind_segment_str(token, true).unwrap())
1088            .collect()
1089    }
1090
1091    fn scenario(name: &str, tokens: &[&str]) -> NamedWindScenario {
1092        NamedWindScenario {
1093            name: name.to_string(),
1094            segments: segments(tokens),
1095        }
1096    }
1097
1098    pub(super) fn test_load() -> CorridorLoad {
1099        CorridorLoad {
1100            muzzle_velocity_mps: 823.0,
1101            bc: 0.475,
1102            drag_model: DragModel::G1,
1103            mass_kg: 0.010_886,
1104            diameter_m: 0.007_823,
1105            bullet_length_m: 0.031,
1106            zero_distance_m: 91.44,
1107            sight_height_m: 0.0508,
1108            temperature_c: 15.0,
1109            pressure_hpa: 1013.25,
1110            humidity_pct: 50.0,
1111            altitude_m: 0.0,
1112        }
1113    }
1114
1115    fn request(scenarios: Vec<NamedWindScenario>, nominal: Option<&str>) -> RobustHoldRequest {
1116        RobustHoldRequest {
1117            scenarios: WindScenarioSetV1 {
1118                version: 1,
1119                scenarios,
1120                nominal: nominal.map(str::to_string),
1121            },
1122            ranges_m: vec![182.88, 365.76, 548.64],
1123            target: None,
1124            load: test_load(),
1125        }
1126    }
1127
1128    // ---- Acceptance criterion 2: the corridor contains every scenario ----
1129    #[test]
1130    fn corridor_contains_every_scenario_at_every_range() {
1131        let report = solve_robust_hold(&request(
1132            vec![
1133                scenario("low", &["4:90:1000"]),
1134                scenario("high", &["14:90:1000"]),
1135                scenario("switch", &["10:90:400", "8:270:1000"]),
1136            ],
1137            None,
1138        ))
1139        .unwrap();
1140        assert_eq!(report.version, ROBUST_HOLD_REPORT_VERSION);
1141        assert_eq!(report.rows.len(), 3);
1142        for row in &report.rows {
1143            assert_eq!(row.scenarios.len(), 3);
1144            for hold in &row.scenarios {
1145                assert!(
1146                    hold.elevation_mil >= row.elevation_min_mil
1147                        && hold.elevation_mil <= row.elevation_max_mil,
1148                    "elevation {} outside [{}, {}]",
1149                    hold.elevation_mil,
1150                    row.elevation_min_mil,
1151                    row.elevation_max_mil
1152                );
1153                assert!(
1154                    hold.windage_mil >= row.windage_min_mil
1155                        && hold.windage_mil <= row.windage_max_mil,
1156                    "windage {} outside [{}, {}]",
1157                    hold.windage_mil,
1158                    row.windage_min_mil,
1159                    row.windage_max_mil
1160                );
1161            }
1162            // The scenarios genuinely disagree, so this is not a vacuous containment.
1163            assert!(row.windage_max_mil - row.windage_min_mil > 0.1, "{row:?}");
1164        }
1165    }
1166
1167    // ---- Acceptance criterion 3: one scenario collapses the corridor ----
1168    #[test]
1169    fn a_single_scenario_gives_a_zero_width_corridor() {
1170        let report =
1171            solve_robust_hold(&request(vec![scenario("only", &["9:90:1000"])], None)).unwrap();
1172        for row in &report.rows {
1173            assert_eq!(row.elevation_min_mil, row.elevation_max_mil);
1174            assert_eq!(row.windage_min_mil, row.windage_max_mil);
1175            assert_eq!(row.minimax_elevation_mil, row.scenarios[0].elevation_mil);
1176            assert_eq!(row.minimax_windage_mil, row.scenarios[0].windage_mil);
1177            assert_eq!(row.worst_case_miss_mil, 0.0);
1178            assert_eq!(row.worst_case_elevation_miss_mil, 0.0);
1179            assert_eq!(row.worst_case_windage_miss_mil, 0.0);
1180        }
1181    }
1182
1183    // ---- Acceptance criterion 4: minimax never loses to the nominal hold ----
1184    #[test]
1185    fn minimax_worst_case_never_exceeds_the_nominal_holds() {
1186        for (target, label) in [
1187            (None, "no target"),
1188            (
1189                Some(TargetSpec::Rect {
1190                    width_m: 0.3,
1191                    height_m: 0.5,
1192                }),
1193                "rect",
1194            ),
1195            (Some(TargetSpec::Circle { diameter_m: 0.3 }), "circle"),
1196        ] {
1197            for nominal in ["low", "high", "switch"] {
1198                let mut req = request(
1199                    vec![
1200                        scenario("low", &["4:90:1000"]),
1201                        scenario("high", &["14:90:1000"]),
1202                        scenario("switch", &["10:90:400", "8:270:1000"]),
1203                    ],
1204                    Some(nominal),
1205                );
1206                req.target = target;
1207                let report = solve_robust_hold(&req).unwrap();
1208                for row in &report.rows {
1209                    let nominal_worst = row.nominal_worst_case_miss_mil.unwrap();
1210                    assert!(
1211                        row.worst_case_miss_mil <= nominal_worst + 1e-12,
1212                        "{label}/{nominal} at {} m: minimax {} > nominal {}",
1213                        row.range_m,
1214                        row.worst_case_miss_mil,
1215                        nominal_worst
1216                    );
1217                }
1218            }
1219        }
1220    }
1221
1222    // ---- Acceptance criterion 7: reordering changes nothing ----
1223    #[test]
1224    fn reordering_scenarios_changes_nothing() {
1225        let a = solve_robust_hold(&request(
1226            vec![
1227                scenario("low", &["4:90:1000"]),
1228                scenario("high", &["14:90:1000"]),
1229                scenario("switch", &["10:90:400", "8:270:1000"]),
1230            ],
1231            Some("high"),
1232        ))
1233        .unwrap();
1234        let b = solve_robust_hold(&request(
1235            vec![
1236                scenario("switch", &["10:90:400", "8:270:1000"]),
1237                scenario("high", &["14:90:1000"]),
1238                scenario("low", &["4:90:1000"]),
1239            ],
1240            Some("high"),
1241        ))
1242        .unwrap();
1243        assert_eq!(a, b, "scenario order must not affect any output");
1244        // And the internal order is alphabetical, not the input order.
1245        assert_eq!(a.scenario_names, vec!["high", "low", "switch"]);
1246    }
1247
1248    // ---- Acceptance criterion 5: target fit, both shapes, including the boundary ----
1249    #[test]
1250    fn target_fit_is_correct_for_both_shapes_including_boundary_contact() {
1251        // A GENUINELY TWO-AXIS spread: two crosswinds spread the hold in windage, and a
1252        // third scenario carrying a strong vertical wind spreads it in elevation. Both
1253        // axes must move, or the circle (L2) and rectangle (L-infinity) metrics coincide
1254        // and the strict L2 > L-infinity assertion at the end proves nothing — an earlier
1255        // pure-crosswind fixture made exactly that mistake.
1256        let base = || {
1257            request(
1258                vec![
1259                    scenario("cross-light", &["4:90:1000"]),
1260                    scenario("cross-heavy", &["14:90:1000"]),
1261                    scenario("updraft", &["4:90:1000:10"]),
1262                ],
1263                None,
1264            )
1265        };
1266        let mut probe = base();
1267        probe.ranges_m = vec![548.64];
1268        let report = solve_robust_hold(&probe).unwrap();
1269        let row = &report.rows[0];
1270        let half_span_wind = row.worst_case_windage_miss_mil;
1271        let half_span_elev = row.worst_case_elevation_miss_mil;
1272        // Precondition: both axes really spread. If the vertical wind stopped moving the
1273        // elevation hold this fails LOUDLY rather than letting the metric test degenerate.
1274        assert!(
1275            half_span_wind > 0.05 && half_span_elev > 0.05,
1276            "fixture must spread BOTH axes: windage {half_span_wind}, elevation {half_span_elev}"
1277        );
1278        let range_m = row.range_m;
1279        let wind_linear = half_span_wind / 1000.0 * range_m;
1280        let elev_linear = half_span_elev / 1000.0 * range_m;
1281
1282        // RECTANGLE, exactly on the boundary: width = 2 * the windage half-span.
1283        let mut exact = base();
1284        exact.ranges_m = vec![548.64];
1285        exact.target = Some(TargetSpec::Rect {
1286            width_m: 2.0 * wind_linear,
1287            height_m: (2.0 * elev_linear).max(0.01),
1288        });
1289        assert_eq!(
1290            solve_robust_hold(&exact).unwrap().rows[0].fits_target,
1291            Some(true),
1292            "boundary contact counts as a fit"
1293        );
1294
1295        // A hair narrower does not fit.
1296        let mut narrow = exact.clone();
1297        narrow.target = Some(TargetSpec::Rect {
1298            width_m: 2.0 * wind_linear * 0.99,
1299            height_m: (2.0 * elev_linear).max(0.01),
1300        });
1301        assert_eq!(
1302            solve_robust_hold(&narrow).unwrap().rows[0].fits_target,
1303            Some(false)
1304        );
1305
1306        // A hair wider does.
1307        let mut wide = exact.clone();
1308        wide.target = Some(TargetSpec::Rect {
1309            width_m: 2.0 * wind_linear * 1.01,
1310            height_m: (2.0 * elev_linear).max(0.01) * 1.01,
1311        });
1312        assert_eq!(
1313            solve_robust_hold(&wide).unwrap().rows[0].fits_target,
1314            Some(true)
1315        );
1316
1317        // CIRCLE: the metric changes, so re-read the radius under it.
1318        let mut circle_probe = base();
1319        circle_probe.ranges_m = vec![548.64];
1320        circle_probe.target = Some(TargetSpec::Circle { diameter_m: 1.0 });
1321        let circle_row = solve_robust_hold(&circle_probe).unwrap().rows[0].clone();
1322        let radius_linear = circle_row.worst_case_miss_mil / 1000.0 * range_m;
1323
1324        let mut exact_circle = circle_probe.clone();
1325        exact_circle.target = Some(TargetSpec::Circle {
1326            diameter_m: 2.0 * radius_linear,
1327        });
1328        assert_eq!(
1329            solve_robust_hold(&exact_circle).unwrap().rows[0].fits_target,
1330            Some(true),
1331            "boundary contact counts as a fit for a circle too"
1332        );
1333
1334        let mut small_circle = circle_probe.clone();
1335        small_circle.target = Some(TargetSpec::Circle {
1336            diameter_m: 2.0 * radius_linear * 0.99,
1337        });
1338        assert_eq!(
1339            solve_robust_hold(&small_circle).unwrap().rows[0].fits_target,
1340            Some(false)
1341        );
1342
1343        // And the two metrics really are different: the circular objective is the
1344        // Euclidean radius, which for a genuine two-axis spread STRICTLY exceeds the
1345        // larger half-span (the L-infinity answer). With both axes confirmed nonzero
1346        // above, a non-strict `>=` here would also pass if the code computed L-infinity,
1347        // so the strict `>` is what actually distinguishes the two metrics.
1348        let l_inf = half_span_wind.max(half_span_elev);
1349        assert!(
1350            circle_row.worst_case_miss_mil > l_inf + 1e-6,
1351            "circular metric must be a true L2 radius strictly above the L-inf half-span: \
1352             L2 {} vs L-inf {}",
1353            circle_row.worst_case_miss_mil,
1354            l_inf
1355        );
1356    }
1357
1358    // ---- Acceptance criterion 6: caps and malformed segments, before any work ----
1359    #[test]
1360    fn caps_and_malformed_input_are_structured_errors_before_any_solve() {
1361        let nine: Vec<NamedWindScenario> = (0..9)
1362            .map(|i| scenario(&format!("s{i}"), &["5:90:1000"]))
1363            .collect();
1364        assert_eq!(
1365            solve_robust_hold(&request(nine, None)).unwrap_err(),
1366            WindScenarioError::TooManyScenarios { count: 9, max: 8 }
1367        );
1368
1369        let mut too_many_ranges = request(vec![scenario("a", &["5:90:1000"])], None);
1370        too_many_ranges.ranges_m = (1..=65).map(f64::from).collect();
1371        assert_eq!(
1372            solve_robust_hold(&too_many_ranges).unwrap_err(),
1373            WindScenarioError::TooManyRanges { count: 65, max: 64 }
1374        );
1375
1376        let mut no_scenarios = request(vec![], None);
1377        no_scenarios.ranges_m = vec![100.0];
1378        assert_eq!(
1379            solve_robust_hold(&no_scenarios).unwrap_err(),
1380            WindScenarioError::NoScenarios
1381        );
1382
1383        let mut bad_segment = request(vec![scenario("a", &["5:90:1000"])], None);
1384        bad_segment.scenarios.scenarios[0].segments[0].until_m = -1.0;
1385        assert!(matches!(
1386            solve_robust_hold(&bad_segment).unwrap_err(),
1387            WindScenarioError::InvalidSegment { .. }
1388        ));
1389
1390        let mut empty_segments = request(vec![scenario("a", &["5:90:1000"])], None);
1391        empty_segments.scenarios.scenarios[0].segments.clear();
1392        assert!(matches!(
1393            solve_robust_hold(&empty_segments).unwrap_err(),
1394            WindScenarioError::NoSegments { .. }
1395        ));
1396
1397        let mut duplicate = request(
1398            vec![scenario("a", &["5:90:1000"]), scenario("a", &["9:90:1000"])],
1399            None,
1400        );
1401        duplicate.ranges_m = vec![100.0];
1402        assert!(matches!(
1403            solve_robust_hold(&duplicate).unwrap_err(),
1404            WindScenarioError::DuplicateScenarioName { .. }
1405        ));
1406
1407        let unknown_nominal = request(vec![scenario("a", &["5:90:1000"])], Some("nope"));
1408        assert!(matches!(
1409            solve_robust_hold(&unknown_nominal).unwrap_err(),
1410            WindScenarioError::UnknownNominal { .. }
1411        ));
1412
1413        let mut bad_version = request(vec![scenario("a", &["5:90:1000"])], None);
1414        bad_version.scenarios.version = 2;
1415        assert_eq!(
1416            solve_robust_hold(&bad_version).unwrap_err(),
1417            WindScenarioError::UnsupportedVersion {
1418                version: 2,
1419                expected: 1
1420            }
1421        );
1422
1423        let mut duplicate_range = request(vec![scenario("a", &["5:90:1000"])], None);
1424        duplicate_range.ranges_m = vec![100.0, 200.0, 100.0];
1425        assert_eq!(
1426            solve_robust_hold(&duplicate_range).unwrap_err(),
1427            WindScenarioError::DuplicateRange { value: 100.0 }
1428        );
1429
1430        let mut bad_load = request(vec![scenario("a", &["5:90:1000"])], None);
1431        bad_load.load.muzzle_velocity_mps = 0.0;
1432        assert!(matches!(
1433            solve_robust_hold(&bad_load).unwrap_err(),
1434            WindScenarioError::InvalidLoad { .. }
1435        ));
1436    }
1437
1438    #[test]
1439    fn parsing_enforces_the_version_and_the_scenario_cap_before_segments() {
1440        // A future version is rejected even though its scenario shapes are unknown.
1441        let err = parse_wind_scenario_set(
1442            r#"{"version":2,"scenarios":[{"name":"a","segments":["x"]}]}"#,
1443            UnitSystem::Imperial,
1444        )
1445        .unwrap_err();
1446        assert_eq!(
1447            err,
1448            WindScenarioError::UnsupportedVersion {
1449                version: 2,
1450                expected: 1
1451            },
1452            "the version check must precede segment parsing"
1453        );
1454
1455        // Nine scenarios are rejected on count, not on their (valid) contents.
1456        let scenarios: Vec<String> = (0..9)
1457            .map(|i| format!(r#"{{"name":"s{i}","segments":["5:90:1000"]}}"#))
1458            .collect();
1459        let doc = format!(
1460            r#"{{"version":1,"scenarios":[{}]}}"#,
1461            scenarios.join(",")
1462        );
1463        assert_eq!(
1464            parse_wind_scenario_set(&doc, UnitSystem::Imperial).unwrap_err(),
1465            WindScenarioError::TooManyScenarios { count: 9, max: 8 }
1466        );
1467
1468        let err = parse_wind_scenario_set(
1469            r#"{"version":1,"scenarios":[{"name":"a","segments":["nope"]}]}"#,
1470            UnitSystem::Imperial,
1471        )
1472        .unwrap_err();
1473        assert!(matches!(err, WindScenarioError::MalformedSegment { .. }), "{err}");
1474
1475        let err = parse_wind_scenario_set(
1476            r#"{"version":1,"scenarios":[{"name":"  ","segments":["5:90:1000"]}]}"#,
1477            UnitSystem::Imperial,
1478        )
1479        .unwrap_err();
1480        assert_eq!(err, WindScenarioError::EmptyScenarioName { index: 0 });
1481
1482        assert!(matches!(
1483            parse_wind_scenario_set("not json", UnitSystem::Imperial).unwrap_err(),
1484            WindScenarioError::MalformedDocument { .. }
1485        ));
1486
1487        // A valid document, with the units applied the way --wind-segment applies them.
1488        let set = parse_wind_scenario_set(
1489            r#"{"version":1,"nominal":"low",
1490                "scenarios":[{"name":"low","segments":["10:90:400"]}]}"#,
1491            UnitSystem::Imperial,
1492        )
1493        .unwrap();
1494        assert_eq!(set.nominal.as_deref(), Some("low"));
1495        assert_eq!(set.scenarios.len(), 1);
1496        // 10 mph -> km/h, 400 yd -> m.
1497        assert!((set.scenarios[0].segments[0].speed_kmh - 16.09344).abs() < 1e-9);
1498        assert!((set.scenarios[0].segments[0].until_m - 365.76).abs() < 1e-9);
1499    }
1500
1501    #[test]
1502    fn target_spec_parsing() {
1503        assert_eq!(
1504            parse_target_spec("rect:12x18", UnitSystem::Imperial).unwrap(),
1505            TargetSpec::Rect {
1506                width_m: 12.0 * 0.0254,
1507                height_m: 18.0 * 0.0254
1508            }
1509        );
1510        assert_eq!(
1511            parse_target_spec("circle:10", UnitSystem::Metric).unwrap(),
1512            TargetSpec::Circle { diameter_m: 0.1 }
1513        );
1514        for bad in ["rect", "rect:12", "circle:-1", "blob:3", "rect:0x5", "circle:abc"] {
1515            assert!(
1516                parse_target_spec(bad, UnitSystem::Imperial).is_err(),
1517                "'{bad}' should be rejected"
1518            );
1519        }
1520    }
1521
1522    #[test]
1523    fn minimum_enclosing_circle_is_exact_and_order_independent() {
1524        // Three points on a unit circle: the enclosing circle is that circle.
1525        let points = vec![(1.0, 0.0), (-0.5, 0.866_025_403_784_438_6), (-0.5, -0.866_025_403_784_438_6)];
1526        let center = minimum_enclosing_circle_center(&points);
1527        assert!(center.0.abs() < 1e-9 && center.1.abs() < 1e-9, "{center:?}");
1528
1529        // Two points: the circle on their diameter, not the bounding box's center.
1530        let pair = vec![(0.0, 0.0), (2.0, 4.0)];
1531        assert_eq!(minimum_enclosing_circle_center(&pair), (1.0, 2.0));
1532
1533        // A point inside three others does not move the answer, in any order. Compared
1534        // with a tolerance, not bit-for-bit: the candidate enumeration order changes with
1535        // the input order, so the winning circle can differ in its last ULP. The
1536        // ORDER-INDEPENDENCE the ticket requires is guaranteed one level up, by
1537        // `solve_robust_hold` sorting scenarios by name before this is ever called (see
1538        // `reordering_scenarios_changes_nothing`, which asserts exact equality of the
1539        // whole report).
1540        let mut with_interior = points.clone();
1541        with_interior.push((0.1, -0.05));
1542        let a = minimum_enclosing_circle_center(&with_interior);
1543        with_interior.reverse();
1544        let b = minimum_enclosing_circle_center(&with_interior);
1545        assert!(
1546            (a.0 - b.0).abs() < 1e-12 && (a.1 - b.1).abs() < 1e-12,
1547            "{a:?} vs {b:?}"
1548        );
1549
1550        assert_eq!(minimum_enclosing_circle_center(&[(3.0, 4.0)]), (3.0, 4.0));
1551        assert_eq!(minimum_enclosing_circle_center(&[]), (0.0, 0.0));
1552    }
1553
1554    #[test]
1555    fn formatter_renders_both_shapes_and_json_is_the_wire_schema() {
1556        let mut req = request(
1557            vec![
1558                scenario("low", &["4:90:1000"]),
1559                scenario("high", &["14:90:1000"]),
1560            ],
1561            Some("low"),
1562        );
1563        req.target = Some(TargetSpec::Rect {
1564            width_m: 0.3,
1565            height_m: 0.5,
1566        });
1567        let report = solve_robust_hold(&req).unwrap();
1568
1569        // `-o json` IS the versioned wire schema: it deserializes straight back into the
1570        // report type, field for field. Values are compared with a tolerance rather than
1571        // bit-for-bit because serde_json's default float parser is accurate to within an
1572        // ULP, not exactly round-tripping (that needs its `float_roundtrip` feature) —
1573        // which is a property of the JSON reader, not of this schema.
1574        let json = format_robust_hold_report(&report, RobustHoldFormat::Json, UnitSystem::Imperial);
1575        let back: RobustHoldReportV1 = serde_json::from_str(&json).unwrap();
1576        assert_eq!(back.version, report.version);
1577        assert_eq!(back.scenario_names, report.scenario_names);
1578        assert_eq!(back.nominal, report.nominal);
1579        assert_eq!(back.metric, report.metric);
1580        assert_eq!(back.rows.len(), report.rows.len());
1581        for (got, want) in back.rows.iter().zip(&report.rows) {
1582            assert_eq!(got.worst_case_scenario, want.worst_case_scenario);
1583            assert_eq!(got.fits_target, want.fits_target);
1584            assert_eq!(got.scenarios.len(), want.scenarios.len());
1585            for (a, b) in got.scenarios.iter().zip(&want.scenarios) {
1586                assert_eq!(a.name, b.name);
1587                assert!((a.elevation_mil - b.elevation_mil).abs() < 1e-12);
1588                assert!((a.windage_mil - b.windage_mil).abs() < 1e-12);
1589            }
1590            for (a, b) in [
1591                (got.range_m, want.range_m),
1592                (got.elevation_min_mil, want.elevation_min_mil),
1593                (got.elevation_max_mil, want.elevation_max_mil),
1594                (got.windage_min_mil, want.windage_min_mil),
1595                (got.windage_max_mil, want.windage_max_mil),
1596                (got.minimax_elevation_mil, want.minimax_elevation_mil),
1597                (got.minimax_windage_mil, want.minimax_windage_mil),
1598                (got.worst_case_miss_mil, want.worst_case_miss_mil),
1599            ] {
1600                assert!((a - b).abs() < 1e-12, "{a} vs {b}");
1601            }
1602        }
1603
1604        let table =
1605            format_robust_hold_report(&report, RobustHoldFormat::Table, UnitSystem::Imperial);
1606        assert!(table.contains("Robust Hold Corridor"));
1607        assert!(table.contains("Minimax hold"));
1608        assert!(table.contains("Holding the nominal"));
1609        assert!(table.contains("Fits target"));
1610        assert!(
1611            table.contains("NOT a probability interval"),
1612            "the non-goal must be stated where it is read: {table}"
1613        );
1614        assert!(table.ends_with('\n'));
1615    }
1616}