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