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 pub station_presence: crate::atmosphere::StationAtmospherePresence,
164}
165
166#[derive(Debug, Clone, PartialEq)]
168pub struct RobustHoldRequest {
169 pub scenarios: WindScenarioSetV1,
170 pub ranges_m: Vec<f64>,
172 pub target: Option<TargetSpec>,
173 pub load: CorridorLoad,
174}
175
176#[derive(Debug, Clone, PartialEq)]
179pub enum WindScenarioError {
180 UnsupportedVersion { version: u32, expected: u32 },
182 NoScenarios,
184 TooManyScenarios { count: usize, max: usize },
186 EmptyScenarioName { index: usize },
188 DuplicateScenarioName { name: String },
190 NoSegments { name: String },
192 MalformedSegment {
194 scenario: String,
195 index: usize,
196 message: String,
197 },
198 InvalidSegment { scenario: String, message: String },
200 UnknownNominal {
202 name: String,
203 available: Vec<String>,
204 },
205 NoRanges,
207 TooManyRanges { count: usize, max: usize },
209 InvalidRange { value: f64 },
211 DuplicateRange { value: f64 },
213 InvalidTarget { message: String },
215 InvalidLoad { field: &'static str, value: f64 },
217 MalformedDocument { message: String },
219 SolveFailed { scenario: String, message: String },
221}
222
223impl fmt::Display for WindScenarioError {
224 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
225 match self {
226 WindScenarioError::UnsupportedVersion { version, expected } => write!(
227 f,
228 "unsupported wind scenario set version {version}; this build implements {expected}"
229 ),
230 WindScenarioError::NoScenarios => {
231 write!(f, "the scenario set contains no scenarios")
232 }
233 WindScenarioError::TooManyScenarios { count, max } => write!(
234 f,
235 "{count} scenarios exceeds the limit of {max}: a hold corridor is meant to \
236 span a handful of concrete wind calls, not a swept distribution"
237 ),
238 WindScenarioError::EmptyScenarioName { index } => {
239 write!(f, "scenario #{index} has an empty name")
240 }
241 WindScenarioError::DuplicateScenarioName { name } => write!(
242 f,
243 "two scenarios are both named '{name}'; names carry provenance through the \
244 whole report and must be unique"
245 ),
246 WindScenarioError::NoSegments { name } => {
247 write!(f, "scenario '{name}' has no wind segments")
248 }
249 WindScenarioError::MalformedSegment {
250 scenario,
251 index,
252 message,
253 } => write!(f, "scenario '{scenario}' segment #{index}: {message}"),
254 WindScenarioError::InvalidSegment { scenario, message } => {
255 write!(f, "scenario '{scenario}': {message}")
256 }
257 WindScenarioError::UnknownNominal { name, available } => write!(
258 f,
259 "nominal scenario '{name}' is not in the set (available: {})",
260 available.join(", ")
261 ),
262 WindScenarioError::NoRanges => write!(f, "no ranges were requested"),
263 WindScenarioError::TooManyRanges { count, max } => {
264 write!(f, "{count} ranges exceeds the limit of {max}")
265 }
266 WindScenarioError::InvalidRange { value } => {
267 write!(f, "range {value} must be finite and greater than zero")
268 }
269 WindScenarioError::DuplicateRange { value } => {
270 write!(f, "range {value} was requested more than once")
271 }
272 WindScenarioError::InvalidTarget { message } => write!(f, "invalid target: {message}"),
273 WindScenarioError::InvalidLoad { field, value } => {
274 write!(f, "{field} ({value}) must be finite and greater than zero")
275 }
276 WindScenarioError::MalformedDocument { message } => {
277 write!(f, "malformed wind scenario set: {message}")
278 }
279 WindScenarioError::SolveFailed { scenario, message } => {
280 write!(f, "scenario '{scenario}' could not be solved: {message}")
281 }
282 }
283 }
284}
285
286impl Error for WindScenarioError {}
287
288#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
290pub struct ScenarioHold {
291 pub name: String,
292 pub elevation_mil: f64,
294 pub windage_mil: f64,
296}
297
298#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
300pub struct CorridorRow {
301 pub range_m: f64,
302 pub scenarios: Vec<ScenarioHold>,
304 pub elevation_min_mil: f64,
305 pub elevation_max_mil: f64,
306 pub windage_min_mil: f64,
307 pub windage_max_mil: f64,
308 pub minimax_elevation_mil: f64,
310 pub minimax_windage_mil: f64,
311 pub worst_case_miss_mil: f64,
315 pub worst_case_elevation_miss_mil: f64,
318 pub worst_case_windage_miss_mil: f64,
319 pub worst_case_scenario: String,
322 #[serde(default, skip_serializing_if = "Option::is_none")]
325 pub nominal_worst_case_miss_mil: Option<f64>,
326 #[serde(default, skip_serializing_if = "Option::is_none")]
329 pub fits_target: Option<bool>,
330}
331
332#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
334pub struct RobustHoldReportV1 {
335 pub version: u32,
337 pub ranges_m: Vec<f64>,
339 pub scenario_names: Vec<String>,
342 #[serde(default, skip_serializing_if = "Option::is_none")]
343 pub nominal: Option<String>,
344 #[serde(default, skip_serializing_if = "Option::is_none")]
345 pub target: Option<TargetSpec>,
346 pub metric: String,
348 pub rows: Vec<CorridorRow>,
349}
350
351pub fn parse_wind_scenario_set(
369 text: &str,
370 units: UnitSystem,
371) -> Result<WindScenarioSetV1, WindScenarioError> {
372 #[derive(Deserialize)]
373 struct WireScenario {
374 name: String,
375 segments: Vec<String>,
376 }
377 #[derive(Deserialize)]
378 struct WireSet {
379 version: u32,
380 scenarios: Vec<WireScenario>,
381 #[serde(default)]
382 nominal: Option<String>,
383 }
384
385 let wire: WireSet =
386 serde_json::from_str(text).map_err(|e| WindScenarioError::MalformedDocument {
387 message: e.to_string(),
388 })?;
389 if wire.version != WIND_SCENARIO_SET_VERSION {
392 return Err(WindScenarioError::UnsupportedVersion {
393 version: wire.version,
394 expected: WIND_SCENARIO_SET_VERSION,
395 });
396 }
397 if wire.scenarios.len() > MAX_WIND_SCENARIOS {
400 return Err(WindScenarioError::TooManyScenarios {
401 count: wire.scenarios.len(),
402 max: MAX_WIND_SCENARIOS,
403 });
404 }
405
406 let imperial = matches!(units, UnitSystem::Imperial);
407 let mut scenarios = Vec::with_capacity(wire.scenarios.len());
408 for (index, scenario) in wire.scenarios.into_iter().enumerate() {
409 if scenario.name.trim().is_empty() {
410 return Err(WindScenarioError::EmptyScenarioName { index });
411 }
412 let mut segments = Vec::with_capacity(scenario.segments.len());
413 for (segment_index, token) in scenario.segments.iter().enumerate() {
414 let segment = crate::wind::parse_wind_segment_str(token, imperial).map_err(|e| {
415 WindScenarioError::MalformedSegment {
416 scenario: scenario.name.clone(),
417 index: segment_index,
418 message: e,
419 }
420 })?;
421 segments.push(segment);
422 }
423 scenarios.push(NamedWindScenario {
424 name: scenario.name,
425 segments,
426 });
427 }
428
429 Ok(WindScenarioSetV1 {
430 version: WIND_SCENARIO_SET_VERSION,
431 scenarios,
432 nominal: wire.nominal,
433 })
434}
435
436pub fn parse_target_spec(spec: &str, units: UnitSystem) -> Result<TargetSpec, WindScenarioError> {
440 let invalid = |message: String| WindScenarioError::InvalidTarget { message };
441 let to_m = |value: f64| match units {
442 UnitSystem::Imperial => value * 0.0254,
443 UnitSystem::Metric => value * 0.01,
444 };
445 let (shape, rest) = spec.split_once(':').ok_or_else(|| {
446 invalid(format!(
447 "'{spec}': expected rect:WIDTHxHEIGHT or circle:DIAMETER"
448 ))
449 })?;
450 let positive = |token: &str, what: &str| -> Result<f64, WindScenarioError> {
451 let value: f64 = token
452 .trim()
453 .parse()
454 .map_err(|_| invalid(format!("'{spec}': {what} '{token}' is not a number")))?;
455 if !value.is_finite() || value <= 0.0 {
456 return Err(invalid(format!(
457 "'{spec}': {what} must be finite and greater than zero"
458 )));
459 }
460 Ok(value)
461 };
462 match shape.trim().to_lowercase().as_str() {
463 "rect" | "rectangle" => {
464 let (w, h) = rest
465 .split_once(['x', 'X'])
466 .ok_or_else(|| invalid(format!("'{spec}': expected rect:WIDTHxHEIGHT")))?;
467 Ok(TargetSpec::Rect {
468 width_m: to_m(positive(w, "width")?),
469 height_m: to_m(positive(h, "height")?),
470 })
471 }
472 "circle" | "circ" => Ok(TargetSpec::Circle {
473 diameter_m: to_m(positive(rest, "diameter")?),
474 }),
475 other => Err(invalid(format!(
476 "'{spec}': unknown shape '{other}' (expected rect or circle)"
477 ))),
478 }
479}
480
481impl RobustHoldRequest {
482 pub fn validate(&self) -> Result<(), WindScenarioError> {
487 if self.scenarios.version != WIND_SCENARIO_SET_VERSION {
488 return Err(WindScenarioError::UnsupportedVersion {
489 version: self.scenarios.version,
490 expected: WIND_SCENARIO_SET_VERSION,
491 });
492 }
493 if self.scenarios.scenarios.is_empty() {
494 return Err(WindScenarioError::NoScenarios);
495 }
496 if self.scenarios.scenarios.len() > MAX_WIND_SCENARIOS {
497 return Err(WindScenarioError::TooManyScenarios {
498 count: self.scenarios.scenarios.len(),
499 max: MAX_WIND_SCENARIOS,
500 });
501 }
502 let mut seen: Vec<&str> = Vec::with_capacity(self.scenarios.scenarios.len());
503 for (index, scenario) in self.scenarios.scenarios.iter().enumerate() {
504 if scenario.name.trim().is_empty() {
505 return Err(WindScenarioError::EmptyScenarioName { index });
506 }
507 if seen.contains(&scenario.name.as_str()) {
508 return Err(WindScenarioError::DuplicateScenarioName {
509 name: scenario.name.clone(),
510 });
511 }
512 seen.push(&scenario.name);
513 if scenario.segments.is_empty() {
514 return Err(WindScenarioError::NoSegments {
515 name: scenario.name.clone(),
516 });
517 }
518 validate_wind_segments(&scenario.segments).map_err(|e| {
519 WindScenarioError::InvalidSegment {
520 scenario: scenario.name.clone(),
521 message: format!(
522 "segment #{} is invalid ({:?} violates {:?})",
523 e.index, e.field, e.rule
524 ),
525 }
526 })?;
527 }
528 if let Some(nominal) = self.scenarios.nominal.as_deref() {
529 if !self
530 .scenarios
531 .scenarios
532 .iter()
533 .any(|scenario| scenario.name == nominal)
534 {
535 return Err(WindScenarioError::UnknownNominal {
536 name: nominal.to_string(),
537 available: self
538 .scenarios
539 .scenarios
540 .iter()
541 .map(|scenario| scenario.name.clone())
542 .collect(),
543 });
544 }
545 }
546
547 if self.ranges_m.is_empty() {
548 return Err(WindScenarioError::NoRanges);
549 }
550 if self.ranges_m.len() > MAX_CORRIDOR_RANGES {
551 return Err(WindScenarioError::TooManyRanges {
552 count: self.ranges_m.len(),
553 max: MAX_CORRIDOR_RANGES,
554 });
555 }
556 let mut seen_ranges: Vec<f64> = Vec::with_capacity(self.ranges_m.len());
557 for &range in &self.ranges_m {
558 if !range.is_finite() || range <= 0.0 {
559 return Err(WindScenarioError::InvalidRange { value: range });
560 }
561 if seen_ranges.contains(&range) {
562 return Err(WindScenarioError::DuplicateRange { value: range });
563 }
564 seen_ranges.push(range);
565 }
566
567 if let Some(target) = self.target {
568 let bad = |message: &str| WindScenarioError::InvalidTarget {
569 message: message.to_string(),
570 };
571 match target {
572 TargetSpec::Rect { width_m, height_m } => {
573 if !width_m.is_finite() || width_m <= 0.0 {
574 return Err(bad("width must be finite and greater than zero"));
575 }
576 if !height_m.is_finite() || height_m <= 0.0 {
577 return Err(bad("height must be finite and greater than zero"));
578 }
579 }
580 TargetSpec::Circle { diameter_m } => {
581 if !diameter_m.is_finite() || diameter_m <= 0.0 {
582 return Err(bad("diameter must be finite and greater than zero"));
583 }
584 }
585 }
586 }
587
588 let load = &self.load;
589 for (field, value) in [
590 ("muzzle velocity", load.muzzle_velocity_mps),
591 ("ballistic coefficient", load.bc),
592 ("bullet mass", load.mass_kg),
593 ("bullet diameter", load.diameter_m),
594 ("bullet length", load.bullet_length_m),
595 ("zero distance", load.zero_distance_m),
596 ] {
597 if !value.is_finite() || value <= 0.0 {
598 return Err(WindScenarioError::InvalidLoad { field, value });
599 }
600 }
601 for (field, value) in [
602 ("sight height", load.sight_height_m),
603 ("temperature", load.temperature_c),
604 ("pressure", load.pressure_hpa),
605 ("humidity", load.humidity_pct),
606 ("altitude", load.altitude_m),
607 ] {
608 if !value.is_finite() {
609 return Err(WindScenarioError::InvalidLoad { field, value });
610 }
611 }
612 Ok(())
613 }
614}
615
616pub fn solve_robust_hold(
623 request: &RobustHoldRequest,
624) -> Result<RobustHoldReportV1, WindScenarioError> {
625 request.validate()?;
626
627 let mut scenarios = request.scenarios.scenarios.clone();
631 scenarios.sort_by(|a, b| a.name.cmp(&b.name));
632 let scenario_names: Vec<String> = scenarios
633 .iter()
634 .map(|scenario| scenario.name.clone())
635 .collect();
636
637 let load = &request.load;
638 let atmosphere = AtmosphericConditions {
639 temperature: load.temperature_c,
640 pressure: load.pressure_hpa,
641 humidity: load.humidity_pct,
642 altitude: load.altitude_m,
643 };
644 let base_inputs = BallisticInputs {
645 bc_value: load.bc,
646 bc_type: load.drag_model,
647 bullet_mass: load.mass_kg,
648 muzzle_velocity: load.muzzle_velocity_mps,
649 bullet_diameter: load.diameter_m,
650 bullet_length: load.bullet_length_m,
651 sight_height: load.sight_height_m,
652 use_rk4: true,
653 ..Default::default()
654 };
655 let zero_angle = crate::cli_api::calculate_zero_angle_with_station_presence(
656 base_inputs.clone(),
657 load.zero_distance_m,
658 load.sight_height_m,
659 WindConditions::default(),
660 atmosphere.clone(),
661 load.station_presence,
662 )
663 .map_err(|e| WindScenarioError::SolveFailed {
664 scenario: "<zero>".to_string(),
665 message: e.to_string(),
666 })?;
667
668 let furthest_m = request
669 .ranges_m
670 .iter()
671 .fold(0.0_f64, |acc, &range| acc.max(range));
672 let max_range_m = furthest_m * 1.02;
673
674 let mut per_scenario: Vec<Vec<(f64, f64)>> = Vec::with_capacity(scenarios.len());
676 for scenario in &scenarios {
677 let mut inputs = base_inputs.clone();
678 inputs.muzzle_angle = zero_angle;
679 inputs.enable_trajectory_sampling = true;
680 inputs.sample_interval = SAMPLE_INTERVAL_M;
681
682 let mut solver = TrajectorySolver::new_with_station_presence(
683 inputs,
684 WindConditions::default(),
685 atmosphere.clone(),
686 load.station_presence,
687 );
688 solver.set_max_range(max_range_m);
689 solver.set_time_step(0.001);
690 solver.set_wind_segments(scenario.segments.clone());
691 let result = solver.solve().map_err(|e| WindScenarioError::SolveFailed {
692 scenario: scenario.name.clone(),
693 message: e.to_string(),
694 })?;
695 let samples = result.sampled_points.unwrap_or_default();
696 if samples.len() < 2 {
697 return Err(WindScenarioError::SolveFailed {
698 scenario: scenario.name.clone(),
699 message: "the trajectory produced too few sampled points".to_string(),
700 });
701 }
702 let mut holds = Vec::with_capacity(request.ranges_m.len());
703 for &range_m in &request.ranges_m {
704 let hold = interpolate_hold_mil(&samples, range_m).ok_or_else(|| {
705 WindScenarioError::SolveFailed {
706 scenario: scenario.name.clone(),
707 message: format!(
708 "the trajectory does not reach {range_m:.1} m (it was sampled to {:.1} m)",
709 samples.last().map_or(0.0, |s| s.distance_m)
710 ),
711 }
712 })?;
713 holds.push(hold);
714 }
715 per_scenario.push(holds);
716 }
717
718 let metric = request
719 .target
720 .map_or(CorridorMetric::Rectangular, TargetSpec::metric);
721 let nominal_index = request
722 .scenarios
723 .nominal
724 .as_deref()
725 .and_then(|name| scenarios.iter().position(|s| s.name == name));
726
727 let mut rows = Vec::with_capacity(request.ranges_m.len());
728 for (range_index, &range_m) in request.ranges_m.iter().enumerate() {
729 let points: Vec<(f64, f64)> = per_scenario
730 .iter()
731 .map(|holds| holds[range_index])
732 .collect();
733
734 let (elevation_min_mil, elevation_max_mil) = span(points.iter().map(|p| p.0));
735 let (windage_min_mil, windage_max_mil) = span(points.iter().map(|p| p.1));
736
737 let (minimax_elevation_mil, minimax_windage_mil) = match metric {
738 CorridorMetric::Rectangular => (
739 0.5 * (elevation_min_mil + elevation_max_mil),
740 0.5 * (windage_min_mil + windage_max_mil),
741 ),
742 CorridorMetric::Circular => minimum_enclosing_circle_center(&points),
743 };
744 let hold = (minimax_elevation_mil, minimax_windage_mil);
745
746 let worst_case_miss_mil = worst_case(&points, hold, metric);
747 let worst_case_elevation_miss_mil = points
748 .iter()
749 .fold(0.0_f64, |acc, p| acc.max((p.0 - hold.0).abs()));
750 let worst_case_windage_miss_mil = points
751 .iter()
752 .fold(0.0_f64, |acc, p| acc.max((p.1 - hold.1).abs()));
753 let worst_index = points
755 .iter()
756 .enumerate()
757 .fold((0usize, f64::NEG_INFINITY), |(best, best_d), (i, p)| {
758 let d = deviation(*p, hold, metric);
759 if d > best_d {
760 (i, d)
761 } else {
762 (best, best_d)
763 }
764 })
765 .0;
766
767 let nominal_worst_case_miss_mil =
768 nominal_index.map(|index| worst_case(&points, points[index], metric));
769
770 let fits_target = request.target.map(|target| {
771 let to_linear_m = |mil: f64| mil / 1000.0 * range_m;
772 match target {
773 TargetSpec::Rect { width_m, height_m } => {
774 to_linear_m(worst_case_elevation_miss_mil)
775 <= height_m / 2.0 + TARGET_FIT_EPSILON_M
776 && to_linear_m(worst_case_windage_miss_mil)
777 <= width_m / 2.0 + TARGET_FIT_EPSILON_M
778 }
779 TargetSpec::Circle { diameter_m } => {
780 to_linear_m(worst_case_miss_mil) <= diameter_m / 2.0 + TARGET_FIT_EPSILON_M
781 }
782 }
783 });
784
785 rows.push(CorridorRow {
786 range_m,
787 scenarios: scenario_names
788 .iter()
789 .zip(&points)
790 .map(|(name, point)| ScenarioHold {
791 name: name.clone(),
792 elevation_mil: point.0,
793 windage_mil: point.1,
794 })
795 .collect(),
796 elevation_min_mil,
797 elevation_max_mil,
798 windage_min_mil,
799 windage_max_mil,
800 minimax_elevation_mil,
801 minimax_windage_mil,
802 worst_case_miss_mil,
803 worst_case_elevation_miss_mil,
804 worst_case_windage_miss_mil,
805 worst_case_scenario: scenario_names[worst_index].clone(),
806 nominal_worst_case_miss_mil,
807 fits_target,
808 });
809 }
810
811 Ok(RobustHoldReportV1 {
812 version: ROBUST_HOLD_REPORT_VERSION,
813 ranges_m: request.ranges_m.clone(),
814 scenario_names,
815 nominal: request.scenarios.nominal.clone(),
816 target: request.target,
817 metric: match metric {
818 CorridorMetric::Rectangular => "rectangular".to_string(),
819 CorridorMetric::Circular => "circular".to_string(),
820 },
821 rows,
822 })
823}
824
825fn interpolate_hold_mil(
832 samples: &[crate::trajectory_sampling::TrajectorySample],
833 range_m: f64,
834) -> Option<(f64, f64)> {
835 if !range_m.is_finite() || range_m <= 0.0 {
836 return None;
837 }
838 let Bracket::Inside { lo, t } =
839 bracket_param(samples.len(), |i| samples[i].distance_m, range_m)
840 else {
841 return None;
842 };
843 let hi = lo + 1;
844 let lerp = |a: f64, b: f64| a + (b - a) * t;
845 Some((
846 lerp(samples[lo].drop_m, samples[hi].drop_m) / range_m * 1000.0,
847 lerp(samples[lo].wind_drift_m, samples[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
1026 .push_str("Target: none — the minimax hold uses the per-axis metric\n"),
1027 }
1028 out.push_str(
1029 "\nHolds are milliradians: elevation positive = hold UP, windage positive = \
1030 hold RIGHT.\nThe corridor is the span of the scenarios you supplied. It is \
1031 NOT a probability interval.\n\n",
1032 );
1033
1034 for row in &report.rows {
1035 out.push_str(&format!(
1036 "--- {:.0} {} ---\n",
1037 range_display(row.range_m),
1038 dist_unit
1039 ));
1040 out.push_str(" Scenario Elev(mil) Wind(mil)\n");
1041 for hold in &row.scenarios {
1042 out.push_str(&format!(
1043 " {:<20} {:>9.3} {:>9.3}\n",
1044 hold.name, hold.elevation_mil, hold.windage_mil
1045 ));
1046 }
1047 out.push_str(&format!(
1048 " Corridor {:>9.3} {:>9.3} (min)\n",
1049 row.elevation_min_mil, row.windage_min_mil
1050 ));
1051 out.push_str(&format!(
1052 " {:>9.3} {:>9.3} (max)\n",
1053 row.elevation_max_mil, row.windage_max_mil
1054 ));
1055 out.push_str(&format!(
1056 " Minimax hold {:>9.3} {:>9.3}\n",
1057 row.minimax_elevation_mil, row.minimax_windage_mil
1058 ));
1059 out.push_str(&format!(
1060 " Worst case from it {:>9.3} mil ({:.2} {}), scenario '{}'\n",
1061 row.worst_case_miss_mil,
1062 size_display(row.worst_case_miss_mil / 1000.0 * row.range_m),
1063 size_unit,
1064 row.worst_case_scenario
1065 ));
1066 if let Some(nominal_worst) = row.nominal_worst_case_miss_mil {
1067 out.push_str(&format!(
1068 " Holding the nominal {:>9.3} mil ({:.2} {})\n",
1069 nominal_worst,
1070 size_display(nominal_worst / 1000.0 * row.range_m),
1071 size_unit
1072 ));
1073 }
1074 if let Some(fits) = row.fits_target {
1075 out.push_str(&format!(
1076 " Fits target {}\n",
1077 if fits {
1078 "yes — one hold covers every scenario"
1079 } else {
1080 "NO — no single hold covers every scenario here"
1081 }
1082 ));
1083 }
1084 out.push('\n');
1085 }
1086 out
1087 }
1088 }
1089}
1090
1091#[cfg(test)]
1092mod tests {
1093 use super::*;
1094
1095 fn segments(tokens: &[&str]) -> Vec<WindSegment> {
1096 tokens
1097 .iter()
1098 .map(|token| crate::wind::parse_wind_segment_str(token, true).unwrap())
1099 .collect()
1100 }
1101
1102 fn scenario(name: &str, tokens: &[&str]) -> NamedWindScenario {
1103 NamedWindScenario {
1104 name: name.to_string(),
1105 segments: segments(tokens),
1106 }
1107 }
1108
1109 pub(super) fn test_load() -> CorridorLoad {
1110 CorridorLoad {
1111 muzzle_velocity_mps: 823.0,
1112 bc: 0.475,
1113 drag_model: DragModel::G1,
1114 mass_kg: 0.010_886,
1115 diameter_m: 0.007_823,
1116 bullet_length_m: 0.031,
1117 zero_distance_m: 91.44,
1118 sight_height_m: 0.0508,
1119 temperature_c: 15.0,
1120 pressure_hpa: 1013.25,
1121 humidity_pct: 50.0,
1122 altitude_m: 0.0,
1123 station_presence: crate::atmosphere::StationAtmospherePresence::NEITHER,
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!(r#"{{"version":1,"scenarios":[{}]}}"#, scenarios.join(","));
1472 assert_eq!(
1473 parse_wind_scenario_set(&doc, UnitSystem::Imperial).unwrap_err(),
1474 WindScenarioError::TooManyScenarios { count: 9, max: 8 }
1475 );
1476
1477 let err = parse_wind_scenario_set(
1478 r#"{"version":1,"scenarios":[{"name":"a","segments":["nope"]}]}"#,
1479 UnitSystem::Imperial,
1480 )
1481 .unwrap_err();
1482 assert!(
1483 matches!(err, WindScenarioError::MalformedSegment { .. }),
1484 "{err}"
1485 );
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 [
1527 "rect",
1528 "rect:12",
1529 "circle:-1",
1530 "blob:3",
1531 "rect:0x5",
1532 "circle:abc",
1533 ] {
1534 assert!(
1535 parse_target_spec(bad, UnitSystem::Imperial).is_err(),
1536 "'{bad}' should be rejected"
1537 );
1538 }
1539 }
1540
1541 #[test]
1542 fn minimum_enclosing_circle_is_exact_and_order_independent() {
1543 let points = vec![
1545 (1.0, 0.0),
1546 (-0.5, 0.866_025_403_784_438_6),
1547 (-0.5, -0.866_025_403_784_438_6),
1548 ];
1549 let center = minimum_enclosing_circle_center(&points);
1550 assert!(center.0.abs() < 1e-9 && center.1.abs() < 1e-9, "{center:?}");
1551
1552 let pair = vec![(0.0, 0.0), (2.0, 4.0)];
1554 assert_eq!(minimum_enclosing_circle_center(&pair), (1.0, 2.0));
1555
1556 let mut with_interior = points.clone();
1564 with_interior.push((0.1, -0.05));
1565 let a = minimum_enclosing_circle_center(&with_interior);
1566 with_interior.reverse();
1567 let b = minimum_enclosing_circle_center(&with_interior);
1568 assert!(
1569 (a.0 - b.0).abs() < 1e-12 && (a.1 - b.1).abs() < 1e-12,
1570 "{a:?} vs {b:?}"
1571 );
1572
1573 assert_eq!(minimum_enclosing_circle_center(&[(3.0, 4.0)]), (3.0, 4.0));
1574 assert_eq!(minimum_enclosing_circle_center(&[]), (0.0, 0.0));
1575 }
1576
1577 #[test]
1578 fn formatter_renders_both_shapes_and_json_is_the_wire_schema() {
1579 let mut req = request(
1580 vec![
1581 scenario("low", &["4:90:1000"]),
1582 scenario("high", &["14:90:1000"]),
1583 ],
1584 Some("low"),
1585 );
1586 req.target = Some(TargetSpec::Rect {
1587 width_m: 0.3,
1588 height_m: 0.5,
1589 });
1590 let report = solve_robust_hold(&req).unwrap();
1591
1592 let json = format_robust_hold_report(&report, RobustHoldFormat::Json, UnitSystem::Imperial);
1598 let back: RobustHoldReportV1 = serde_json::from_str(&json).unwrap();
1599 assert_eq!(back.version, report.version);
1600 assert_eq!(back.scenario_names, report.scenario_names);
1601 assert_eq!(back.nominal, report.nominal);
1602 assert_eq!(back.metric, report.metric);
1603 assert_eq!(back.rows.len(), report.rows.len());
1604 for (got, want) in back.rows.iter().zip(&report.rows) {
1605 assert_eq!(got.worst_case_scenario, want.worst_case_scenario);
1606 assert_eq!(got.fits_target, want.fits_target);
1607 assert_eq!(got.scenarios.len(), want.scenarios.len());
1608 for (a, b) in got.scenarios.iter().zip(&want.scenarios) {
1609 assert_eq!(a.name, b.name);
1610 assert!((a.elevation_mil - b.elevation_mil).abs() < 1e-12);
1611 assert!((a.windage_mil - b.windage_mil).abs() < 1e-12);
1612 }
1613 for (a, b) in [
1614 (got.range_m, want.range_m),
1615 (got.elevation_min_mil, want.elevation_min_mil),
1616 (got.elevation_max_mil, want.elevation_max_mil),
1617 (got.windage_min_mil, want.windage_min_mil),
1618 (got.windage_max_mil, want.windage_max_mil),
1619 (got.minimax_elevation_mil, want.minimax_elevation_mil),
1620 (got.minimax_windage_mil, want.minimax_windage_mil),
1621 (got.worst_case_miss_mil, want.worst_case_miss_mil),
1622 ] {
1623 assert!((a - b).abs() < 1e-12, "{a} vs {b}");
1624 }
1625 }
1626
1627 let table =
1628 format_robust_hold_report(&report, RobustHoldFormat::Table, UnitSystem::Imperial);
1629 assert!(table.contains("Robust Hold Corridor"));
1630 assert!(table.contains("Minimax hold"));
1631 assert!(table.contains("Holding the nominal"));
1632 assert!(table.contains("Fits target"));
1633 assert!(
1634 table.contains("NOT a probability interval"),
1635 "the non-goal must be stated where it is read: {table}"
1636 );
1637 assert!(table.ends_with('\n'));
1638 }
1639}