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