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::wind::{validate_wind_segments, WindSegment};
47use crate::WindConditions;
48
49/// Schema version carried by [`WindScenarioSetV1`] and enforced on load.
50pub const WIND_SCENARIO_SET_VERSION: u32 = 1;
51
52/// Schema version carried by [`RobustHoldReportV1`].
53pub const ROBUST_HOLD_REPORT_VERSION: u32 = 1;
54
55/// Most scenarios one run will accept.
56///
57/// Each scenario is a full trajectory solve, and the point of the feature is a handful of
58/// concrete calls a person can actually reason about — past this, the honest tool is a
59/// statistical one. Enforced BEFORE any solving so an oversized set costs nothing.
60pub const MAX_WIND_SCENARIOS: usize = 8;
61
62/// Most ranges one run will sample. Same rationale: a bounded, checkable table, not a
63/// swept surface.
64pub const MAX_CORRIDOR_RANGES: usize = 64;
65
66/// Slack (meters) allowed when deciding whether a miss is inside a target.
67///
68/// Boundary contact COUNTS AS A FIT — a hold that puts the worst-case impact exactly on the
69/// edge of the target is, by the geometry, still on the target. This epsilon exists only so
70/// that a case constructed to sit exactly on the boundary is not thrown off it by the last
71/// bit of a floating-point division; it is a nanometer, far below any physical meaning.
72pub const TARGET_FIT_EPSILON_M: f64 = 1.0e-9;
73
74/// Trajectory sample spacing used for every scenario, meters (~1 yard).
75///
76/// The requested ranges are read by linear interpolation between neighbouring samples;
77/// at this spacing that is far below the resolution any hold is read to.
78const SAMPLE_INTERVAL_M: f64 = 0.9144;
79
80/// One named wind hypothesis: a label plus the downrange segments that define it.
81#[derive(Debug, Clone, PartialEq)]
82pub struct NamedWindScenario {
83    /// Human label, carried through the whole report so provenance is never lost.
84    pub name: String,
85    /// Downrange wind segments, in the same representation
86    /// [`crate::wind::parse_wind_segment_str`] produces.
87    pub segments: Vec<WindSegment>,
88}
89
90/// A bounded, named set of wind hypotheses (MBA-1349).
91#[derive(Debug, Clone, PartialEq)]
92pub struct WindScenarioSetV1 {
93    /// Must equal [`WIND_SCENARIO_SET_VERSION`]; anything else is rejected rather than
94    /// best-effort parsed.
95    pub version: u32,
96    pub scenarios: Vec<NamedWindScenario>,
97    /// Which scenario is the user's own best guess, if they named one. Used only as the
98    /// comparison baseline for the minimax hold — it is never weighted, and never treated
99    /// as more likely.
100    pub nominal: Option<String>,
101}
102
103/// The target a corridor is judged against.
104#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
105#[serde(tag = "shape", rename_all = "snake_case")]
106pub enum TargetSpec {
107    /// A rectangle centered on the point of aim.
108    Rect { width_m: f64, height_m: f64 },
109    /// A circle centered on the point of aim.
110    Circle { diameter_m: f64 },
111}
112
113/// Which norm the minimax hold minimizes.
114///
115/// This is not cosmetic: the two target shapes have genuinely different optimal holds, and
116/// picking one silently would give a rectangle the circle's answer (or vice versa).
117#[derive(Debug, Clone, Copy, PartialEq, Eq)]
118pub enum CorridorMetric {
119    /// Per-axis (L-infinity). The two axes are independent for a rectangle, so the optimum
120    /// is the per-axis MIDPOINT OF THE EXTREMES on each axis separately, and the objective
121    /// it minimizes is `max over scenarios of max(|Δelevation|, |Δwindage|)`.
122    ///
123    /// This is also the default when no target was given: with no shape to judge against,
124    /// treating the axes independently is the assumption that adds the least.
125    Rectangular,
126    /// Euclidean (L2). For a circle the optimum is the center of the MINIMUM ENCLOSING
127    /// CIRCLE of the scenario holds, and the objective is the largest Euclidean distance
128    /// from the hold to any scenario — i.e. that circle's radius.
129    Circular,
130}
131
132impl TargetSpec {
133    /// The metric this shape implies.
134    pub fn metric(self) -> CorridorMetric {
135        match self {
136            TargetSpec::Rect { .. } => CorridorMetric::Rectangular,
137            TargetSpec::Circle { .. } => CorridorMetric::Circular,
138        }
139    }
140}
141
142/// The rifle, load and atmosphere every scenario is solved with. SI throughout.
143#[derive(Debug, Clone, PartialEq)]
144pub struct CorridorLoad {
145    pub muzzle_velocity_mps: f64,
146    pub bc: f64,
147    pub drag_model: DragModel,
148    pub mass_kg: f64,
149    pub diameter_m: f64,
150    pub bullet_length_m: f64,
151    pub zero_distance_m: f64,
152    pub sight_height_m: f64,
153    pub temperature_c: f64,
154    pub pressure_hpa: f64,
155    /// Relative humidity, percent (0 through 100).
156    pub humidity_pct: f64,
157    pub altitude_m: f64,
158}
159
160/// One robust-hold run.
161#[derive(Debug, Clone, PartialEq)]
162pub struct RobustHoldRequest {
163    pub scenarios: WindScenarioSetV1,
164    /// Ranges to report, meters. Order is preserved in the report; duplicates are rejected.
165    pub ranges_m: Vec<f64>,
166    pub target: Option<TargetSpec>,
167    pub load: CorridorLoad,
168}
169
170/// Why a robust-hold run was rejected. Typed, and every variant is raised BEFORE any
171/// trajectory is solved except [`Self::SolveFailed`].
172#[derive(Debug, Clone, PartialEq)]
173pub enum WindScenarioError {
174    /// The set declared a version this build does not implement.
175    UnsupportedVersion { version: u32, expected: u32 },
176    /// No scenarios at all.
177    NoScenarios,
178    /// More scenarios than [`MAX_WIND_SCENARIOS`].
179    TooManyScenarios { count: usize, max: usize },
180    /// A scenario carried an empty or whitespace-only name.
181    EmptyScenarioName { index: usize },
182    /// Two scenarios shared a name, so the report could not keep their provenance apart.
183    DuplicateScenarioName { name: String },
184    /// A scenario carried no wind segments.
185    NoSegments { name: String },
186    /// A segment token could not be parsed.
187    MalformedSegment {
188        scenario: String,
189        index: usize,
190        message: String,
191    },
192    /// A segment parsed but violated [`crate::wind::validate_wind_segments`].
193    InvalidSegment { scenario: String, message: String },
194    /// `nominal` named a scenario that is not in the set.
195    UnknownNominal { name: String, available: Vec<String> },
196    /// No ranges were requested.
197    NoRanges,
198    /// More ranges than [`MAX_CORRIDOR_RANGES`].
199    TooManyRanges { count: usize, max: usize },
200    /// A requested range was not finite and positive.
201    InvalidRange { value: f64 },
202    /// The same range was requested twice.
203    DuplicateRange { value: f64 },
204    /// A target dimension was not finite and positive.
205    InvalidTarget { message: String },
206    /// A load or atmosphere value was not usable.
207    InvalidLoad { field: &'static str, value: f64 },
208    /// The scenario set could not be decoded at all.
209    MalformedDocument { message: String },
210    /// A scenario's trajectory could not be solved, or did not reach a requested range.
211    SolveFailed { scenario: String, message: String },
212}
213
214impl fmt::Display for WindScenarioError {
215    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
216        match self {
217            WindScenarioError::UnsupportedVersion { version, expected } => write!(
218                f,
219                "unsupported wind scenario set version {version}; this build implements {expected}"
220            ),
221            WindScenarioError::NoScenarios => {
222                write!(f, "the scenario set contains no scenarios")
223            }
224            WindScenarioError::TooManyScenarios { count, max } => write!(
225                f,
226                "{count} scenarios exceeds the limit of {max}: a hold corridor is meant to \
227                 span a handful of concrete wind calls, not a swept distribution"
228            ),
229            WindScenarioError::EmptyScenarioName { index } => {
230                write!(f, "scenario #{index} has an empty name")
231            }
232            WindScenarioError::DuplicateScenarioName { name } => write!(
233                f,
234                "two scenarios are both named '{name}'; names carry provenance through the \
235                 whole report and must be unique"
236            ),
237            WindScenarioError::NoSegments { name } => {
238                write!(f, "scenario '{name}' has no wind segments")
239            }
240            WindScenarioError::MalformedSegment {
241                scenario,
242                index,
243                message,
244            } => write!(f, "scenario '{scenario}' segment #{index}: {message}"),
245            WindScenarioError::InvalidSegment { scenario, message } => {
246                write!(f, "scenario '{scenario}': {message}")
247            }
248            WindScenarioError::UnknownNominal { name, available } => write!(
249                f,
250                "nominal scenario '{name}' is not in the set (available: {})",
251                available.join(", ")
252            ),
253            WindScenarioError::NoRanges => write!(f, "no ranges were requested"),
254            WindScenarioError::TooManyRanges { count, max } => {
255                write!(f, "{count} ranges exceeds the limit of {max}")
256            }
257            WindScenarioError::InvalidRange { value } => write!(
258                f,
259                "range {value} must be finite and greater than zero"
260            ),
261            WindScenarioError::DuplicateRange { value } => {
262                write!(f, "range {value} was requested more than once")
263            }
264            WindScenarioError::InvalidTarget { message } => write!(f, "invalid target: {message}"),
265            WindScenarioError::InvalidLoad { field, value } => {
266                write!(f, "{field} ({value}) must be finite and greater than zero")
267            }
268            WindScenarioError::MalformedDocument { message } => {
269                write!(f, "malformed wind scenario set: {message}")
270            }
271            WindScenarioError::SolveFailed { scenario, message } => {
272                write!(f, "scenario '{scenario}' could not be solved: {message}")
273            }
274        }
275    }
276}
277
278impl Error for WindScenarioError {}
279
280/// One scenario's hold at one range.
281#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
282pub struct ScenarioHold {
283    pub name: String,
284    /// Milliradians the shooter holds UP.
285    pub elevation_mil: f64,
286    /// Milliradians the shooter holds RIGHT.
287    pub windage_mil: f64,
288}
289
290/// The corridor and chosen hold at one range.
291#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
292pub struct CorridorRow {
293    pub range_m: f64,
294    /// Every scenario, in sorted-by-name order.
295    pub scenarios: Vec<ScenarioHold>,
296    pub elevation_min_mil: f64,
297    pub elevation_max_mil: f64,
298    pub windage_min_mil: f64,
299    pub windage_max_mil: f64,
300    /// The hold that minimizes the worst case under the active metric.
301    pub minimax_elevation_mil: f64,
302    pub minimax_windage_mil: f64,
303    /// Worst-case deviation from the minimax hold to any scenario, under the active
304    /// metric: the larger per-axis half-span for [`CorridorMetric::Rectangular`], the
305    /// minimum-enclosing-circle radius for [`CorridorMetric::Circular`].
306    pub worst_case_miss_mil: f64,
307    /// Per-axis worst cases from the same hold, always reported: these are what the
308    /// rectangular fit check uses, and they stay meaningful under either metric.
309    pub worst_case_elevation_miss_mil: f64,
310    pub worst_case_windage_miss_mil: f64,
311    /// Which scenario is furthest from the minimax hold under the active metric. Ties
312    /// resolve to the first in sorted-by-name order.
313    pub worst_case_scenario: String,
314    /// The same worst-case measure computed from the DESIGNATED NOMINAL scenario's hold,
315    /// when one was named. Present so the report can show what the minimax buys.
316    #[serde(default, skip_serializing_if = "Option::is_none")]
317    pub nominal_worst_case_miss_mil: Option<f64>,
318    /// Whether the whole corridor is inside the target when held at the minimax hold.
319    /// `None` when no target was supplied. Boundary contact counts as a fit.
320    #[serde(default, skip_serializing_if = "Option::is_none")]
321    pub fits_target: Option<bool>,
322}
323
324/// A complete robust-hold report (MBA-1349).
325#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
326pub struct RobustHoldReportV1 {
327    /// Always [`ROBUST_HOLD_REPORT_VERSION`].
328    pub version: u32,
329    /// The requested ranges, meters, in the order they were asked for.
330    pub ranges_m: Vec<f64>,
331    /// Scenario names in the internal sorted order — the order every row's
332    /// [`CorridorRow::scenarios`] uses.
333    pub scenario_names: Vec<String>,
334    #[serde(default, skip_serializing_if = "Option::is_none")]
335    pub nominal: Option<String>,
336    #[serde(default, skip_serializing_if = "Option::is_none")]
337    pub target: Option<TargetSpec>,
338    /// `"rectangular"` or `"circular"` — which norm the minimax holds minimize.
339    pub metric: String,
340    pub rows: Vec<CorridorRow>,
341}
342
343/// Decode a wind scenario set from JSON TEXT (MBA-1349).
344///
345/// Text, not a path: this module stays fs-free so it compiles for wasm32, exactly like
346/// [`crate::drag_file`] and [`crate::truing_dsf`]. File reading belongs to the front end.
347///
348/// Segments are written as the SAME `SPEED:ANGLE:UNTIL[:VERTICAL]` tokens `--wind-segment`
349/// takes, so there is one wind grammar to learn and one parser to trust:
350///
351/// ```json
352/// {"version": 1, "nominal": "low",
353///  "scenarios": [
354///    {"name": "low",  "segments": ["5:90:400", "3:90:800"]},
355///    {"name": "high", "segments": ["12:90:400", "9:270:800"]}]}
356/// ```
357///
358/// `units` selects how SPEED and UNTIL are read (mph/yards imperial, m/s and meters
359/// metric), matching `--wind-segment` exactly.
360pub fn parse_wind_scenario_set(
361    text: &str,
362    units: UnitSystem,
363) -> Result<WindScenarioSetV1, WindScenarioError> {
364    #[derive(Deserialize)]
365    struct WireScenario {
366        name: String,
367        segments: Vec<String>,
368    }
369    #[derive(Deserialize)]
370    struct WireSet {
371        version: u32,
372        scenarios: Vec<WireScenario>,
373        #[serde(default)]
374        nominal: Option<String>,
375    }
376
377    let wire: WireSet =
378        serde_json::from_str(text).map_err(|e| WindScenarioError::MalformedDocument {
379            message: e.to_string(),
380        })?;
381    // Version FIRST: a future document may legitimately carry shapes this build does not
382    // know, and must get the stable version error rather than a confusing field error.
383    if wire.version != WIND_SCENARIO_SET_VERSION {
384        return Err(WindScenarioError::UnsupportedVersion {
385            version: wire.version,
386            expected: WIND_SCENARIO_SET_VERSION,
387        });
388    }
389    // The cap is checked before any segment is parsed, so an oversized document costs one
390    // JSON decode and nothing more.
391    if wire.scenarios.len() > MAX_WIND_SCENARIOS {
392        return Err(WindScenarioError::TooManyScenarios {
393            count: wire.scenarios.len(),
394            max: MAX_WIND_SCENARIOS,
395        });
396    }
397
398    let imperial = matches!(units, UnitSystem::Imperial);
399    let mut scenarios = Vec::with_capacity(wire.scenarios.len());
400    for (index, scenario) in wire.scenarios.into_iter().enumerate() {
401        if scenario.name.trim().is_empty() {
402            return Err(WindScenarioError::EmptyScenarioName { index });
403        }
404        let mut segments = Vec::with_capacity(scenario.segments.len());
405        for (segment_index, token) in scenario.segments.iter().enumerate() {
406            let segment = crate::wind::parse_wind_segment_str(token, imperial).map_err(|e| {
407                WindScenarioError::MalformedSegment {
408                    scenario: scenario.name.clone(),
409                    index: segment_index,
410                    message: e,
411                }
412            })?;
413            segments.push(segment);
414        }
415        scenarios.push(NamedWindScenario {
416            name: scenario.name,
417            segments,
418        });
419    }
420
421    Ok(WindScenarioSetV1 {
422        version: WIND_SCENARIO_SET_VERSION,
423        scenarios,
424        nominal: wire.nominal,
425    })
426}
427
428/// Parse a `rect:WxH` or `circle:D` target specification.
429///
430/// Dimensions are inches (imperial) or centimeters (metric), matching `mpbr --vital-zone`.
431pub fn parse_target_spec(spec: &str, units: UnitSystem) -> Result<TargetSpec, WindScenarioError> {
432    let invalid = |message: String| WindScenarioError::InvalidTarget { message };
433    let to_m = |value: f64| match units {
434        UnitSystem::Imperial => value * 0.0254,
435        UnitSystem::Metric => value * 0.01,
436    };
437    let (shape, rest) = spec.split_once(':').ok_or_else(|| {
438        invalid(format!(
439            "'{spec}': expected rect:WIDTHxHEIGHT or circle:DIAMETER"
440        ))
441    })?;
442    let positive = |token: &str, what: &str| -> Result<f64, WindScenarioError> {
443        let value: f64 = token
444            .trim()
445            .parse()
446            .map_err(|_| invalid(format!("'{spec}': {what} '{token}' is not a number")))?;
447        if !value.is_finite() || value <= 0.0 {
448            return Err(invalid(format!(
449                "'{spec}': {what} must be finite and greater than zero"
450            )));
451        }
452        Ok(value)
453    };
454    match shape.trim().to_lowercase().as_str() {
455        "rect" | "rectangle" => {
456            let (w, h) = rest
457                .split_once(['x', 'X'])
458                .ok_or_else(|| invalid(format!("'{spec}': expected rect:WIDTHxHEIGHT")))?;
459            Ok(TargetSpec::Rect {
460                width_m: to_m(positive(w, "width")?),
461                height_m: to_m(positive(h, "height")?),
462            })
463        }
464        "circle" | "circ" => Ok(TargetSpec::Circle {
465            diameter_m: to_m(positive(rest, "diameter")?),
466        }),
467        other => Err(invalid(format!(
468            "'{spec}': unknown shape '{other}' (expected rect or circle)"
469        ))),
470    }
471}
472
473impl RobustHoldRequest {
474    /// Validate everything cheap BEFORE any trajectory work begins.
475    ///
476    /// That ordering is the point of the ticket's cap criterion: an oversized or malformed
477    /// request must cost a validation pass, never eight full solves.
478    pub fn validate(&self) -> Result<(), WindScenarioError> {
479        if self.scenarios.version != WIND_SCENARIO_SET_VERSION {
480            return Err(WindScenarioError::UnsupportedVersion {
481                version: self.scenarios.version,
482                expected: WIND_SCENARIO_SET_VERSION,
483            });
484        }
485        if self.scenarios.scenarios.is_empty() {
486            return Err(WindScenarioError::NoScenarios);
487        }
488        if self.scenarios.scenarios.len() > MAX_WIND_SCENARIOS {
489            return Err(WindScenarioError::TooManyScenarios {
490                count: self.scenarios.scenarios.len(),
491                max: MAX_WIND_SCENARIOS,
492            });
493        }
494        let mut seen: Vec<&str> = Vec::with_capacity(self.scenarios.scenarios.len());
495        for (index, scenario) in self.scenarios.scenarios.iter().enumerate() {
496            if scenario.name.trim().is_empty() {
497                return Err(WindScenarioError::EmptyScenarioName { index });
498            }
499            if seen.contains(&scenario.name.as_str()) {
500                return Err(WindScenarioError::DuplicateScenarioName {
501                    name: scenario.name.clone(),
502                });
503            }
504            seen.push(&scenario.name);
505            if scenario.segments.is_empty() {
506                return Err(WindScenarioError::NoSegments {
507                    name: scenario.name.clone(),
508                });
509            }
510            validate_wind_segments(&scenario.segments).map_err(|e| {
511                WindScenarioError::InvalidSegment {
512                    scenario: scenario.name.clone(),
513                    message: format!(
514                        "segment #{} is invalid ({:?} violates {:?})",
515                        e.index, e.field, e.rule
516                    ),
517                }
518            })?;
519        }
520        if let Some(nominal) = self.scenarios.nominal.as_deref() {
521            if !self
522                .scenarios
523                .scenarios
524                .iter()
525                .any(|scenario| scenario.name == nominal)
526            {
527                return Err(WindScenarioError::UnknownNominal {
528                    name: nominal.to_string(),
529                    available: self
530                        .scenarios
531                        .scenarios
532                        .iter()
533                        .map(|scenario| scenario.name.clone())
534                        .collect(),
535                });
536            }
537        }
538
539        if self.ranges_m.is_empty() {
540            return Err(WindScenarioError::NoRanges);
541        }
542        if self.ranges_m.len() > MAX_CORRIDOR_RANGES {
543            return Err(WindScenarioError::TooManyRanges {
544                count: self.ranges_m.len(),
545                max: MAX_CORRIDOR_RANGES,
546            });
547        }
548        let mut seen_ranges: Vec<f64> = Vec::with_capacity(self.ranges_m.len());
549        for &range in &self.ranges_m {
550            if !range.is_finite() || range <= 0.0 {
551                return Err(WindScenarioError::InvalidRange { value: range });
552            }
553            if seen_ranges.contains(&range) {
554                return Err(WindScenarioError::DuplicateRange { value: range });
555            }
556            seen_ranges.push(range);
557        }
558
559        if let Some(target) = self.target {
560            let bad = |message: &str| WindScenarioError::InvalidTarget {
561                message: message.to_string(),
562            };
563            match target {
564                TargetSpec::Rect { width_m, height_m } => {
565                    if !width_m.is_finite() || width_m <= 0.0 {
566                        return Err(bad("width must be finite and greater than zero"));
567                    }
568                    if !height_m.is_finite() || height_m <= 0.0 {
569                        return Err(bad("height must be finite and greater than zero"));
570                    }
571                }
572                TargetSpec::Circle { diameter_m } => {
573                    if !diameter_m.is_finite() || diameter_m <= 0.0 {
574                        return Err(bad("diameter must be finite and greater than zero"));
575                    }
576                }
577            }
578        }
579
580        let load = &self.load;
581        for (field, value) in [
582            ("muzzle velocity", load.muzzle_velocity_mps),
583            ("ballistic coefficient", load.bc),
584            ("bullet mass", load.mass_kg),
585            ("bullet diameter", load.diameter_m),
586            ("bullet length", load.bullet_length_m),
587            ("zero distance", load.zero_distance_m),
588        ] {
589            if !value.is_finite() || value <= 0.0 {
590                return Err(WindScenarioError::InvalidLoad { field, value });
591            }
592        }
593        for (field, value) in [
594            ("sight height", load.sight_height_m),
595            ("temperature", load.temperature_c),
596            ("pressure", load.pressure_hpa),
597            ("humidity", load.humidity_pct),
598            ("altitude", load.altitude_m),
599        ] {
600            if !value.is_finite() {
601                return Err(WindScenarioError::InvalidLoad { field, value });
602            }
603        }
604        Ok(())
605    }
606}
607
608/// Solve one robust-hold run (MBA-1349).
609///
610/// Every scenario gets exactly ONE trajectory solve through the existing segmented-wind
611/// machinery, sampled at roughly one-yard intervals and read at each requested range by
612/// linear interpolation. The muzzle angle is solved once in calm air (see the module header) and
613/// reused, so the corridor reflects only the wind hypotheses.
614pub fn solve_robust_hold(
615    request: &RobustHoldRequest,
616) -> Result<RobustHoldReportV1, WindScenarioError> {
617    request.validate()?;
618
619    // Determinism: sort by name once, up front. Everything downstream — row order, the
620    // corridor extremes, the worst-case scenario tie-break — is then over a fixed order,
621    // so the caller's ordering cannot influence any output.
622    let mut scenarios = request.scenarios.scenarios.clone();
623    scenarios.sort_by(|a, b| a.name.cmp(&b.name));
624    let scenario_names: Vec<String> = scenarios
625        .iter()
626        .map(|scenario| scenario.name.clone())
627        .collect();
628
629    let load = &request.load;
630    let atmosphere = AtmosphericConditions {
631        temperature: load.temperature_c,
632        pressure: load.pressure_hpa,
633        humidity: load.humidity_pct,
634        altitude: load.altitude_m,
635    };
636    let base_inputs = BallisticInputs {
637        bc_value: load.bc,
638        bc_type: load.drag_model,
639        bullet_mass: load.mass_kg,
640        muzzle_velocity: load.muzzle_velocity_mps,
641        bullet_diameter: load.diameter_m,
642        bullet_length: load.bullet_length_m,
643        sight_height: load.sight_height_m,
644        use_rk4: true,
645        ..Default::default()
646    };
647    let zero_angle = crate::cli_api::calculate_zero_angle_with_conditions(
648        base_inputs.clone(),
649        load.zero_distance_m,
650        load.sight_height_m,
651        WindConditions::default(),
652        atmosphere.clone(),
653    )
654    .map_err(|e| WindScenarioError::SolveFailed {
655        scenario: "<zero>".to_string(),
656        message: e.to_string(),
657    })?;
658
659    let furthest_m = request
660        .ranges_m
661        .iter()
662        .fold(0.0_f64, |acc, &range| acc.max(range));
663    let max_range_m = furthest_m * 1.02;
664
665    // Per scenario: one solve, then the hold at each requested range.
666    let mut per_scenario: Vec<Vec<(f64, f64)>> = Vec::with_capacity(scenarios.len());
667    for scenario in &scenarios {
668        let mut inputs = base_inputs.clone();
669        inputs.muzzle_angle = zero_angle;
670        inputs.enable_trajectory_sampling = true;
671        inputs.sample_interval = SAMPLE_INTERVAL_M;
672
673        let mut solver =
674            TrajectorySolver::new(inputs, WindConditions::default(), atmosphere.clone());
675        solver.set_max_range(max_range_m);
676        solver.set_time_step(0.001);
677        solver.set_wind_segments(scenario.segments.clone());
678        let result = solver.solve().map_err(|e| WindScenarioError::SolveFailed {
679            scenario: scenario.name.clone(),
680            message: e.to_string(),
681        })?;
682        let samples = result.sampled_points.unwrap_or_default();
683        if samples.len() < 2 {
684            return Err(WindScenarioError::SolveFailed {
685                scenario: scenario.name.clone(),
686                message: "the trajectory produced too few sampled points".to_string(),
687            });
688        }
689        let mut holds = Vec::with_capacity(request.ranges_m.len());
690        for &range_m in &request.ranges_m {
691            let hold = interpolate_hold_mil(&samples, range_m).ok_or_else(|| {
692                WindScenarioError::SolveFailed {
693                    scenario: scenario.name.clone(),
694                    message: format!(
695                        "the trajectory does not reach {range_m:.1} m (it was sampled to {:.1} m)",
696                        samples.last().map_or(0.0, |s| s.distance_m)
697                    ),
698                }
699            })?;
700            holds.push(hold);
701        }
702        per_scenario.push(holds);
703    }
704
705    let metric = request
706        .target
707        .map_or(CorridorMetric::Rectangular, TargetSpec::metric);
708    let nominal_index = request
709        .scenarios
710        .nominal
711        .as_deref()
712        .and_then(|name| scenarios.iter().position(|s| s.name == name));
713
714    let mut rows = Vec::with_capacity(request.ranges_m.len());
715    for (range_index, &range_m) in request.ranges_m.iter().enumerate() {
716        let points: Vec<(f64, f64)> = per_scenario
717            .iter()
718            .map(|holds| holds[range_index])
719            .collect();
720
721        let (elevation_min_mil, elevation_max_mil) = span(points.iter().map(|p| p.0));
722        let (windage_min_mil, windage_max_mil) = span(points.iter().map(|p| p.1));
723
724        let (minimax_elevation_mil, minimax_windage_mil) = match metric {
725            CorridorMetric::Rectangular => (
726                0.5 * (elevation_min_mil + elevation_max_mil),
727                0.5 * (windage_min_mil + windage_max_mil),
728            ),
729            CorridorMetric::Circular => minimum_enclosing_circle_center(&points),
730        };
731        let hold = (minimax_elevation_mil, minimax_windage_mil);
732
733        let worst_case_miss_mil = worst_case(&points, hold, metric);
734        let worst_case_elevation_miss_mil = points
735            .iter()
736            .fold(0.0_f64, |acc, p| acc.max((p.0 - hold.0).abs()));
737        let worst_case_windage_miss_mil = points
738            .iter()
739            .fold(0.0_f64, |acc, p| acc.max((p.1 - hold.1).abs()));
740        // First in sorted-by-name order among ties.
741        let worst_index = points
742            .iter()
743            .enumerate()
744            .fold((0usize, f64::NEG_INFINITY), |(best, best_d), (i, p)| {
745                let d = deviation(*p, hold, metric);
746                if d > best_d {
747                    (i, d)
748                } else {
749                    (best, best_d)
750                }
751            })
752            .0;
753
754        let nominal_worst_case_miss_mil =
755            nominal_index.map(|index| worst_case(&points, points[index], metric));
756
757        let fits_target = request.target.map(|target| {
758            let to_linear_m = |mil: f64| mil / 1000.0 * range_m;
759            match target {
760                TargetSpec::Rect { width_m, height_m } => {
761                    to_linear_m(worst_case_elevation_miss_mil)
762                        <= height_m / 2.0 + TARGET_FIT_EPSILON_M
763                        && to_linear_m(worst_case_windage_miss_mil)
764                            <= width_m / 2.0 + TARGET_FIT_EPSILON_M
765                }
766                TargetSpec::Circle { diameter_m } => {
767                    to_linear_m(worst_case_miss_mil) <= diameter_m / 2.0 + TARGET_FIT_EPSILON_M
768                }
769            }
770        });
771
772        rows.push(CorridorRow {
773            range_m,
774            scenarios: scenario_names
775                .iter()
776                .zip(&points)
777                .map(|(name, point)| ScenarioHold {
778                    name: name.clone(),
779                    elevation_mil: point.0,
780                    windage_mil: point.1,
781                })
782                .collect(),
783            elevation_min_mil,
784            elevation_max_mil,
785            windage_min_mil,
786            windage_max_mil,
787            minimax_elevation_mil,
788            minimax_windage_mil,
789            worst_case_miss_mil,
790            worst_case_elevation_miss_mil,
791            worst_case_windage_miss_mil,
792            worst_case_scenario: scenario_names[worst_index].clone(),
793            nominal_worst_case_miss_mil,
794            fits_target,
795        });
796    }
797
798    Ok(RobustHoldReportV1 {
799        version: ROBUST_HOLD_REPORT_VERSION,
800        ranges_m: request.ranges_m.clone(),
801        scenario_names,
802        nominal: request.scenarios.nominal.clone(),
803        target: request.target,
804        metric: match metric {
805            CorridorMetric::Rectangular => "rectangular".to_string(),
806            CorridorMetric::Circular => "circular".to_string(),
807        },
808        rows,
809    })
810}
811
812/// Linearly interpolate `(elevation_mil, windage_mil)` at `range_m`.
813///
814/// `TrajectorySample::drop_m` is positive BELOW the line of sight and `wind_drift_m`
815/// positive to the RIGHT, so both map straight onto the hold convention. Milliradians use
816/// the small-angle definition (1 mil subtends 1/1000 of the range) — the same conversion
817/// every other 0.31.0 surface uses.
818fn interpolate_hold_mil(
819    samples: &[crate::trajectory_sampling::TrajectorySample],
820    range_m: f64,
821) -> Option<(f64, f64)> {
822    if !range_m.is_finite() || range_m <= 0.0 {
823        return None;
824    }
825    let first = samples.first()?;
826    let last = samples.last()?;
827    if range_m < first.distance_m || range_m > last.distance_m {
828        return None;
829    }
830    let index = samples
831        .partition_point(|s| s.distance_m < range_m)
832        .min(samples.len() - 1);
833    let (lo, hi) = if index == 0 {
834        (&samples[0], &samples[0])
835    } else {
836        (&samples[index - 1], &samples[index])
837    };
838    let width = hi.distance_m - lo.distance_m;
839    let t = if width.abs() < f64::EPSILON {
840        0.0
841    } else {
842        (range_m - lo.distance_m) / width
843    };
844    let lerp = |a: f64, b: f64| a + (b - a) * t;
845    Some((
846        lerp(lo.drop_m, hi.drop_m) / range_m * 1000.0,
847        lerp(lo.wind_drift_m, 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.push_str(
1026                    "Target:         none — the minimax hold uses the per-axis metric\n",
1027                ),
1028            }
1029            out.push_str(
1030                "\nHolds are milliradians: elevation positive = hold UP, windage positive = \
1031                 hold RIGHT.\nThe corridor is the span of the scenarios you supplied. It is \
1032                 NOT a probability interval.\n\n",
1033            );
1034
1035            for row in &report.rows {
1036                out.push_str(&format!(
1037                    "--- {:.0} {} ---\n",
1038                    range_display(row.range_m),
1039                    dist_unit
1040                ));
1041                out.push_str("  Scenario              Elev(mil)  Wind(mil)\n");
1042                for hold in &row.scenarios {
1043                    out.push_str(&format!(
1044                        "  {:<20}  {:>9.3}  {:>9.3}\n",
1045                        hold.name, hold.elevation_mil, hold.windage_mil
1046                    ));
1047                }
1048                out.push_str(&format!(
1049                    "  Corridor              {:>9.3}  {:>9.3}   (min)\n",
1050                    row.elevation_min_mil, row.windage_min_mil
1051                ));
1052                out.push_str(&format!(
1053                    "                        {:>9.3}  {:>9.3}   (max)\n",
1054                    row.elevation_max_mil, row.windage_max_mil
1055                ));
1056                out.push_str(&format!(
1057                    "  Minimax hold          {:>9.3}  {:>9.3}\n",
1058                    row.minimax_elevation_mil, row.minimax_windage_mil
1059                ));
1060                out.push_str(&format!(
1061                    "  Worst case from it    {:>9.3} mil ({:.2} {}), scenario '{}'\n",
1062                    row.worst_case_miss_mil,
1063                    size_display(row.worst_case_miss_mil / 1000.0 * row.range_m),
1064                    size_unit,
1065                    row.worst_case_scenario
1066                ));
1067                if let Some(nominal_worst) = row.nominal_worst_case_miss_mil {
1068                    out.push_str(&format!(
1069                        "  Holding the nominal   {:>9.3} mil ({:.2} {})\n",
1070                        nominal_worst,
1071                        size_display(nominal_worst / 1000.0 * row.range_m),
1072                        size_unit
1073                    ));
1074                }
1075                if let Some(fits) = row.fits_target {
1076                    out.push_str(&format!(
1077                        "  Fits target           {}\n",
1078                        if fits {
1079                            "yes — one hold covers every scenario"
1080                        } else {
1081                            "NO — no single hold covers every scenario here"
1082                        }
1083                    ));
1084                }
1085                out.push('\n');
1086            }
1087            out
1088        }
1089    }
1090}
1091
1092#[cfg(test)]
1093mod tests {
1094    use super::*;
1095
1096    fn segments(tokens: &[&str]) -> Vec<WindSegment> {
1097        tokens
1098            .iter()
1099            .map(|token| crate::wind::parse_wind_segment_str(token, true).unwrap())
1100            .collect()
1101    }
1102
1103    fn scenario(name: &str, tokens: &[&str]) -> NamedWindScenario {
1104        NamedWindScenario {
1105            name: name.to_string(),
1106            segments: segments(tokens),
1107        }
1108    }
1109
1110    pub(super) fn test_load() -> CorridorLoad {
1111        CorridorLoad {
1112            muzzle_velocity_mps: 823.0,
1113            bc: 0.475,
1114            drag_model: DragModel::G1,
1115            mass_kg: 0.010_886,
1116            diameter_m: 0.007_823,
1117            bullet_length_m: 0.031,
1118            zero_distance_m: 91.44,
1119            sight_height_m: 0.0508,
1120            temperature_c: 15.0,
1121            pressure_hpa: 1013.25,
1122            humidity_pct: 50.0,
1123            altitude_m: 0.0,
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!(
1472            r#"{{"version":1,"scenarios":[{}]}}"#,
1473            scenarios.join(",")
1474        );
1475        assert_eq!(
1476            parse_wind_scenario_set(&doc, UnitSystem::Imperial).unwrap_err(),
1477            WindScenarioError::TooManyScenarios { count: 9, max: 8 }
1478        );
1479
1480        let err = parse_wind_scenario_set(
1481            r#"{"version":1,"scenarios":[{"name":"a","segments":["nope"]}]}"#,
1482            UnitSystem::Imperial,
1483        )
1484        .unwrap_err();
1485        assert!(matches!(err, WindScenarioError::MalformedSegment { .. }), "{err}");
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 ["rect", "rect:12", "circle:-1", "blob:3", "rect:0x5", "circle:abc"] {
1527            assert!(
1528                parse_target_spec(bad, UnitSystem::Imperial).is_err(),
1529                "'{bad}' should be rejected"
1530            );
1531        }
1532    }
1533
1534    #[test]
1535    fn minimum_enclosing_circle_is_exact_and_order_independent() {
1536        // Three points on a unit circle: the enclosing circle is that circle.
1537        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)];
1538        let center = minimum_enclosing_circle_center(&points);
1539        assert!(center.0.abs() < 1e-9 && center.1.abs() < 1e-9, "{center:?}");
1540
1541        // Two points: the circle on their diameter, not the bounding box's center.
1542        let pair = vec![(0.0, 0.0), (2.0, 4.0)];
1543        assert_eq!(minimum_enclosing_circle_center(&pair), (1.0, 2.0));
1544
1545        // A point inside three others does not move the answer, in any order. Compared
1546        // with a tolerance, not bit-for-bit: the candidate enumeration order changes with
1547        // the input order, so the winning circle can differ in its last ULP. The
1548        // ORDER-INDEPENDENCE the ticket requires is guaranteed one level up, by
1549        // `solve_robust_hold` sorting scenarios by name before this is ever called (see
1550        // `reordering_scenarios_changes_nothing`, which asserts exact equality of the
1551        // whole report).
1552        let mut with_interior = points.clone();
1553        with_interior.push((0.1, -0.05));
1554        let a = minimum_enclosing_circle_center(&with_interior);
1555        with_interior.reverse();
1556        let b = minimum_enclosing_circle_center(&with_interior);
1557        assert!(
1558            (a.0 - b.0).abs() < 1e-12 && (a.1 - b.1).abs() < 1e-12,
1559            "{a:?} vs {b:?}"
1560        );
1561
1562        assert_eq!(minimum_enclosing_circle_center(&[(3.0, 4.0)]), (3.0, 4.0));
1563        assert_eq!(minimum_enclosing_circle_center(&[]), (0.0, 0.0));
1564    }
1565
1566    #[test]
1567    fn formatter_renders_both_shapes_and_json_is_the_wire_schema() {
1568        let mut req = request(
1569            vec![
1570                scenario("low", &["4:90:1000"]),
1571                scenario("high", &["14:90:1000"]),
1572            ],
1573            Some("low"),
1574        );
1575        req.target = Some(TargetSpec::Rect {
1576            width_m: 0.3,
1577            height_m: 0.5,
1578        });
1579        let report = solve_robust_hold(&req).unwrap();
1580
1581        // `-o json` IS the versioned wire schema: it deserializes straight back into the
1582        // report type, field for field. Values are compared with a tolerance rather than
1583        // bit-for-bit because serde_json's default float parser is accurate to within an
1584        // ULP, not exactly round-tripping (that needs its `float_roundtrip` feature) —
1585        // which is a property of the JSON reader, not of this schema.
1586        let json = format_robust_hold_report(&report, RobustHoldFormat::Json, UnitSystem::Imperial);
1587        let back: RobustHoldReportV1 = serde_json::from_str(&json).unwrap();
1588        assert_eq!(back.version, report.version);
1589        assert_eq!(back.scenario_names, report.scenario_names);
1590        assert_eq!(back.nominal, report.nominal);
1591        assert_eq!(back.metric, report.metric);
1592        assert_eq!(back.rows.len(), report.rows.len());
1593        for (got, want) in back.rows.iter().zip(&report.rows) {
1594            assert_eq!(got.worst_case_scenario, want.worst_case_scenario);
1595            assert_eq!(got.fits_target, want.fits_target);
1596            assert_eq!(got.scenarios.len(), want.scenarios.len());
1597            for (a, b) in got.scenarios.iter().zip(&want.scenarios) {
1598                assert_eq!(a.name, b.name);
1599                assert!((a.elevation_mil - b.elevation_mil).abs() < 1e-12);
1600                assert!((a.windage_mil - b.windage_mil).abs() < 1e-12);
1601            }
1602            for (a, b) in [
1603                (got.range_m, want.range_m),
1604                (got.elevation_min_mil, want.elevation_min_mil),
1605                (got.elevation_max_mil, want.elevation_max_mil),
1606                (got.windage_min_mil, want.windage_min_mil),
1607                (got.windage_max_mil, want.windage_max_mil),
1608                (got.minimax_elevation_mil, want.minimax_elevation_mil),
1609                (got.minimax_windage_mil, want.minimax_windage_mil),
1610                (got.worst_case_miss_mil, want.worst_case_miss_mil),
1611            ] {
1612                assert!((a - b).abs() < 1e-12, "{a} vs {b}");
1613            }
1614        }
1615
1616        let table =
1617            format_robust_hold_report(&report, RobustHoldFormat::Table, UnitSystem::Imperial);
1618        assert!(table.contains("Robust Hold Corridor"));
1619        assert!(table.contains("Minimax hold"));
1620        assert!(table.contains("Holding the nominal"));
1621        assert!(table.contains("Fits target"));
1622        assert!(
1623            table.contains("NOT a probability interval"),
1624            "the non-goal must be stated where it is read: {table}"
1625        );
1626        assert!(table.ends_with('\n'));
1627    }
1628}