Skip to main content

ballistics_engine/
wind_scenarios.rs

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