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 {
197 name: String,
198 available: Vec<String>,
199 },
200 NoRanges,
202 TooManyRanges { count: usize, max: usize },
204 InvalidRange { value: f64 },
206 DuplicateRange { value: f64 },
208 InvalidTarget { message: String },
210 InvalidLoad { field: &'static str, value: f64 },
212 MalformedDocument { message: String },
214 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
285pub struct ScenarioHold {
286 pub name: String,
287 pub elevation_mil: f64,
289 pub windage_mil: f64,
291}
292
293#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
295pub struct CorridorRow {
296 pub range_m: f64,
297 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 pub minimax_elevation_mil: f64,
305 pub minimax_windage_mil: f64,
306 pub worst_case_miss_mil: f64,
310 pub worst_case_elevation_miss_mil: f64,
313 pub worst_case_windage_miss_mil: f64,
314 pub worst_case_scenario: String,
317 #[serde(default, skip_serializing_if = "Option::is_none")]
320 pub nominal_worst_case_miss_mil: Option<f64>,
321 #[serde(default, skip_serializing_if = "Option::is_none")]
324 pub fits_target: Option<bool>,
325}
326
327#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
329pub struct RobustHoldReportV1 {
330 pub version: u32,
332 pub ranges_m: Vec<f64>,
334 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 pub metric: String,
343 pub rows: Vec<CorridorRow>,
344}
345
346pub 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 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 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
431pub 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 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
611pub fn solve_robust_hold(
618 request: &RobustHoldRequest,
619) -> Result<RobustHoldReportV1, WindScenarioError> {
620 request.validate()?;
621
622 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 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 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
815fn 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
855fn 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
865fn 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
872fn 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 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 best.map_or(points[0], |(center, _)| center)
931}
932
933fn 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
950pub enum RobustHoldFormat {
951 Table,
952 Json,
953}
954
955pub 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 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 #[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 assert!(row.windage_max_mil - row.windage_min_mil > 0.1, "{row:?}");
1165 }
1166 }
1167
1168 #[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 #[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 #[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 assert_eq!(a.scenario_names, vec!["high", "low", "switch"]);
1247 }
1248
1249 #[test]
1251 fn target_fit_is_correct_for_both_shapes_including_boundary_contact() {
1252 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 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 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 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 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 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 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 #[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 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 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 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 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 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 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}