1use 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
50pub const WIND_SCENARIO_SET_VERSION: u32 = 1;
52
53pub const ROBUST_HOLD_REPORT_VERSION: u32 = 1;
55
56pub const MAX_WIND_SCENARIOS: usize = 8;
62
63pub const MAX_CORRIDOR_RANGES: usize = 64;
66
67pub const TARGET_FIT_EPSILON_M: f64 = 1.0e-9;
74
75const SAMPLE_INTERVAL_M: f64 = 0.9144;
80
81#[derive(Debug, Clone, PartialEq)]
83pub struct NamedWindScenario {
84 pub name: String,
86 pub segments: Vec<WindSegment>,
89}
90
91#[derive(Debug, Clone, PartialEq)]
93pub struct WindScenarioSetV1 {
94 pub version: u32,
97 pub scenarios: Vec<NamedWindScenario>,
98 pub nominal: Option<String>,
102}
103
104#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
106#[serde(tag = "shape", rename_all = "snake_case")]
107pub enum TargetSpec {
108 Rect { width_m: f64, height_m: f64 },
110 Circle { diameter_m: f64 },
112}
113
114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub enum CorridorMetric {
120 Rectangular,
127 Circular,
131}
132
133impl TargetSpec {
134 pub fn metric(self) -> CorridorMetric {
136 match self {
137 TargetSpec::Rect { .. } => CorridorMetric::Rectangular,
138 TargetSpec::Circle { .. } => CorridorMetric::Circular,
139 }
140 }
141}
142
143#[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 pub humidity_pct: f64,
158 pub altitude_m: f64,
159}
160
161#[derive(Debug, Clone, PartialEq)]
163pub struct RobustHoldRequest {
164 pub scenarios: WindScenarioSetV1,
165 pub ranges_m: Vec<f64>,
167 pub target: Option<TargetSpec>,
168 pub load: CorridorLoad,
169}
170
171#[derive(Debug, Clone, PartialEq)]
174pub enum WindScenarioError {
175 UnsupportedVersion { version: u32, expected: u32 },
177 NoScenarios,
179 TooManyScenarios { count: usize, max: usize },
181 EmptyScenarioName { index: usize },
183 DuplicateScenarioName { name: String },
185 NoSegments { name: String },
187 MalformedSegment {
189 scenario: String,
190 index: usize,
191 message: String,
192 },
193 InvalidSegment { scenario: String, message: String },
195 UnknownNominal { name: String, available: Vec<String> },
197 NoRanges,
199 TooManyRanges { count: usize, max: usize },
201 InvalidRange { value: f64 },
203 DuplicateRange { value: f64 },
205 InvalidTarget { message: String },
207 InvalidLoad { field: &'static str, value: f64 },
209 MalformedDocument { message: String },
211 SolveFailed { scenario: String, message: String },
213}
214
215impl fmt::Display for WindScenarioError {
216 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
217 match self {
218 WindScenarioError::UnsupportedVersion { version, expected } => write!(
219 f,
220 "unsupported wind scenario set version {version}; this build implements {expected}"
221 ),
222 WindScenarioError::NoScenarios => {
223 write!(f, "the scenario set contains no scenarios")
224 }
225 WindScenarioError::TooManyScenarios { count, max } => write!(
226 f,
227 "{count} scenarios exceeds the limit of {max}: a hold corridor is meant to \
228 span a handful of concrete wind calls, not a swept distribution"
229 ),
230 WindScenarioError::EmptyScenarioName { index } => {
231 write!(f, "scenario #{index} has an empty name")
232 }
233 WindScenarioError::DuplicateScenarioName { name } => write!(
234 f,
235 "two scenarios are both named '{name}'; names carry provenance through the \
236 whole report and must be unique"
237 ),
238 WindScenarioError::NoSegments { name } => {
239 write!(f, "scenario '{name}' has no wind segments")
240 }
241 WindScenarioError::MalformedSegment {
242 scenario,
243 index,
244 message,
245 } => write!(f, "scenario '{scenario}' segment #{index}: {message}"),
246 WindScenarioError::InvalidSegment { scenario, message } => {
247 write!(f, "scenario '{scenario}': {message}")
248 }
249 WindScenarioError::UnknownNominal { name, available } => write!(
250 f,
251 "nominal scenario '{name}' is not in the set (available: {})",
252 available.join(", ")
253 ),
254 WindScenarioError::NoRanges => write!(f, "no ranges were requested"),
255 WindScenarioError::TooManyRanges { count, max } => {
256 write!(f, "{count} ranges exceeds the limit of {max}")
257 }
258 WindScenarioError::InvalidRange { value } => write!(
259 f,
260 "range {value} must be finite and greater than zero"
261 ),
262 WindScenarioError::DuplicateRange { value } => {
263 write!(f, "range {value} was requested more than once")
264 }
265 WindScenarioError::InvalidTarget { message } => write!(f, "invalid target: {message}"),
266 WindScenarioError::InvalidLoad { field, value } => {
267 write!(f, "{field} ({value}) must be finite and greater than zero")
268 }
269 WindScenarioError::MalformedDocument { message } => {
270 write!(f, "malformed wind scenario set: {message}")
271 }
272 WindScenarioError::SolveFailed { scenario, message } => {
273 write!(f, "scenario '{scenario}' could not be solved: {message}")
274 }
275 }
276 }
277}
278
279impl Error for WindScenarioError {}
280
281#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
283pub struct ScenarioHold {
284 pub name: String,
285 pub elevation_mil: f64,
287 pub windage_mil: f64,
289}
290
291#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
293pub struct CorridorRow {
294 pub range_m: f64,
295 pub scenarios: Vec<ScenarioHold>,
297 pub elevation_min_mil: f64,
298 pub elevation_max_mil: f64,
299 pub windage_min_mil: f64,
300 pub windage_max_mil: f64,
301 pub minimax_elevation_mil: f64,
303 pub minimax_windage_mil: f64,
304 pub worst_case_miss_mil: f64,
308 pub worst_case_elevation_miss_mil: f64,
311 pub worst_case_windage_miss_mil: f64,
312 pub worst_case_scenario: String,
315 #[serde(default, skip_serializing_if = "Option::is_none")]
318 pub nominal_worst_case_miss_mil: Option<f64>,
319 #[serde(default, skip_serializing_if = "Option::is_none")]
322 pub fits_target: Option<bool>,
323}
324
325#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
327pub struct RobustHoldReportV1 {
328 pub version: u32,
330 pub ranges_m: Vec<f64>,
332 pub scenario_names: Vec<String>,
335 #[serde(default, skip_serializing_if = "Option::is_none")]
336 pub nominal: Option<String>,
337 #[serde(default, skip_serializing_if = "Option::is_none")]
338 pub target: Option<TargetSpec>,
339 pub metric: String,
341 pub rows: Vec<CorridorRow>,
342}
343
344pub fn parse_wind_scenario_set(
362 text: &str,
363 units: UnitSystem,
364) -> Result<WindScenarioSetV1, WindScenarioError> {
365 #[derive(Deserialize)]
366 struct WireScenario {
367 name: String,
368 segments: Vec<String>,
369 }
370 #[derive(Deserialize)]
371 struct WireSet {
372 version: u32,
373 scenarios: Vec<WireScenario>,
374 #[serde(default)]
375 nominal: Option<String>,
376 }
377
378 let wire: WireSet =
379 serde_json::from_str(text).map_err(|e| WindScenarioError::MalformedDocument {
380 message: e.to_string(),
381 })?;
382 if wire.version != WIND_SCENARIO_SET_VERSION {
385 return Err(WindScenarioError::UnsupportedVersion {
386 version: wire.version,
387 expected: WIND_SCENARIO_SET_VERSION,
388 });
389 }
390 if wire.scenarios.len() > MAX_WIND_SCENARIOS {
393 return Err(WindScenarioError::TooManyScenarios {
394 count: wire.scenarios.len(),
395 max: MAX_WIND_SCENARIOS,
396 });
397 }
398
399 let imperial = matches!(units, UnitSystem::Imperial);
400 let mut scenarios = Vec::with_capacity(wire.scenarios.len());
401 for (index, scenario) in wire.scenarios.into_iter().enumerate() {
402 if scenario.name.trim().is_empty() {
403 return Err(WindScenarioError::EmptyScenarioName { index });
404 }
405 let mut segments = Vec::with_capacity(scenario.segments.len());
406 for (segment_index, token) in scenario.segments.iter().enumerate() {
407 let segment = crate::wind::parse_wind_segment_str(token, imperial).map_err(|e| {
408 WindScenarioError::MalformedSegment {
409 scenario: scenario.name.clone(),
410 index: segment_index,
411 message: e,
412 }
413 })?;
414 segments.push(segment);
415 }
416 scenarios.push(NamedWindScenario {
417 name: scenario.name,
418 segments,
419 });
420 }
421
422 Ok(WindScenarioSetV1 {
423 version: WIND_SCENARIO_SET_VERSION,
424 scenarios,
425 nominal: wire.nominal,
426 })
427}
428
429pub fn parse_target_spec(spec: &str, units: UnitSystem) -> Result<TargetSpec, WindScenarioError> {
433 let invalid = |message: String| WindScenarioError::InvalidTarget { message };
434 let to_m = |value: f64| match units {
435 UnitSystem::Imperial => value * 0.0254,
436 UnitSystem::Metric => value * 0.01,
437 };
438 let (shape, rest) = spec.split_once(':').ok_or_else(|| {
439 invalid(format!(
440 "'{spec}': expected rect:WIDTHxHEIGHT or circle:DIAMETER"
441 ))
442 })?;
443 let positive = |token: &str, what: &str| -> Result<f64, WindScenarioError> {
444 let value: f64 = token
445 .trim()
446 .parse()
447 .map_err(|_| invalid(format!("'{spec}': {what} '{token}' is not a number")))?;
448 if !value.is_finite() || value <= 0.0 {
449 return Err(invalid(format!(
450 "'{spec}': {what} must be finite and greater than zero"
451 )));
452 }
453 Ok(value)
454 };
455 match shape.trim().to_lowercase().as_str() {
456 "rect" | "rectangle" => {
457 let (w, h) = rest
458 .split_once(['x', 'X'])
459 .ok_or_else(|| invalid(format!("'{spec}': expected rect:WIDTHxHEIGHT")))?;
460 Ok(TargetSpec::Rect {
461 width_m: to_m(positive(w, "width")?),
462 height_m: to_m(positive(h, "height")?),
463 })
464 }
465 "circle" | "circ" => Ok(TargetSpec::Circle {
466 diameter_m: to_m(positive(rest, "diameter")?),
467 }),
468 other => Err(invalid(format!(
469 "'{spec}': unknown shape '{other}' (expected rect or circle)"
470 ))),
471 }
472}
473
474impl RobustHoldRequest {
475 pub fn validate(&self) -> Result<(), WindScenarioError> {
480 if self.scenarios.version != WIND_SCENARIO_SET_VERSION {
481 return Err(WindScenarioError::UnsupportedVersion {
482 version: self.scenarios.version,
483 expected: WIND_SCENARIO_SET_VERSION,
484 });
485 }
486 if self.scenarios.scenarios.is_empty() {
487 return Err(WindScenarioError::NoScenarios);
488 }
489 if self.scenarios.scenarios.len() > MAX_WIND_SCENARIOS {
490 return Err(WindScenarioError::TooManyScenarios {
491 count: self.scenarios.scenarios.len(),
492 max: MAX_WIND_SCENARIOS,
493 });
494 }
495 let mut seen: Vec<&str> = Vec::with_capacity(self.scenarios.scenarios.len());
496 for (index, scenario) in self.scenarios.scenarios.iter().enumerate() {
497 if scenario.name.trim().is_empty() {
498 return Err(WindScenarioError::EmptyScenarioName { index });
499 }
500 if seen.contains(&scenario.name.as_str()) {
501 return Err(WindScenarioError::DuplicateScenarioName {
502 name: scenario.name.clone(),
503 });
504 }
505 seen.push(&scenario.name);
506 if scenario.segments.is_empty() {
507 return Err(WindScenarioError::NoSegments {
508 name: scenario.name.clone(),
509 });
510 }
511 validate_wind_segments(&scenario.segments).map_err(|e| {
512 WindScenarioError::InvalidSegment {
513 scenario: scenario.name.clone(),
514 message: format!(
515 "segment #{} is invalid ({:?} violates {:?})",
516 e.index, e.field, e.rule
517 ),
518 }
519 })?;
520 }
521 if let Some(nominal) = self.scenarios.nominal.as_deref() {
522 if !self
523 .scenarios
524 .scenarios
525 .iter()
526 .any(|scenario| scenario.name == nominal)
527 {
528 return Err(WindScenarioError::UnknownNominal {
529 name: nominal.to_string(),
530 available: self
531 .scenarios
532 .scenarios
533 .iter()
534 .map(|scenario| scenario.name.clone())
535 .collect(),
536 });
537 }
538 }
539
540 if self.ranges_m.is_empty() {
541 return Err(WindScenarioError::NoRanges);
542 }
543 if self.ranges_m.len() > MAX_CORRIDOR_RANGES {
544 return Err(WindScenarioError::TooManyRanges {
545 count: self.ranges_m.len(),
546 max: MAX_CORRIDOR_RANGES,
547 });
548 }
549 let mut seen_ranges: Vec<f64> = Vec::with_capacity(self.ranges_m.len());
550 for &range in &self.ranges_m {
551 if !range.is_finite() || range <= 0.0 {
552 return Err(WindScenarioError::InvalidRange { value: range });
553 }
554 if seen_ranges.contains(&range) {
555 return Err(WindScenarioError::DuplicateRange { value: range });
556 }
557 seen_ranges.push(range);
558 }
559
560 if let Some(target) = self.target {
561 let bad = |message: &str| WindScenarioError::InvalidTarget {
562 message: message.to_string(),
563 };
564 match target {
565 TargetSpec::Rect { width_m, height_m } => {
566 if !width_m.is_finite() || width_m <= 0.0 {
567 return Err(bad("width must be finite and greater than zero"));
568 }
569 if !height_m.is_finite() || height_m <= 0.0 {
570 return Err(bad("height must be finite and greater than zero"));
571 }
572 }
573 TargetSpec::Circle { diameter_m } => {
574 if !diameter_m.is_finite() || diameter_m <= 0.0 {
575 return Err(bad("diameter must be finite and greater than zero"));
576 }
577 }
578 }
579 }
580
581 let load = &self.load;
582 for (field, value) in [
583 ("muzzle velocity", load.muzzle_velocity_mps),
584 ("ballistic coefficient", load.bc),
585 ("bullet mass", load.mass_kg),
586 ("bullet diameter", load.diameter_m),
587 ("bullet length", load.bullet_length_m),
588 ("zero distance", load.zero_distance_m),
589 ] {
590 if !value.is_finite() || value <= 0.0 {
591 return Err(WindScenarioError::InvalidLoad { field, value });
592 }
593 }
594 for (field, value) in [
595 ("sight height", load.sight_height_m),
596 ("temperature", load.temperature_c),
597 ("pressure", load.pressure_hpa),
598 ("humidity", load.humidity_pct),
599 ("altitude", load.altitude_m),
600 ] {
601 if !value.is_finite() {
602 return Err(WindScenarioError::InvalidLoad { field, value });
603 }
604 }
605 Ok(())
606 }
607}
608
609pub fn solve_robust_hold(
616 request: &RobustHoldRequest,
617) -> Result<RobustHoldReportV1, WindScenarioError> {
618 request.validate()?;
619
620 let mut scenarios = request.scenarios.scenarios.clone();
624 scenarios.sort_by(|a, b| a.name.cmp(&b.name));
625 let scenario_names: Vec<String> = scenarios
626 .iter()
627 .map(|scenario| scenario.name.clone())
628 .collect();
629
630 let load = &request.load;
631 let atmosphere = AtmosphericConditions {
632 temperature: load.temperature_c,
633 pressure: load.pressure_hpa,
634 humidity: load.humidity_pct,
635 altitude: load.altitude_m,
636 };
637 let base_inputs = BallisticInputs {
638 bc_value: load.bc,
639 bc_type: load.drag_model,
640 bullet_mass: load.mass_kg,
641 muzzle_velocity: load.muzzle_velocity_mps,
642 bullet_diameter: load.diameter_m,
643 bullet_length: load.bullet_length_m,
644 sight_height: load.sight_height_m,
645 use_rk4: true,
646 ..Default::default()
647 };
648 let zero_angle = crate::cli_api::calculate_zero_angle_with_conditions(
649 base_inputs.clone(),
650 load.zero_distance_m,
651 load.sight_height_m,
652 WindConditions::default(),
653 atmosphere.clone(),
654 )
655 .map_err(|e| WindScenarioError::SolveFailed {
656 scenario: "<zero>".to_string(),
657 message: e.to_string(),
658 })?;
659
660 let furthest_m = request
661 .ranges_m
662 .iter()
663 .fold(0.0_f64, |acc, &range| acc.max(range));
664 let max_range_m = furthest_m * 1.02;
665
666 let mut per_scenario: Vec<Vec<(f64, f64)>> = Vec::with_capacity(scenarios.len());
668 for scenario in &scenarios {
669 let mut inputs = base_inputs.clone();
670 inputs.muzzle_angle = zero_angle;
671 inputs.enable_trajectory_sampling = true;
672 inputs.sample_interval = SAMPLE_INTERVAL_M;
673
674 let mut solver =
675 TrajectorySolver::new(inputs, WindConditions::default(), atmosphere.clone());
676 solver.set_max_range(max_range_m);
677 solver.set_time_step(0.001);
678 solver.set_wind_segments(scenario.segments.clone());
679 let result = solver.solve().map_err(|e| WindScenarioError::SolveFailed {
680 scenario: scenario.name.clone(),
681 message: e.to_string(),
682 })?;
683 let samples = result.sampled_points.unwrap_or_default();
684 if samples.len() < 2 {
685 return Err(WindScenarioError::SolveFailed {
686 scenario: scenario.name.clone(),
687 message: "the trajectory produced too few sampled points".to_string(),
688 });
689 }
690 let mut holds = Vec::with_capacity(request.ranges_m.len());
691 for &range_m in &request.ranges_m {
692 let hold = interpolate_hold_mil(&samples, range_m).ok_or_else(|| {
693 WindScenarioError::SolveFailed {
694 scenario: scenario.name.clone(),
695 message: format!(
696 "the trajectory does not reach {range_m:.1} m (it was sampled to {:.1} m)",
697 samples.last().map_or(0.0, |s| s.distance_m)
698 ),
699 }
700 })?;
701 holds.push(hold);
702 }
703 per_scenario.push(holds);
704 }
705
706 let metric = request
707 .target
708 .map_or(CorridorMetric::Rectangular, TargetSpec::metric);
709 let nominal_index = request
710 .scenarios
711 .nominal
712 .as_deref()
713 .and_then(|name| scenarios.iter().position(|s| s.name == name));
714
715 let mut rows = Vec::with_capacity(request.ranges_m.len());
716 for (range_index, &range_m) in request.ranges_m.iter().enumerate() {
717 let points: Vec<(f64, f64)> = per_scenario
718 .iter()
719 .map(|holds| holds[range_index])
720 .collect();
721
722 let (elevation_min_mil, elevation_max_mil) = span(points.iter().map(|p| p.0));
723 let (windage_min_mil, windage_max_mil) = span(points.iter().map(|p| p.1));
724
725 let (minimax_elevation_mil, minimax_windage_mil) = match metric {
726 CorridorMetric::Rectangular => (
727 0.5 * (elevation_min_mil + elevation_max_mil),
728 0.5 * (windage_min_mil + windage_max_mil),
729 ),
730 CorridorMetric::Circular => minimum_enclosing_circle_center(&points),
731 };
732 let hold = (minimax_elevation_mil, minimax_windage_mil);
733
734 let worst_case_miss_mil = worst_case(&points, hold, metric);
735 let worst_case_elevation_miss_mil = points
736 .iter()
737 .fold(0.0_f64, |acc, p| acc.max((p.0 - hold.0).abs()));
738 let worst_case_windage_miss_mil = points
739 .iter()
740 .fold(0.0_f64, |acc, p| acc.max((p.1 - hold.1).abs()));
741 let worst_index = points
743 .iter()
744 .enumerate()
745 .fold((0usize, f64::NEG_INFINITY), |(best, best_d), (i, p)| {
746 let d = deviation(*p, hold, metric);
747 if d > best_d {
748 (i, d)
749 } else {
750 (best, best_d)
751 }
752 })
753 .0;
754
755 let nominal_worst_case_miss_mil =
756 nominal_index.map(|index| worst_case(&points, points[index], metric));
757
758 let fits_target = request.target.map(|target| {
759 let to_linear_m = |mil: f64| mil / 1000.0 * range_m;
760 match target {
761 TargetSpec::Rect { width_m, height_m } => {
762 to_linear_m(worst_case_elevation_miss_mil)
763 <= height_m / 2.0 + TARGET_FIT_EPSILON_M
764 && to_linear_m(worst_case_windage_miss_mil)
765 <= width_m / 2.0 + TARGET_FIT_EPSILON_M
766 }
767 TargetSpec::Circle { diameter_m } => {
768 to_linear_m(worst_case_miss_mil) <= diameter_m / 2.0 + TARGET_FIT_EPSILON_M
769 }
770 }
771 });
772
773 rows.push(CorridorRow {
774 range_m,
775 scenarios: scenario_names
776 .iter()
777 .zip(&points)
778 .map(|(name, point)| ScenarioHold {
779 name: name.clone(),
780 elevation_mil: point.0,
781 windage_mil: point.1,
782 })
783 .collect(),
784 elevation_min_mil,
785 elevation_max_mil,
786 windage_min_mil,
787 windage_max_mil,
788 minimax_elevation_mil,
789 minimax_windage_mil,
790 worst_case_miss_mil,
791 worst_case_elevation_miss_mil,
792 worst_case_windage_miss_mil,
793 worst_case_scenario: scenario_names[worst_index].clone(),
794 nominal_worst_case_miss_mil,
795 fits_target,
796 });
797 }
798
799 Ok(RobustHoldReportV1 {
800 version: ROBUST_HOLD_REPORT_VERSION,
801 ranges_m: request.ranges_m.clone(),
802 scenario_names,
803 nominal: request.scenarios.nominal.clone(),
804 target: request.target,
805 metric: match metric {
806 CorridorMetric::Rectangular => "rectangular".to_string(),
807 CorridorMetric::Circular => "circular".to_string(),
808 },
809 rows,
810 })
811}
812
813fn interpolate_hold_mil(
820 samples: &[crate::trajectory_sampling::TrajectorySample],
821 range_m: f64,
822) -> Option<(f64, f64)> {
823 if !range_m.is_finite() || range_m <= 0.0 {
824 return None;
825 }
826 let Bracket::Inside { lo, t } =
827 bracket_param(samples.len(), |i| samples[i].distance_m, range_m)
828 else {
829 return None;
830 };
831 let hi = lo + 1;
832 let lerp = |a: f64, b: f64| a + (b - a) * t;
833 Some((
834 lerp(samples[lo].drop_m, samples[hi].drop_m) / range_m * 1000.0,
835 lerp(samples[lo].wind_drift_m, samples[hi].wind_drift_m) / range_m * 1000.0,
836 ))
837}
838
839fn span(values: impl Iterator<Item = f64>) -> (f64, f64) {
840 let mut lo = f64::INFINITY;
841 let mut hi = f64::NEG_INFINITY;
842 for value in values {
843 if value < lo {
844 lo = value;
845 }
846 if value > hi {
847 hi = value;
848 }
849 }
850 (lo, hi)
851}
852
853fn deviation(point: (f64, f64), hold: (f64, f64), metric: CorridorMetric) -> f64 {
855 let de = (point.0 - hold.0).abs();
856 let dw = (point.1 - hold.1).abs();
857 match metric {
858 CorridorMetric::Rectangular => de.max(dw),
859 CorridorMetric::Circular => (de * de + dw * dw).sqrt(),
860 }
861}
862
863fn worst_case(points: &[(f64, f64)], hold: (f64, f64), metric: CorridorMetric) -> f64 {
865 points
866 .iter()
867 .fold(0.0_f64, |acc, &p| acc.max(deviation(p, hold, metric)))
868}
869
870fn minimum_enclosing_circle_center(points: &[(f64, f64)]) -> (f64, f64) {
880 let n = points.len();
881 if n == 0 {
882 return (0.0, 0.0);
883 }
884 if n == 1 {
885 return points[0];
886 }
887 const CONTAINS_EPSILON: f64 = 1.0e-9;
890 let mut best: Option<((f64, f64), f64)> = None;
891 let mut consider = |center: (f64, f64), radius: f64| {
892 if !center.0.is_finite() || !center.1.is_finite() || !radius.is_finite() {
893 return;
894 }
895 if points
896 .iter()
897 .any(|p| deviation(*p, center, CorridorMetric::Circular) > radius + CONTAINS_EPSILON)
898 {
899 return;
900 }
901 match best {
902 Some((_, best_radius)) if best_radius <= radius => {}
903 _ => best = Some((center, radius)),
904 }
905 };
906
907 for i in 0..n {
908 for j in (i + 1)..n {
909 let center = (
910 0.5 * (points[i].0 + points[j].0),
911 0.5 * (points[i].1 + points[j].1),
912 );
913 let radius = deviation(points[i], center, CorridorMetric::Circular);
914 consider(center, radius);
915 }
916 }
917 for i in 0..n {
918 for j in (i + 1)..n {
919 for k in (j + 1)..n {
920 if let Some(center) = circumcenter(points[i], points[j], points[k]) {
921 let radius = deviation(points[i], center, CorridorMetric::Circular);
922 consider(center, radius);
923 }
924 }
925 }
926 }
927 best.map_or(points[0], |(center, _)| center)
929}
930
931fn circumcenter(a: (f64, f64), b: (f64, f64), c: (f64, f64)) -> Option<(f64, f64)> {
933 let d = 2.0 * (a.0 * (b.1 - c.1) + b.0 * (c.1 - a.1) + c.0 * (a.1 - b.1));
934 if d.abs() < 1.0e-15 {
935 return None;
936 }
937 let a2 = a.0 * a.0 + a.1 * a.1;
938 let b2 = b.0 * b.0 + b.1 * b.1;
939 let c2 = c.0 * c.0 + c.1 * c.1;
940 Some((
941 (a2 * (b.1 - c.1) + b2 * (c.1 - a.1) + c2 * (a.1 - b.1)) / d,
942 (a2 * (c.0 - b.0) + b2 * (a.0 - c.0) + c2 * (b.0 - a.0)) / d,
943 ))
944}
945
946#[derive(Debug, Clone, Copy, PartialEq, Eq)]
948pub enum RobustHoldFormat {
949 Table,
950 Json,
951}
952
953pub fn format_robust_hold_report(
962 report: &RobustHoldReportV1,
963 format: RobustHoldFormat,
964 units: UnitSystem,
965) -> String {
966 let (dist_unit, size_unit) = match units {
967 UnitSystem::Imperial => ("yd", "in"),
968 UnitSystem::Metric => ("m", "cm"),
969 };
970 let range_display = |range_m: f64| match units {
971 UnitSystem::Imperial => range_m / 0.9144,
972 UnitSystem::Metric => range_m,
973 };
974 let size_display = |value_m: f64| match units {
975 UnitSystem::Imperial => value_m / 0.0254,
976 UnitSystem::Metric => value_m * 100.0,
977 };
978
979 match format {
980 RobustHoldFormat::Json => {
981 format!(
984 "{}\n",
985 serde_json::to_string_pretty(report)
986 .unwrap_or_else(|_| "{\"error\":\"serialization failed\"}".to_string())
987 )
988 }
989 RobustHoldFormat::Table => {
990 let mut out = String::new();
991 out.push_str("Robust Hold Corridor\n");
992 out.push_str("====================\n\n");
993 out.push_str(&format!(
994 "Scenarios ({}): {}\n",
995 report.scenario_names.len(),
996 report.scenario_names.join(", ")
997 ));
998 if let Some(nominal) = &report.nominal {
999 out.push_str(&format!("Nominal: {nominal}\n"));
1000 }
1001 match report.target {
1002 Some(TargetSpec::Rect { width_m, height_m }) => out.push_str(&format!(
1003 "Target: rectangle {:.1} x {:.1} {} (per-axis / L-inf metric)\n",
1004 size_display(width_m),
1005 size_display(height_m),
1006 size_unit
1007 )),
1008 Some(TargetSpec::Circle { diameter_m }) => out.push_str(&format!(
1009 "Target: circle {:.1} {} across (Euclidean / L2 metric)\n",
1010 size_display(diameter_m),
1011 size_unit
1012 )),
1013 None => out.push_str(
1014 "Target: none — the minimax hold uses the per-axis metric\n",
1015 ),
1016 }
1017 out.push_str(
1018 "\nHolds are milliradians: elevation positive = hold UP, windage positive = \
1019 hold RIGHT.\nThe corridor is the span of the scenarios you supplied. It is \
1020 NOT a probability interval.\n\n",
1021 );
1022
1023 for row in &report.rows {
1024 out.push_str(&format!(
1025 "--- {:.0} {} ---\n",
1026 range_display(row.range_m),
1027 dist_unit
1028 ));
1029 out.push_str(" Scenario Elev(mil) Wind(mil)\n");
1030 for hold in &row.scenarios {
1031 out.push_str(&format!(
1032 " {:<20} {:>9.3} {:>9.3}\n",
1033 hold.name, hold.elevation_mil, hold.windage_mil
1034 ));
1035 }
1036 out.push_str(&format!(
1037 " Corridor {:>9.3} {:>9.3} (min)\n",
1038 row.elevation_min_mil, row.windage_min_mil
1039 ));
1040 out.push_str(&format!(
1041 " {:>9.3} {:>9.3} (max)\n",
1042 row.elevation_max_mil, row.windage_max_mil
1043 ));
1044 out.push_str(&format!(
1045 " Minimax hold {:>9.3} {:>9.3}\n",
1046 row.minimax_elevation_mil, row.minimax_windage_mil
1047 ));
1048 out.push_str(&format!(
1049 " Worst case from it {:>9.3} mil ({:.2} {}), scenario '{}'\n",
1050 row.worst_case_miss_mil,
1051 size_display(row.worst_case_miss_mil / 1000.0 * row.range_m),
1052 size_unit,
1053 row.worst_case_scenario
1054 ));
1055 if let Some(nominal_worst) = row.nominal_worst_case_miss_mil {
1056 out.push_str(&format!(
1057 " Holding the nominal {:>9.3} mil ({:.2} {})\n",
1058 nominal_worst,
1059 size_display(nominal_worst / 1000.0 * row.range_m),
1060 size_unit
1061 ));
1062 }
1063 if let Some(fits) = row.fits_target {
1064 out.push_str(&format!(
1065 " Fits target {}\n",
1066 if fits {
1067 "yes — one hold covers every scenario"
1068 } else {
1069 "NO — no single hold covers every scenario here"
1070 }
1071 ));
1072 }
1073 out.push('\n');
1074 }
1075 out
1076 }
1077 }
1078}
1079
1080#[cfg(test)]
1081mod tests {
1082 use super::*;
1083
1084 fn segments(tokens: &[&str]) -> Vec<WindSegment> {
1085 tokens
1086 .iter()
1087 .map(|token| crate::wind::parse_wind_segment_str(token, true).unwrap())
1088 .collect()
1089 }
1090
1091 fn scenario(name: &str, tokens: &[&str]) -> NamedWindScenario {
1092 NamedWindScenario {
1093 name: name.to_string(),
1094 segments: segments(tokens),
1095 }
1096 }
1097
1098 pub(super) fn test_load() -> CorridorLoad {
1099 CorridorLoad {
1100 muzzle_velocity_mps: 823.0,
1101 bc: 0.475,
1102 drag_model: DragModel::G1,
1103 mass_kg: 0.010_886,
1104 diameter_m: 0.007_823,
1105 bullet_length_m: 0.031,
1106 zero_distance_m: 91.44,
1107 sight_height_m: 0.0508,
1108 temperature_c: 15.0,
1109 pressure_hpa: 1013.25,
1110 humidity_pct: 50.0,
1111 altitude_m: 0.0,
1112 }
1113 }
1114
1115 fn request(scenarios: Vec<NamedWindScenario>, nominal: Option<&str>) -> RobustHoldRequest {
1116 RobustHoldRequest {
1117 scenarios: WindScenarioSetV1 {
1118 version: 1,
1119 scenarios,
1120 nominal: nominal.map(str::to_string),
1121 },
1122 ranges_m: vec![182.88, 365.76, 548.64],
1123 target: None,
1124 load: test_load(),
1125 }
1126 }
1127
1128 #[test]
1130 fn corridor_contains_every_scenario_at_every_range() {
1131 let report = solve_robust_hold(&request(
1132 vec![
1133 scenario("low", &["4:90:1000"]),
1134 scenario("high", &["14:90:1000"]),
1135 scenario("switch", &["10:90:400", "8:270:1000"]),
1136 ],
1137 None,
1138 ))
1139 .unwrap();
1140 assert_eq!(report.version, ROBUST_HOLD_REPORT_VERSION);
1141 assert_eq!(report.rows.len(), 3);
1142 for row in &report.rows {
1143 assert_eq!(row.scenarios.len(), 3);
1144 for hold in &row.scenarios {
1145 assert!(
1146 hold.elevation_mil >= row.elevation_min_mil
1147 && hold.elevation_mil <= row.elevation_max_mil,
1148 "elevation {} outside [{}, {}]",
1149 hold.elevation_mil,
1150 row.elevation_min_mil,
1151 row.elevation_max_mil
1152 );
1153 assert!(
1154 hold.windage_mil >= row.windage_min_mil
1155 && hold.windage_mil <= row.windage_max_mil,
1156 "windage {} outside [{}, {}]",
1157 hold.windage_mil,
1158 row.windage_min_mil,
1159 row.windage_max_mil
1160 );
1161 }
1162 assert!(row.windage_max_mil - row.windage_min_mil > 0.1, "{row:?}");
1164 }
1165 }
1166
1167 #[test]
1169 fn a_single_scenario_gives_a_zero_width_corridor() {
1170 let report =
1171 solve_robust_hold(&request(vec![scenario("only", &["9:90:1000"])], None)).unwrap();
1172 for row in &report.rows {
1173 assert_eq!(row.elevation_min_mil, row.elevation_max_mil);
1174 assert_eq!(row.windage_min_mil, row.windage_max_mil);
1175 assert_eq!(row.minimax_elevation_mil, row.scenarios[0].elevation_mil);
1176 assert_eq!(row.minimax_windage_mil, row.scenarios[0].windage_mil);
1177 assert_eq!(row.worst_case_miss_mil, 0.0);
1178 assert_eq!(row.worst_case_elevation_miss_mil, 0.0);
1179 assert_eq!(row.worst_case_windage_miss_mil, 0.0);
1180 }
1181 }
1182
1183 #[test]
1185 fn minimax_worst_case_never_exceeds_the_nominal_holds() {
1186 for (target, label) in [
1187 (None, "no target"),
1188 (
1189 Some(TargetSpec::Rect {
1190 width_m: 0.3,
1191 height_m: 0.5,
1192 }),
1193 "rect",
1194 ),
1195 (Some(TargetSpec::Circle { diameter_m: 0.3 }), "circle"),
1196 ] {
1197 for nominal in ["low", "high", "switch"] {
1198 let mut req = request(
1199 vec![
1200 scenario("low", &["4:90:1000"]),
1201 scenario("high", &["14:90:1000"]),
1202 scenario("switch", &["10:90:400", "8:270:1000"]),
1203 ],
1204 Some(nominal),
1205 );
1206 req.target = target;
1207 let report = solve_robust_hold(&req).unwrap();
1208 for row in &report.rows {
1209 let nominal_worst = row.nominal_worst_case_miss_mil.unwrap();
1210 assert!(
1211 row.worst_case_miss_mil <= nominal_worst + 1e-12,
1212 "{label}/{nominal} at {} m: minimax {} > nominal {}",
1213 row.range_m,
1214 row.worst_case_miss_mil,
1215 nominal_worst
1216 );
1217 }
1218 }
1219 }
1220 }
1221
1222 #[test]
1224 fn reordering_scenarios_changes_nothing() {
1225 let a = solve_robust_hold(&request(
1226 vec![
1227 scenario("low", &["4:90:1000"]),
1228 scenario("high", &["14:90:1000"]),
1229 scenario("switch", &["10:90:400", "8:270:1000"]),
1230 ],
1231 Some("high"),
1232 ))
1233 .unwrap();
1234 let b = solve_robust_hold(&request(
1235 vec![
1236 scenario("switch", &["10:90:400", "8:270:1000"]),
1237 scenario("high", &["14:90:1000"]),
1238 scenario("low", &["4:90:1000"]),
1239 ],
1240 Some("high"),
1241 ))
1242 .unwrap();
1243 assert_eq!(a, b, "scenario order must not affect any output");
1244 assert_eq!(a.scenario_names, vec!["high", "low", "switch"]);
1246 }
1247
1248 #[test]
1250 fn target_fit_is_correct_for_both_shapes_including_boundary_contact() {
1251 let base = || {
1257 request(
1258 vec![
1259 scenario("cross-light", &["4:90:1000"]),
1260 scenario("cross-heavy", &["14:90:1000"]),
1261 scenario("updraft", &["4:90:1000:10"]),
1262 ],
1263 None,
1264 )
1265 };
1266 let mut probe = base();
1267 probe.ranges_m = vec![548.64];
1268 let report = solve_robust_hold(&probe).unwrap();
1269 let row = &report.rows[0];
1270 let half_span_wind = row.worst_case_windage_miss_mil;
1271 let half_span_elev = row.worst_case_elevation_miss_mil;
1272 assert!(
1275 half_span_wind > 0.05 && half_span_elev > 0.05,
1276 "fixture must spread BOTH axes: windage {half_span_wind}, elevation {half_span_elev}"
1277 );
1278 let range_m = row.range_m;
1279 let wind_linear = half_span_wind / 1000.0 * range_m;
1280 let elev_linear = half_span_elev / 1000.0 * range_m;
1281
1282 let mut exact = base();
1284 exact.ranges_m = vec![548.64];
1285 exact.target = Some(TargetSpec::Rect {
1286 width_m: 2.0 * wind_linear,
1287 height_m: (2.0 * elev_linear).max(0.01),
1288 });
1289 assert_eq!(
1290 solve_robust_hold(&exact).unwrap().rows[0].fits_target,
1291 Some(true),
1292 "boundary contact counts as a fit"
1293 );
1294
1295 let mut narrow = exact.clone();
1297 narrow.target = Some(TargetSpec::Rect {
1298 width_m: 2.0 * wind_linear * 0.99,
1299 height_m: (2.0 * elev_linear).max(0.01),
1300 });
1301 assert_eq!(
1302 solve_robust_hold(&narrow).unwrap().rows[0].fits_target,
1303 Some(false)
1304 );
1305
1306 let mut wide = exact.clone();
1308 wide.target = Some(TargetSpec::Rect {
1309 width_m: 2.0 * wind_linear * 1.01,
1310 height_m: (2.0 * elev_linear).max(0.01) * 1.01,
1311 });
1312 assert_eq!(
1313 solve_robust_hold(&wide).unwrap().rows[0].fits_target,
1314 Some(true)
1315 );
1316
1317 let mut circle_probe = base();
1319 circle_probe.ranges_m = vec![548.64];
1320 circle_probe.target = Some(TargetSpec::Circle { diameter_m: 1.0 });
1321 let circle_row = solve_robust_hold(&circle_probe).unwrap().rows[0].clone();
1322 let radius_linear = circle_row.worst_case_miss_mil / 1000.0 * range_m;
1323
1324 let mut exact_circle = circle_probe.clone();
1325 exact_circle.target = Some(TargetSpec::Circle {
1326 diameter_m: 2.0 * radius_linear,
1327 });
1328 assert_eq!(
1329 solve_robust_hold(&exact_circle).unwrap().rows[0].fits_target,
1330 Some(true),
1331 "boundary contact counts as a fit for a circle too"
1332 );
1333
1334 let mut small_circle = circle_probe.clone();
1335 small_circle.target = Some(TargetSpec::Circle {
1336 diameter_m: 2.0 * radius_linear * 0.99,
1337 });
1338 assert_eq!(
1339 solve_robust_hold(&small_circle).unwrap().rows[0].fits_target,
1340 Some(false)
1341 );
1342
1343 let l_inf = half_span_wind.max(half_span_elev);
1349 assert!(
1350 circle_row.worst_case_miss_mil > l_inf + 1e-6,
1351 "circular metric must be a true L2 radius strictly above the L-inf half-span: \
1352 L2 {} vs L-inf {}",
1353 circle_row.worst_case_miss_mil,
1354 l_inf
1355 );
1356 }
1357
1358 #[test]
1360 fn caps_and_malformed_input_are_structured_errors_before_any_solve() {
1361 let nine: Vec<NamedWindScenario> = (0..9)
1362 .map(|i| scenario(&format!("s{i}"), &["5:90:1000"]))
1363 .collect();
1364 assert_eq!(
1365 solve_robust_hold(&request(nine, None)).unwrap_err(),
1366 WindScenarioError::TooManyScenarios { count: 9, max: 8 }
1367 );
1368
1369 let mut too_many_ranges = request(vec![scenario("a", &["5:90:1000"])], None);
1370 too_many_ranges.ranges_m = (1..=65).map(f64::from).collect();
1371 assert_eq!(
1372 solve_robust_hold(&too_many_ranges).unwrap_err(),
1373 WindScenarioError::TooManyRanges { count: 65, max: 64 }
1374 );
1375
1376 let mut no_scenarios = request(vec![], None);
1377 no_scenarios.ranges_m = vec![100.0];
1378 assert_eq!(
1379 solve_robust_hold(&no_scenarios).unwrap_err(),
1380 WindScenarioError::NoScenarios
1381 );
1382
1383 let mut bad_segment = request(vec![scenario("a", &["5:90:1000"])], None);
1384 bad_segment.scenarios.scenarios[0].segments[0].until_m = -1.0;
1385 assert!(matches!(
1386 solve_robust_hold(&bad_segment).unwrap_err(),
1387 WindScenarioError::InvalidSegment { .. }
1388 ));
1389
1390 let mut empty_segments = request(vec![scenario("a", &["5:90:1000"])], None);
1391 empty_segments.scenarios.scenarios[0].segments.clear();
1392 assert!(matches!(
1393 solve_robust_hold(&empty_segments).unwrap_err(),
1394 WindScenarioError::NoSegments { .. }
1395 ));
1396
1397 let mut duplicate = request(
1398 vec![scenario("a", &["5:90:1000"]), scenario("a", &["9:90:1000"])],
1399 None,
1400 );
1401 duplicate.ranges_m = vec![100.0];
1402 assert!(matches!(
1403 solve_robust_hold(&duplicate).unwrap_err(),
1404 WindScenarioError::DuplicateScenarioName { .. }
1405 ));
1406
1407 let unknown_nominal = request(vec![scenario("a", &["5:90:1000"])], Some("nope"));
1408 assert!(matches!(
1409 solve_robust_hold(&unknown_nominal).unwrap_err(),
1410 WindScenarioError::UnknownNominal { .. }
1411 ));
1412
1413 let mut bad_version = request(vec![scenario("a", &["5:90:1000"])], None);
1414 bad_version.scenarios.version = 2;
1415 assert_eq!(
1416 solve_robust_hold(&bad_version).unwrap_err(),
1417 WindScenarioError::UnsupportedVersion {
1418 version: 2,
1419 expected: 1
1420 }
1421 );
1422
1423 let mut duplicate_range = request(vec![scenario("a", &["5:90:1000"])], None);
1424 duplicate_range.ranges_m = vec![100.0, 200.0, 100.0];
1425 assert_eq!(
1426 solve_robust_hold(&duplicate_range).unwrap_err(),
1427 WindScenarioError::DuplicateRange { value: 100.0 }
1428 );
1429
1430 let mut bad_load = request(vec![scenario("a", &["5:90:1000"])], None);
1431 bad_load.load.muzzle_velocity_mps = 0.0;
1432 assert!(matches!(
1433 solve_robust_hold(&bad_load).unwrap_err(),
1434 WindScenarioError::InvalidLoad { .. }
1435 ));
1436 }
1437
1438 #[test]
1439 fn parsing_enforces_the_version_and_the_scenario_cap_before_segments() {
1440 let err = parse_wind_scenario_set(
1442 r#"{"version":2,"scenarios":[{"name":"a","segments":["x"]}]}"#,
1443 UnitSystem::Imperial,
1444 )
1445 .unwrap_err();
1446 assert_eq!(
1447 err,
1448 WindScenarioError::UnsupportedVersion {
1449 version: 2,
1450 expected: 1
1451 },
1452 "the version check must precede segment parsing"
1453 );
1454
1455 let scenarios: Vec<String> = (0..9)
1457 .map(|i| format!(r#"{{"name":"s{i}","segments":["5:90:1000"]}}"#))
1458 .collect();
1459 let doc = format!(
1460 r#"{{"version":1,"scenarios":[{}]}}"#,
1461 scenarios.join(",")
1462 );
1463 assert_eq!(
1464 parse_wind_scenario_set(&doc, UnitSystem::Imperial).unwrap_err(),
1465 WindScenarioError::TooManyScenarios { count: 9, max: 8 }
1466 );
1467
1468 let err = parse_wind_scenario_set(
1469 r#"{"version":1,"scenarios":[{"name":"a","segments":["nope"]}]}"#,
1470 UnitSystem::Imperial,
1471 )
1472 .unwrap_err();
1473 assert!(matches!(err, WindScenarioError::MalformedSegment { .. }), "{err}");
1474
1475 let err = parse_wind_scenario_set(
1476 r#"{"version":1,"scenarios":[{"name":" ","segments":["5:90:1000"]}]}"#,
1477 UnitSystem::Imperial,
1478 )
1479 .unwrap_err();
1480 assert_eq!(err, WindScenarioError::EmptyScenarioName { index: 0 });
1481
1482 assert!(matches!(
1483 parse_wind_scenario_set("not json", UnitSystem::Imperial).unwrap_err(),
1484 WindScenarioError::MalformedDocument { .. }
1485 ));
1486
1487 let set = parse_wind_scenario_set(
1489 r#"{"version":1,"nominal":"low",
1490 "scenarios":[{"name":"low","segments":["10:90:400"]}]}"#,
1491 UnitSystem::Imperial,
1492 )
1493 .unwrap();
1494 assert_eq!(set.nominal.as_deref(), Some("low"));
1495 assert_eq!(set.scenarios.len(), 1);
1496 assert!((set.scenarios[0].segments[0].speed_kmh - 16.09344).abs() < 1e-9);
1498 assert!((set.scenarios[0].segments[0].until_m - 365.76).abs() < 1e-9);
1499 }
1500
1501 #[test]
1502 fn target_spec_parsing() {
1503 assert_eq!(
1504 parse_target_spec("rect:12x18", UnitSystem::Imperial).unwrap(),
1505 TargetSpec::Rect {
1506 width_m: 12.0 * 0.0254,
1507 height_m: 18.0 * 0.0254
1508 }
1509 );
1510 assert_eq!(
1511 parse_target_spec("circle:10", UnitSystem::Metric).unwrap(),
1512 TargetSpec::Circle { diameter_m: 0.1 }
1513 );
1514 for bad in ["rect", "rect:12", "circle:-1", "blob:3", "rect:0x5", "circle:abc"] {
1515 assert!(
1516 parse_target_spec(bad, UnitSystem::Imperial).is_err(),
1517 "'{bad}' should be rejected"
1518 );
1519 }
1520 }
1521
1522 #[test]
1523 fn minimum_enclosing_circle_is_exact_and_order_independent() {
1524 let points = vec![(1.0, 0.0), (-0.5, 0.866_025_403_784_438_6), (-0.5, -0.866_025_403_784_438_6)];
1526 let center = minimum_enclosing_circle_center(&points);
1527 assert!(center.0.abs() < 1e-9 && center.1.abs() < 1e-9, "{center:?}");
1528
1529 let pair = vec![(0.0, 0.0), (2.0, 4.0)];
1531 assert_eq!(minimum_enclosing_circle_center(&pair), (1.0, 2.0));
1532
1533 let mut with_interior = points.clone();
1541 with_interior.push((0.1, -0.05));
1542 let a = minimum_enclosing_circle_center(&with_interior);
1543 with_interior.reverse();
1544 let b = minimum_enclosing_circle_center(&with_interior);
1545 assert!(
1546 (a.0 - b.0).abs() < 1e-12 && (a.1 - b.1).abs() < 1e-12,
1547 "{a:?} vs {b:?}"
1548 );
1549
1550 assert_eq!(minimum_enclosing_circle_center(&[(3.0, 4.0)]), (3.0, 4.0));
1551 assert_eq!(minimum_enclosing_circle_center(&[]), (0.0, 0.0));
1552 }
1553
1554 #[test]
1555 fn formatter_renders_both_shapes_and_json_is_the_wire_schema() {
1556 let mut req = request(
1557 vec![
1558 scenario("low", &["4:90:1000"]),
1559 scenario("high", &["14:90:1000"]),
1560 ],
1561 Some("low"),
1562 );
1563 req.target = Some(TargetSpec::Rect {
1564 width_m: 0.3,
1565 height_m: 0.5,
1566 });
1567 let report = solve_robust_hold(&req).unwrap();
1568
1569 let json = format_robust_hold_report(&report, RobustHoldFormat::Json, UnitSystem::Imperial);
1575 let back: RobustHoldReportV1 = serde_json::from_str(&json).unwrap();
1576 assert_eq!(back.version, report.version);
1577 assert_eq!(back.scenario_names, report.scenario_names);
1578 assert_eq!(back.nominal, report.nominal);
1579 assert_eq!(back.metric, report.metric);
1580 assert_eq!(back.rows.len(), report.rows.len());
1581 for (got, want) in back.rows.iter().zip(&report.rows) {
1582 assert_eq!(got.worst_case_scenario, want.worst_case_scenario);
1583 assert_eq!(got.fits_target, want.fits_target);
1584 assert_eq!(got.scenarios.len(), want.scenarios.len());
1585 for (a, b) in got.scenarios.iter().zip(&want.scenarios) {
1586 assert_eq!(a.name, b.name);
1587 assert!((a.elevation_mil - b.elevation_mil).abs() < 1e-12);
1588 assert!((a.windage_mil - b.windage_mil).abs() < 1e-12);
1589 }
1590 for (a, b) in [
1591 (got.range_m, want.range_m),
1592 (got.elevation_min_mil, want.elevation_min_mil),
1593 (got.elevation_max_mil, want.elevation_max_mil),
1594 (got.windage_min_mil, want.windage_min_mil),
1595 (got.windage_max_mil, want.windage_max_mil),
1596 (got.minimax_elevation_mil, want.minimax_elevation_mil),
1597 (got.minimax_windage_mil, want.minimax_windage_mil),
1598 (got.worst_case_miss_mil, want.worst_case_miss_mil),
1599 ] {
1600 assert!((a - b).abs() < 1e-12, "{a} vs {b}");
1601 }
1602 }
1603
1604 let table =
1605 format_robust_hold_report(&report, RobustHoldFormat::Table, UnitSystem::Imperial);
1606 assert!(table.contains("Robust Hold Corridor"));
1607 assert!(table.contains("Minimax hold"));
1608 assert!(table.contains("Holding the nominal"));
1609 assert!(table.contains("Fits target"));
1610 assert!(
1611 table.contains("NOT a probability interval"),
1612 "the non-goal must be stated where it is read: {table}"
1613 );
1614 assert!(table.ends_with('\n'));
1615 }
1616}