1use std::error::Error;
13
14use nalgebra::Vector3;
15use serde::Serialize;
16
17use crate::cli_api::UnitSystem;
18use crate::drag::DragTable;
19use crate::{
20 AtmosphericConditions, BallisticInputs, BallisticsError, DragModel, MonteCarloParams,
21 MonteCarloResults, TrajectorySolver, WindConditions,
22};
23
24#[derive(Debug, Clone, Copy, PartialEq)]
27pub enum TargetSize {
28 Rect { width: f64, height: f64 },
30 Radius(f64),
33}
34
35pub fn parse_target_size(spec: &str) -> Result<TargetSize, String> {
38 let trimmed = spec.trim();
39 if trimmed.is_empty() {
40 return Err("expected a size like \"18x30\" or a single radius like \"12\"".to_string());
41 }
42
43 let x_positions: Vec<usize> = trimmed
44 .char_indices()
45 .filter(|(_, c)| *c == 'x' || *c == 'X')
46 .map(|(i, _)| i)
47 .collect();
48
49 match x_positions.len() {
50 0 => {
51 let radius: f64 = trimmed
52 .parse()
53 .map_err(|_| format!("\"{trimmed}\" is not a number or a WIDTHxHEIGHT pair"))?;
54 if !(radius.is_finite() && radius > 0.0) {
55 return Err(format!(
56 "radius must be a positive, finite number, got {radius}"
57 ));
58 }
59 Ok(TargetSize::Radius(radius))
60 }
61 1 => {
62 let idx = x_positions[0];
63 let width_str = &trimmed[..idx];
64 let height_str = &trimmed[idx + 1..];
65 let width: f64 = width_str
66 .trim()
67 .parse()
68 .map_err(|_| format!("\"{}\" is not a valid width", width_str.trim()))?;
69 let height: f64 = height_str
70 .trim()
71 .parse()
72 .map_err(|_| format!("\"{}\" is not a valid height", height_str.trim()))?;
73 if !(width.is_finite() && width > 0.0 && height.is_finite() && height > 0.0) {
74 return Err(format!(
75 "width and height must be positive, finite numbers, got {width}x{height}"
76 ));
77 }
78 Ok(TargetSize::Rect { width, height })
79 }
80 _ => Err(format!(
81 "\"{trimmed}\" has more than one 'x' separator; expected WIDTHxHEIGHT or a single radius"
82 )),
83 }
84}
85
86#[derive(Debug, Clone, Copy, PartialEq)]
89pub enum TargetSizeMetric {
90 Rect { width_m: f64, height_m: f64 },
91 Radius { radius_m: f64 },
92}
93
94fn target_size_to_metric(val: f64, units: UnitSystem) -> f64 {
97 match units {
98 UnitSystem::Metric => val * 0.01, UnitSystem::Imperial => val * 0.0254, }
101}
102
103impl TargetSize {
104 pub fn to_metric(self, units: UnitSystem) -> TargetSizeMetric {
105 match self {
106 TargetSize::Rect { width, height } => TargetSizeMetric::Rect {
107 width_m: target_size_to_metric(width, units),
108 height_m: target_size_to_metric(height, units),
109 },
110 TargetSize::Radius(radius) => TargetSizeMetric::Radius {
111 radius_m: target_size_to_metric(radius, units),
112 },
113 }
114 }
115}
116
117#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
119#[serde(rename_all = "snake_case")]
120pub enum WezErrorBucket {
121 WindCall,
123 MvSd,
125 Other,
128}
129
130impl std::fmt::Display for WezErrorBucket {
131 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
132 let label = match self {
136 WezErrorBucket::WindCall => "wind_call",
137 WezErrorBucket::MvSd => "mv_sd",
138 WezErrorBucket::Other => "other",
139 };
140 write!(f, "{label}")
141 }
142}
143
144#[derive(Debug, Clone, Copy, Default, Serialize)]
148struct WezVarianceShares {
149 wind_call: f64,
150 mv_sd: f64,
151 other: f64,
152}
153
154impl WezVarianceShares {
155 fn dominant(&self) -> Option<WezErrorBucket> {
157 [
158 (WezErrorBucket::WindCall, self.wind_call),
159 (WezErrorBucket::MvSd, self.mv_sd),
160 (WezErrorBucket::Other, self.other),
161 ]
162 .into_iter()
163 .filter(|(_, share)| *share > 0.0)
164 .max_by(|a, b| a.1.total_cmp(&b.1))
165 .map(|(bucket, _)| bucket)
166 }
167}
168
169fn wez_solve_target_plane(
185 inputs: BallisticInputs,
186 wind: WindConditions,
187 atmosphere: AtmosphericConditions,
188 solver_max_range: f64,
189 target_distance_m: f64,
190) -> Result<Vector3<f64>, BallisticsError> {
191 let mut solver = TrajectorySolver::new(inputs, wind, atmosphere);
192 solver.set_max_range(solver_max_range);
193 let result = solver.solve()?;
194 Ok(result
199 .position_at_range(target_distance_m)
200 .expect("WEZ attribution solve: non-empty trajectory always has a last point"))
201}
202
203fn wez_source_variance(
210 sigma: f64,
211 inputs: BallisticInputs,
212 wind: WindConditions,
213 atmosphere: &AtmosphericConditions,
214 solver_max_range: f64,
215 target_distance_m: f64,
216 baseline: &Vector3<f64>,
217) -> Result<f64, BallisticsError> {
218 if sigma.is_nan() || sigma <= 0.0 {
219 return Ok(0.0);
220 }
221 let perturbed = wez_solve_target_plane(
222 inputs,
223 wind,
224 atmosphere.clone(),
225 solver_max_range,
226 target_distance_m,
227 )?;
228 let dy = perturbed.y - baseline.y;
229 let dz = perturbed.z - baseline.z;
230 Ok(dy * dy + dz * dz)
231}
232
233#[allow(
256 clippy::too_many_arguments,
257 reason = "flat arguments mirror the Monte Carlo sampler's own parameter set (MBA-1317)"
258)]
259fn wez_variance_shares(
260 base_inputs: &BallisticInputs,
261 base_wind: &WindConditions,
262 atmosphere: &AtmosphericConditions,
263 solver_max_range: f64,
264 target_distance_m: f64,
265 baseline: &Vector3<f64>,
266 velocity_std_dev: f64,
267 angle_std_dev_rad: f64,
268 bc_std_dev: f64,
269 azimuth_std_dev_rad: f64,
270 wind_speed_std_dev: f64,
271 wind_call_error_std_dev: f64,
272 wind_direction_std_dev_rad: f64,
273) -> Result<WezVarianceShares, BallisticsError> {
274 let mv_sd_var = {
276 let mut inputs = base_inputs.clone();
277 inputs.muzzle_velocity = (inputs.muzzle_velocity + velocity_std_dev).max(0.0);
278 wez_source_variance(
279 velocity_std_dev,
280 inputs,
281 base_wind.clone(),
282 atmosphere,
283 solver_max_range,
284 target_distance_m,
285 baseline,
286 )?
287 };
288
289 let mut other_var = 0.0;
292 {
293 let mut inputs = base_inputs.clone();
294 inputs.muzzle_angle += angle_std_dev_rad;
295 other_var += wez_source_variance(
296 angle_std_dev_rad,
297 inputs,
298 base_wind.clone(),
299 atmosphere,
300 solver_max_range,
301 target_distance_m,
302 baseline,
303 )?;
304 }
305 {
306 let mut inputs = base_inputs.clone();
307 inputs.bc_value = (inputs.bc_value + bc_std_dev).max(0.01);
308 other_var += wez_source_variance(
309 bc_std_dev,
310 inputs,
311 base_wind.clone(),
312 atmosphere,
313 solver_max_range,
314 target_distance_m,
315 baseline,
316 )?;
317 }
318 {
319 let mut inputs = base_inputs.clone();
320 inputs.azimuth_angle += azimuth_std_dev_rad;
321 other_var += wez_source_variance(
322 azimuth_std_dev_rad,
323 inputs,
324 base_wind.clone(),
325 atmosphere,
326 solver_max_range,
327 target_distance_m,
328 baseline,
329 )?;
330 }
331 {
332 let mut wind = base_wind.clone();
333 wind.direction += wind_direction_std_dev_rad;
334 other_var += wez_source_variance(
335 wind_direction_std_dev_rad,
336 base_inputs.clone(),
337 wind,
338 atmosphere,
339 solver_max_range,
340 target_distance_m,
341 baseline,
342 )?;
343 }
344 {
345 let mut wind = base_wind.clone();
346 wind.speed += wind_speed_std_dev;
347 other_var += wez_source_variance(
348 wind_speed_std_dev,
349 base_inputs.clone(),
350 wind,
351 atmosphere,
352 solver_max_range,
353 target_distance_m,
354 baseline,
355 )?;
356 }
357
358 let wind_call_var = {
361 let mut wind = base_wind.clone();
362 wind.speed += wind_call_error_std_dev;
363 wez_source_variance(
364 wind_call_error_std_dev,
365 base_inputs.clone(),
366 wind,
367 atmosphere,
368 solver_max_range,
369 target_distance_m,
370 baseline,
371 )?
372 };
373
374 let total = wind_call_var + mv_sd_var + other_var;
375 if total.is_nan() || total <= 0.0 {
376 return Ok(WezVarianceShares::default());
377 }
378 Ok(WezVarianceShares {
379 wind_call: wind_call_var / total,
380 mv_sd: mv_sd_var / total,
381 other: other_var / total,
382 })
383}
384
385fn wez_p_hit(
402 results: &MonteCarloResults,
403 baseline: &Vector3<f64>,
404 line_of_sight_height_m: f64,
405 target_size: TargetSizeMetric,
406) -> f64 {
407 if results.impact_positions.is_empty() {
408 return 0.0;
409 }
410 let hits = results
411 .impact_positions
412 .iter()
413 .filter(|deviation| {
414 let absolute_y = baseline.y + deviation.y;
415 let absolute_z = baseline.z + deviation.z;
416 let drop_from_los = absolute_y - line_of_sight_height_m;
417 match target_size {
418 TargetSizeMetric::Rect { width_m, height_m } => {
419 drop_from_los.abs() <= height_m / 2.0 && absolute_z.abs() <= width_m / 2.0
420 }
421 TargetSizeMetric::Radius { radius_m } => {
422 (drop_from_los * drop_from_los + absolute_z * absolute_z).sqrt() <= radius_m
423 }
424 }
425 })
426 .count();
427 hits as f64 / results.impact_positions.len() as f64
428}
429
430#[derive(Debug, Clone, Serialize)]
432pub struct WezRow {
433 pub range_m: f64,
434 pub p_hit: f64,
435 pub dominant_error_source: Option<WezErrorBucket>,
436 pub wind_call_share: f64,
437 pub mv_sd_share: f64,
438 pub other_share: f64,
439 pub attribution_unavailable: bool,
443}
444
445#[derive(Debug, Clone, Serialize)]
446pub struct WezTargetSizeJson {
447 #[serde(skip_serializing_if = "Option::is_none")]
448 pub width_m: Option<f64>,
449 #[serde(skip_serializing_if = "Option::is_none")]
450 pub height_m: Option<f64>,
451 #[serde(skip_serializing_if = "Option::is_none")]
452 pub radius_m: Option<f64>,
453}
454
455#[derive(Debug, Clone, Serialize)]
456pub struct WezResult {
457 pub target_size: WezTargetSizeJson,
458 pub wind_speed_std_mps: f64,
459 pub wind_call_error_mps: f64,
460 pub combined_wind_speed_std_mps: f64,
463 pub num_sims_per_step: usize,
464 pub rows: Vec<WezRow>,
465}
466
467#[allow(
514 clippy::too_many_arguments,
515 reason = "flat arguments mirror the stable Monte Carlo CLI command shape (MBA-1317)"
516)]
517pub fn compute_wez(
518 velocity: f64,
519 angle: f64,
520 bc: f64,
521 mass: f64,
522 diameter: f64,
523 num_sims: usize,
524 velocity_std: f64,
525 angle_std: f64,
526 bc_std: f64,
527 wind_std: f64,
528 wind_direction_std: f64,
529 wind_speed: f64,
530 wind_direction: f64,
531 wind_vertical: f64,
532 wind_call_error: f64,
533 target_size: TargetSizeMetric,
534 wez_start: f64,
535 wez_end: f64,
536 wez_step: f64,
537 drag_model: DragModel,
538 custom_drag_table: Option<DragTable>,
539 cd_scale: f64,
540 cant: f64,
541 sight_offset_lateral_m: f64,
542) -> Result<WezResult, Box<dyn Error>> {
543 if !(wez_step > 0.0 && wez_step.is_finite()) {
544 return Err("--wez-step must be a positive, finite distance".into());
545 }
546 if !wez_start.is_finite() || !wez_end.is_finite() || wez_end < wez_start {
547 return Err("--wez-end must be finite and >= --wez-start".into());
548 }
549
550 let bore_height_metric = 1.5_f64;
552 let base_inputs = BallisticInputs {
553 muzzle_velocity: velocity,
554 muzzle_angle: angle.to_radians(),
555 bc_value: bc,
556 bc_type: drag_model,
557 bullet_mass: mass,
558 bullet_diameter: diameter,
559 muzzle_height: bore_height_metric,
560 ground_threshold: 0.0,
561 custom_drag_table,
562 cd_scale,
563 cant_angle: cant.to_radians(),
564 sight_offset_lateral_m,
565 ..Default::default()
566 };
567 let base_wind = WindConditions {
568 speed: wind_speed,
569 direction: wind_direction.to_radians(),
570 vertical_speed: wind_vertical,
571 };
572
573 let combined_wind_speed_std = wind_std.hypot(wind_call_error);
579
580 let angle_std_rad = angle_std.to_radians();
583 let azimuth_std_dev = angle_std_rad * 0.5;
584 let wind_direction_std_rad = wind_direction_std.to_radians();
585
586 let atmosphere = AtmosphericConditions {
593 temperature: base_inputs.temperature,
594 pressure: base_inputs.pressure,
595 humidity: base_inputs.humidity_percent(),
596 altitude: base_inputs.altitude,
597 };
598 let line_of_sight_height_m = base_inputs.muzzle_height + base_inputs.sight_height;
599
600 let mut ranges_m = Vec::new();
601 let mut next = wez_start;
602 for _ in 0..100_000 {
605 if next > wez_end + wez_step * 1e-9 {
606 break;
607 }
608 ranges_m.push(next);
609 next += wez_step;
610 }
611
612 let mut rows = Vec::with_capacity(ranges_m.len());
613 for (step_index, &range_m) in ranges_m.iter().enumerate() {
614 let solver_max_range = range_m.max(1000.0) * 2.0;
615 let baseline = wez_solve_target_plane(
616 base_inputs.clone(),
617 base_wind.clone(),
618 atmosphere.clone(),
619 solver_max_range,
620 range_m,
621 )?;
622 let baseline_reached = baseline.x >= range_m - 1e-6;
623
624 let mc_params = MonteCarloParams {
625 num_simulations: num_sims,
626 velocity_std_dev: velocity_std,
627 angle_std_dev: angle_std_rad,
628 bc_std_dev: bc_std,
629 wind_speed_std_dev: combined_wind_speed_std,
630 target_distance: Some(range_m),
631 base_wind_speed: wind_speed,
632 base_wind_direction: wind_direction.to_radians(),
633 azimuth_std_dev,
634 };
635
636 let seed = 0x57_45_5A_00_u64 ^ (step_index as u64);
639 let p_hit = match crate::run_monte_carlo_with_wind_and_direction_std_dev_seeded(
640 base_inputs.clone(),
641 base_wind.clone(),
642 mc_params,
643 wind_direction_std_rad,
644 seed,
645 ) {
646 Ok(results) => {
647 wez_p_hit(&results, &baseline, line_of_sight_height_m, target_size)
648 }
649 Err(_) => 0.0,
652 };
653
654 let (shares, attribution_unavailable) = if baseline_reached {
655 (
656 wez_variance_shares(
657 &base_inputs,
658 &base_wind,
659 &atmosphere,
660 solver_max_range,
661 range_m,
662 &baseline,
663 velocity_std,
664 angle_std_rad,
665 bc_std,
666 azimuth_std_dev,
667 wind_std,
668 wind_call_error,
669 wind_direction_std_rad,
670 )?,
671 false,
672 )
673 } else {
674 (WezVarianceShares::default(), true)
675 };
676
677 rows.push(WezRow {
678 range_m,
679 p_hit,
680 dominant_error_source: shares.dominant(),
681 wind_call_share: shares.wind_call,
682 mv_sd_share: shares.mv_sd,
683 other_share: shares.other,
684 attribution_unavailable,
685 });
686 }
687
688 Ok(WezResult {
689 target_size: match target_size {
690 TargetSizeMetric::Rect { width_m, height_m } => WezTargetSizeJson {
691 width_m: Some(width_m),
692 height_m: Some(height_m),
693 radius_m: None,
694 },
695 TargetSizeMetric::Radius { radius_m } => WezTargetSizeJson {
696 width_m: None,
697 height_m: None,
698 radius_m: Some(radius_m),
699 },
700 },
701 wind_speed_std_mps: wind_std,
702 wind_call_error_mps: wind_call_error,
703 combined_wind_speed_std_mps: combined_wind_speed_std,
704 num_sims_per_step: num_sims,
705 rows,
706 })
707}
708
709#[cfg(test)]
710mod wez_tests {
711 use super::*;
712
713 fn test_base_inputs() -> BallisticInputs {
717 BallisticInputs {
718 muzzle_velocity: 823.0, muzzle_angle: 0.001274, bc_value: 0.475,
721 bullet_mass: 0.010_886, bullet_diameter: 0.007_82, muzzle_height: 1.5,
724 ground_threshold: 0.0,
725 ..Default::default()
726 }
727 }
728
729 fn test_atmosphere(inputs: &BallisticInputs) -> AtmosphericConditions {
730 AtmosphericConditions {
731 temperature: inputs.temperature,
732 pressure: inputs.pressure,
733 humidity: inputs.humidity_percent(),
734 altitude: inputs.altitude,
735 }
736 }
737
738 #[test]
741 fn parse_target_size_accepts_a_wxh_rectangle() {
742 assert_eq!(
743 parse_target_size("18x30").unwrap(),
744 TargetSize::Rect {
745 width: 18.0,
746 height: 30.0
747 }
748 );
749 assert_eq!(
751 parse_target_size(" 18.5X30.25 ").unwrap(),
752 TargetSize::Rect {
753 width: 18.5,
754 height: 30.25
755 }
756 );
757 }
758
759 #[test]
760 fn parse_target_size_accepts_a_single_radius() {
761 assert_eq!(parse_target_size("12").unwrap(), TargetSize::Radius(12.0));
762 assert_eq!(parse_target_size(" 0.5 ").unwrap(), TargetSize::Radius(0.5));
763 }
764
765 #[test]
766 fn parse_target_size_rejects_garbage() {
767 for bad in [
768 "",
769 " ",
770 "abc",
771 "18xthirty",
772 "eighteenx30",
773 "18x30x40",
774 "0",
775 "-5",
776 "18x-5",
777 "18x0",
778 "NaN",
779 ] {
780 assert!(
781 parse_target_size(bad).is_err(),
782 "expected an error for {bad:?}"
783 );
784 }
785 }
786
787 #[test]
790 fn zero_uncertainty_is_a_step_function_in_range() {
791 let inputs = test_base_inputs();
792 let wind = WindConditions::default();
793 let atmosphere = test_atmosphere(&inputs);
794 let target = TargetSizeMetric::Rect {
796 width_m: 0.4572,
797 height_m: 0.762,
798 };
799 let los_height_m = inputs.muzzle_height + inputs.sight_height;
800
801 let mc_params = MonteCarloParams {
802 num_simulations: 20,
803 velocity_std_dev: 0.0,
804 angle_std_dev: 0.0,
805 bc_std_dev: 0.0,
806 wind_speed_std_dev: 0.0,
807 target_distance: None,
808 base_wind_speed: 0.0,
809 base_wind_direction: 0.0,
810 azimuth_std_dev: 0.0,
811 };
812
813 let mut p_hits = Vec::new();
814 for &range_m in &[50.0_f64, 100.0, 150.0, 200.0, 250.0, 300.0, 350.0, 400.0] {
815 let solver_max_range = range_m.max(1000.0) * 2.0;
816 let baseline = wez_solve_target_plane(
817 inputs.clone(),
818 wind.clone(),
819 atmosphere.clone(),
820 solver_max_range,
821 range_m,
822 )
823 .expect("valid test baseline solve");
824 let mut params = mc_params.clone();
825 params.target_distance = Some(range_m);
826 let results = crate::run_monte_carlo_with_wind_and_direction_std_dev_seeded(
827 inputs.clone(),
828 wind.clone(),
829 params,
830 0.0,
831 0xA11CE,
832 )
833 .expect("zero-uncertainty solve");
834 let p_hit = wez_p_hit(&results, &baseline, los_height_m, target);
835 assert!(
838 p_hit == 0.0 || p_hit == 1.0,
839 "range {range_m} m: expected a step (0.0 or 1.0), got {p_hit}"
840 );
841 p_hits.push((range_m, p_hit));
842 }
843
844 assert!(
845 p_hits.iter().any(|&(_, p)| p == 1.0),
846 "expected at least one in-box range close to the muzzle: {p_hits:?}"
847 );
848 assert!(
849 p_hits.iter().any(|&(_, p)| p == 0.0),
850 "expected at least one out-of-box range far downrange: {p_hits:?}"
851 );
852 let first_miss = p_hits.iter().position(|&(_, p)| p == 0.0);
855 if let Some(idx) = first_miss {
856 assert!(
857 p_hits[idx..].iter().all(|&(_, p)| p == 0.0),
858 "expected the box exit to be permanent for the rest of the sweep: {p_hits:?}"
859 );
860 }
861 }
862
863 #[test]
866 fn p_hit_is_monotone_non_increasing_with_range() {
867 let inputs = test_base_inputs();
868 let wind = WindConditions::default();
869 let atmosphere = test_atmosphere(&inputs);
870 let target = TargetSizeMetric::Rect {
871 width_m: 0.4572,
872 height_m: 0.762,
873 };
874 let los_height_m = inputs.muzzle_height + inputs.sight_height;
875 let wind_call_error = 1.5_f64; let wind_std = 0.5_f64; let combined_wind_std = wind_std.hypot(wind_call_error);
878
879 let mc_params = MonteCarloParams {
880 num_simulations: 500, velocity_std_dev: 1.0,
882 angle_std_dev: 0.001,
883 bc_std_dev: 0.01,
884 wind_speed_std_dev: combined_wind_std,
885 target_distance: None,
886 base_wind_speed: 0.0,
887 base_wind_direction: 0.0,
888 azimuth_std_dev: 0.0005,
889 };
890
891 let ranges_m = [100.0_f64, 200.0, 300.0, 400.0, 500.0, 600.0];
892 let mut p_hits = Vec::new();
893 for (step_index, &range_m) in ranges_m.iter().enumerate() {
894 let solver_max_range = range_m.max(1000.0) * 2.0;
895 let baseline = wez_solve_target_plane(
896 inputs.clone(),
897 wind.clone(),
898 atmosphere.clone(),
899 solver_max_range,
900 range_m,
901 )
902 .expect("valid test baseline solve");
903 let mut params = mc_params.clone();
904 params.target_distance = Some(range_m);
905 let seed = 0x57_45_5A_00_u64 ^ (step_index as u64);
906 let results = crate::run_monte_carlo_with_wind_and_direction_std_dev_seeded(
907 inputs.clone(),
908 wind.clone(),
909 params,
910 0.0,
911 seed,
912 )
913 .expect("dispersed solve");
914 p_hits.push(wez_p_hit(&results, &baseline, los_height_m, target));
915 }
916
917 let tolerance = 0.03;
922 for pair in p_hits.windows(2) {
923 assert!(
924 pair[1] <= pair[0] + tolerance,
925 "P(hit) rose more than the allowed jitter: {p_hits:?}"
926 );
927 }
928 assert!(
930 p_hits.first().unwrap() - p_hits.last().unwrap() > 0.2,
931 "expected a clear overall decline across the sweep: {p_hits:?}"
932 );
933 }
934
935 #[test]
938 fn variance_shares_sum_to_one_when_multiple_sources_are_active() {
939 let inputs = test_base_inputs();
940 let wind = WindConditions::default();
941 let atmosphere = test_atmosphere(&inputs);
942 let range_m: f64 = 300.0;
943 let solver_max_range = range_m.max(1000.0) * 2.0;
944 let baseline = wez_solve_target_plane(
945 inputs.clone(),
946 wind.clone(),
947 atmosphere.clone(),
948 solver_max_range,
949 range_m,
950 )
951 .expect("valid test baseline solve");
952
953 let shares = wez_variance_shares(
954 &inputs,
955 &wind,
956 &atmosphere,
957 solver_max_range,
958 range_m,
959 &baseline,
960 1.0,
961 0.001,
962 0.01,
963 0.0005,
964 0.4,
965 1.2,
966 0.02,
967 )
968 .expect("valid test attribution solve");
969
970 let sum = shares.wind_call + shares.mv_sd + shares.other;
971 assert!(
972 (sum - 1.0).abs() < 1e-9,
973 "shares should sum to ~1.0, got {sum} ({shares:?})"
974 );
975 for share in [shares.wind_call, shares.mv_sd, shares.other] {
976 assert!((0.0..=1.0).contains(&share), "share out of range: {share}");
977 }
978 assert!(shares.dominant().is_some());
979 }
980
981 #[test]
982 fn variance_shares_are_all_zero_with_no_dispersion_sources() {
983 let inputs = test_base_inputs();
984 let wind = WindConditions::default();
985 let atmosphere = test_atmosphere(&inputs);
986 let range_m: f64 = 300.0;
987 let solver_max_range = range_m.max(1000.0) * 2.0;
988 let baseline = wez_solve_target_plane(
989 inputs.clone(),
990 wind.clone(),
991 atmosphere.clone(),
992 solver_max_range,
993 range_m,
994 )
995 .expect("valid test baseline solve");
996
997 let shares = wez_variance_shares(
998 &inputs,
999 &wind,
1000 &atmosphere,
1001 solver_max_range,
1002 range_m,
1003 &baseline,
1004 0.0,
1005 0.0,
1006 0.0,
1007 0.0,
1008 0.0,
1009 0.0,
1010 0.0,
1011 )
1012 .expect("valid test attribution solve");
1013
1014 assert_eq!(shares.wind_call, 0.0);
1015 assert_eq!(shares.mv_sd, 0.0);
1016 assert_eq!(shares.other, 0.0);
1017 assert!(shares.dominant().is_none());
1018 }
1019
1020 #[test]
1021 fn wind_call_bucket_dominates_when_it_is_the_only_active_source() {
1022 let inputs = test_base_inputs();
1023 let wind = WindConditions::default();
1024 let atmosphere = test_atmosphere(&inputs);
1025 let range_m: f64 = 300.0;
1026 let solver_max_range = range_m.max(1000.0) * 2.0;
1027 let baseline = wez_solve_target_plane(
1028 inputs.clone(),
1029 wind.clone(),
1030 atmosphere.clone(),
1031 solver_max_range,
1032 range_m,
1033 )
1034 .expect("valid test baseline solve");
1035
1036 let shares = wez_variance_shares(
1037 &inputs,
1038 &wind,
1039 &atmosphere,
1040 solver_max_range,
1041 range_m,
1042 &baseline,
1043 0.0,
1044 0.0,
1045 0.0,
1046 0.0,
1047 0.0,
1048 3.0,
1049 0.0,
1050 )
1051 .expect("valid test attribution solve");
1052
1053 assert!((shares.wind_call - 1.0).abs() < 1e-9);
1054 assert_eq!(shares.mv_sd, 0.0);
1055 assert_eq!(shares.other, 0.0);
1056 assert_eq!(shares.dominant(), Some(WezErrorBucket::WindCall));
1057 }
1058}