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(
511 clippy::too_many_arguments,
512 reason = "flat arguments mirror the stable Monte Carlo CLI command shape (MBA-1317)"
513)]
514pub fn compute_wez(
515 velocity: f64,
516 angle: f64,
517 bc: f64,
518 mass: f64,
519 diameter: f64,
520 num_sims: usize,
521 velocity_std: f64,
522 angle_std: f64,
523 bc_std: f64,
524 wind_std: f64,
525 wind_direction_std: f64,
526 wind_speed: f64,
527 wind_direction: f64,
528 wind_vertical: f64,
529 wind_call_error: f64,
530 target_size: TargetSizeMetric,
531 wez_start: f64,
532 wez_end: f64,
533 wez_step: f64,
534 drag_model: DragModel,
535 custom_drag_table: Option<DragTable>,
536 cd_scale: f64,
537 cant: f64,
538) -> Result<WezResult, Box<dyn Error>> {
539 if !(wez_step > 0.0 && wez_step.is_finite()) {
540 return Err("--wez-step must be a positive, finite distance".into());
541 }
542 if !wez_start.is_finite() || !wez_end.is_finite() || wez_end < wez_start {
543 return Err("--wez-end must be finite and >= --wez-start".into());
544 }
545
546 let bore_height_metric = 1.5_f64;
548 let base_inputs = BallisticInputs {
549 muzzle_velocity: velocity,
550 muzzle_angle: angle.to_radians(),
551 bc_value: bc,
552 bc_type: drag_model,
553 bullet_mass: mass,
554 bullet_diameter: diameter,
555 muzzle_height: bore_height_metric,
556 ground_threshold: 0.0,
557 custom_drag_table,
558 cd_scale,
559 cant_angle: cant.to_radians(),
560 ..Default::default()
561 };
562 let base_wind = WindConditions {
563 speed: wind_speed,
564 direction: wind_direction.to_radians(),
565 vertical_speed: wind_vertical,
566 };
567
568 let combined_wind_speed_std = wind_std.hypot(wind_call_error);
574
575 let angle_std_rad = angle_std.to_radians();
578 let azimuth_std_dev = angle_std_rad * 0.5;
579 let wind_direction_std_rad = wind_direction_std.to_radians();
580
581 let atmosphere = AtmosphericConditions {
588 temperature: base_inputs.temperature,
589 pressure: base_inputs.pressure,
590 humidity: base_inputs.humidity_percent(),
591 altitude: base_inputs.altitude,
592 };
593 let line_of_sight_height_m = base_inputs.muzzle_height + base_inputs.sight_height;
594
595 let mut ranges_m = Vec::new();
596 let mut next = wez_start;
597 for _ in 0..100_000 {
600 if next > wez_end + wez_step * 1e-9 {
601 break;
602 }
603 ranges_m.push(next);
604 next += wez_step;
605 }
606
607 let mut rows = Vec::with_capacity(ranges_m.len());
608 for (step_index, &range_m) in ranges_m.iter().enumerate() {
609 let solver_max_range = range_m.max(1000.0) * 2.0;
610 let baseline = wez_solve_target_plane(
611 base_inputs.clone(),
612 base_wind.clone(),
613 atmosphere.clone(),
614 solver_max_range,
615 range_m,
616 )?;
617 let baseline_reached = baseline.x >= range_m - 1e-6;
618
619 let mc_params = MonteCarloParams {
620 num_simulations: num_sims,
621 velocity_std_dev: velocity_std,
622 angle_std_dev: angle_std_rad,
623 bc_std_dev: bc_std,
624 wind_speed_std_dev: combined_wind_speed_std,
625 target_distance: Some(range_m),
626 base_wind_speed: wind_speed,
627 base_wind_direction: wind_direction.to_radians(),
628 azimuth_std_dev,
629 };
630
631 let seed = 0x57_45_5A_00_u64 ^ (step_index as u64);
634 let p_hit = match crate::run_monte_carlo_with_wind_and_direction_std_dev_seeded(
635 base_inputs.clone(),
636 base_wind.clone(),
637 mc_params,
638 wind_direction_std_rad,
639 seed,
640 ) {
641 Ok(results) => {
642 wez_p_hit(&results, &baseline, line_of_sight_height_m, target_size)
643 }
644 Err(_) => 0.0,
647 };
648
649 let (shares, attribution_unavailable) = if baseline_reached {
650 (
651 wez_variance_shares(
652 &base_inputs,
653 &base_wind,
654 &atmosphere,
655 solver_max_range,
656 range_m,
657 &baseline,
658 velocity_std,
659 angle_std_rad,
660 bc_std,
661 azimuth_std_dev,
662 wind_std,
663 wind_call_error,
664 wind_direction_std_rad,
665 )?,
666 false,
667 )
668 } else {
669 (WezVarianceShares::default(), true)
670 };
671
672 rows.push(WezRow {
673 range_m,
674 p_hit,
675 dominant_error_source: shares.dominant(),
676 wind_call_share: shares.wind_call,
677 mv_sd_share: shares.mv_sd,
678 other_share: shares.other,
679 attribution_unavailable,
680 });
681 }
682
683 Ok(WezResult {
684 target_size: match target_size {
685 TargetSizeMetric::Rect { width_m, height_m } => WezTargetSizeJson {
686 width_m: Some(width_m),
687 height_m: Some(height_m),
688 radius_m: None,
689 },
690 TargetSizeMetric::Radius { radius_m } => WezTargetSizeJson {
691 width_m: None,
692 height_m: None,
693 radius_m: Some(radius_m),
694 },
695 },
696 wind_speed_std_mps: wind_std,
697 wind_call_error_mps: wind_call_error,
698 combined_wind_speed_std_mps: combined_wind_speed_std,
699 num_sims_per_step: num_sims,
700 rows,
701 })
702}
703
704#[cfg(test)]
705mod wez_tests {
706 use super::*;
707
708 fn test_base_inputs() -> BallisticInputs {
712 BallisticInputs {
713 muzzle_velocity: 823.0, muzzle_angle: 0.001274, bc_value: 0.475,
716 bullet_mass: 0.010_886, bullet_diameter: 0.007_82, muzzle_height: 1.5,
719 ground_threshold: 0.0,
720 ..Default::default()
721 }
722 }
723
724 fn test_atmosphere(inputs: &BallisticInputs) -> AtmosphericConditions {
725 AtmosphericConditions {
726 temperature: inputs.temperature,
727 pressure: inputs.pressure,
728 humidity: inputs.humidity_percent(),
729 altitude: inputs.altitude,
730 }
731 }
732
733 #[test]
736 fn parse_target_size_accepts_a_wxh_rectangle() {
737 assert_eq!(
738 parse_target_size("18x30").unwrap(),
739 TargetSize::Rect {
740 width: 18.0,
741 height: 30.0
742 }
743 );
744 assert_eq!(
746 parse_target_size(" 18.5X30.25 ").unwrap(),
747 TargetSize::Rect {
748 width: 18.5,
749 height: 30.25
750 }
751 );
752 }
753
754 #[test]
755 fn parse_target_size_accepts_a_single_radius() {
756 assert_eq!(parse_target_size("12").unwrap(), TargetSize::Radius(12.0));
757 assert_eq!(parse_target_size(" 0.5 ").unwrap(), TargetSize::Radius(0.5));
758 }
759
760 #[test]
761 fn parse_target_size_rejects_garbage() {
762 for bad in [
763 "",
764 " ",
765 "abc",
766 "18xthirty",
767 "eighteenx30",
768 "18x30x40",
769 "0",
770 "-5",
771 "18x-5",
772 "18x0",
773 "NaN",
774 ] {
775 assert!(
776 parse_target_size(bad).is_err(),
777 "expected an error for {bad:?}"
778 );
779 }
780 }
781
782 #[test]
785 fn zero_uncertainty_is_a_step_function_in_range() {
786 let inputs = test_base_inputs();
787 let wind = WindConditions::default();
788 let atmosphere = test_atmosphere(&inputs);
789 let target = TargetSizeMetric::Rect {
791 width_m: 0.4572,
792 height_m: 0.762,
793 };
794 let los_height_m = inputs.muzzle_height + inputs.sight_height;
795
796 let mc_params = MonteCarloParams {
797 num_simulations: 20,
798 velocity_std_dev: 0.0,
799 angle_std_dev: 0.0,
800 bc_std_dev: 0.0,
801 wind_speed_std_dev: 0.0,
802 target_distance: None,
803 base_wind_speed: 0.0,
804 base_wind_direction: 0.0,
805 azimuth_std_dev: 0.0,
806 };
807
808 let mut p_hits = Vec::new();
809 for &range_m in &[50.0_f64, 100.0, 150.0, 200.0, 250.0, 300.0, 350.0, 400.0] {
810 let solver_max_range = range_m.max(1000.0) * 2.0;
811 let baseline = wez_solve_target_plane(
812 inputs.clone(),
813 wind.clone(),
814 atmosphere.clone(),
815 solver_max_range,
816 range_m,
817 )
818 .expect("valid test baseline solve");
819 let mut params = mc_params.clone();
820 params.target_distance = Some(range_m);
821 let results = crate::run_monte_carlo_with_wind_and_direction_std_dev_seeded(
822 inputs.clone(),
823 wind.clone(),
824 params,
825 0.0,
826 0xA11CE,
827 )
828 .expect("zero-uncertainty solve");
829 let p_hit = wez_p_hit(&results, &baseline, los_height_m, target);
830 assert!(
833 p_hit == 0.0 || p_hit == 1.0,
834 "range {range_m} m: expected a step (0.0 or 1.0), got {p_hit}"
835 );
836 p_hits.push((range_m, p_hit));
837 }
838
839 assert!(
840 p_hits.iter().any(|&(_, p)| p == 1.0),
841 "expected at least one in-box range close to the muzzle: {p_hits:?}"
842 );
843 assert!(
844 p_hits.iter().any(|&(_, p)| p == 0.0),
845 "expected at least one out-of-box range far downrange: {p_hits:?}"
846 );
847 let first_miss = p_hits.iter().position(|&(_, p)| p == 0.0);
850 if let Some(idx) = first_miss {
851 assert!(
852 p_hits[idx..].iter().all(|&(_, p)| p == 0.0),
853 "expected the box exit to be permanent for the rest of the sweep: {p_hits:?}"
854 );
855 }
856 }
857
858 #[test]
861 fn p_hit_is_monotone_non_increasing_with_range() {
862 let inputs = test_base_inputs();
863 let wind = WindConditions::default();
864 let atmosphere = test_atmosphere(&inputs);
865 let target = TargetSizeMetric::Rect {
866 width_m: 0.4572,
867 height_m: 0.762,
868 };
869 let los_height_m = inputs.muzzle_height + inputs.sight_height;
870 let wind_call_error = 1.5_f64; let wind_std = 0.5_f64; let combined_wind_std = wind_std.hypot(wind_call_error);
873
874 let mc_params = MonteCarloParams {
875 num_simulations: 500, velocity_std_dev: 1.0,
877 angle_std_dev: 0.001,
878 bc_std_dev: 0.01,
879 wind_speed_std_dev: combined_wind_std,
880 target_distance: None,
881 base_wind_speed: 0.0,
882 base_wind_direction: 0.0,
883 azimuth_std_dev: 0.0005,
884 };
885
886 let ranges_m = [100.0_f64, 200.0, 300.0, 400.0, 500.0, 600.0];
887 let mut p_hits = Vec::new();
888 for (step_index, &range_m) in ranges_m.iter().enumerate() {
889 let solver_max_range = range_m.max(1000.0) * 2.0;
890 let baseline = wez_solve_target_plane(
891 inputs.clone(),
892 wind.clone(),
893 atmosphere.clone(),
894 solver_max_range,
895 range_m,
896 )
897 .expect("valid test baseline solve");
898 let mut params = mc_params.clone();
899 params.target_distance = Some(range_m);
900 let seed = 0x57_45_5A_00_u64 ^ (step_index as u64);
901 let results = crate::run_monte_carlo_with_wind_and_direction_std_dev_seeded(
902 inputs.clone(),
903 wind.clone(),
904 params,
905 0.0,
906 seed,
907 )
908 .expect("dispersed solve");
909 p_hits.push(wez_p_hit(&results, &baseline, los_height_m, target));
910 }
911
912 let tolerance = 0.03;
917 for pair in p_hits.windows(2) {
918 assert!(
919 pair[1] <= pair[0] + tolerance,
920 "P(hit) rose more than the allowed jitter: {p_hits:?}"
921 );
922 }
923 assert!(
925 p_hits.first().unwrap() - p_hits.last().unwrap() > 0.2,
926 "expected a clear overall decline across the sweep: {p_hits:?}"
927 );
928 }
929
930 #[test]
933 fn variance_shares_sum_to_one_when_multiple_sources_are_active() {
934 let inputs = test_base_inputs();
935 let wind = WindConditions::default();
936 let atmosphere = test_atmosphere(&inputs);
937 let range_m: f64 = 300.0;
938 let solver_max_range = range_m.max(1000.0) * 2.0;
939 let baseline = wez_solve_target_plane(
940 inputs.clone(),
941 wind.clone(),
942 atmosphere.clone(),
943 solver_max_range,
944 range_m,
945 )
946 .expect("valid test baseline solve");
947
948 let shares = wez_variance_shares(
949 &inputs,
950 &wind,
951 &atmosphere,
952 solver_max_range,
953 range_m,
954 &baseline,
955 1.0,
956 0.001,
957 0.01,
958 0.0005,
959 0.4,
960 1.2,
961 0.02,
962 )
963 .expect("valid test attribution solve");
964
965 let sum = shares.wind_call + shares.mv_sd + shares.other;
966 assert!(
967 (sum - 1.0).abs() < 1e-9,
968 "shares should sum to ~1.0, got {sum} ({shares:?})"
969 );
970 for share in [shares.wind_call, shares.mv_sd, shares.other] {
971 assert!((0.0..=1.0).contains(&share), "share out of range: {share}");
972 }
973 assert!(shares.dominant().is_some());
974 }
975
976 #[test]
977 fn variance_shares_are_all_zero_with_no_dispersion_sources() {
978 let inputs = test_base_inputs();
979 let wind = WindConditions::default();
980 let atmosphere = test_atmosphere(&inputs);
981 let range_m: f64 = 300.0;
982 let solver_max_range = range_m.max(1000.0) * 2.0;
983 let baseline = wez_solve_target_plane(
984 inputs.clone(),
985 wind.clone(),
986 atmosphere.clone(),
987 solver_max_range,
988 range_m,
989 )
990 .expect("valid test baseline solve");
991
992 let shares = wez_variance_shares(
993 &inputs,
994 &wind,
995 &atmosphere,
996 solver_max_range,
997 range_m,
998 &baseline,
999 0.0,
1000 0.0,
1001 0.0,
1002 0.0,
1003 0.0,
1004 0.0,
1005 0.0,
1006 )
1007 .expect("valid test attribution solve");
1008
1009 assert_eq!(shares.wind_call, 0.0);
1010 assert_eq!(shares.mv_sd, 0.0);
1011 assert_eq!(shares.other, 0.0);
1012 assert!(shares.dominant().is_none());
1013 }
1014
1015 #[test]
1016 fn wind_call_bucket_dominates_when_it_is_the_only_active_source() {
1017 let inputs = test_base_inputs();
1018 let wind = WindConditions::default();
1019 let atmosphere = test_atmosphere(&inputs);
1020 let range_m: f64 = 300.0;
1021 let solver_max_range = range_m.max(1000.0) * 2.0;
1022 let baseline = wez_solve_target_plane(
1023 inputs.clone(),
1024 wind.clone(),
1025 atmosphere.clone(),
1026 solver_max_range,
1027 range_m,
1028 )
1029 .expect("valid test baseline solve");
1030
1031 let shares = wez_variance_shares(
1032 &inputs,
1033 &wind,
1034 &atmosphere,
1035 solver_max_range,
1036 range_m,
1037 &baseline,
1038 0.0,
1039 0.0,
1040 0.0,
1041 0.0,
1042 0.0,
1043 3.0,
1044 0.0,
1045 )
1046 .expect("valid test attribution solve");
1047
1048 assert!((shares.wind_call - 1.0).abs() < 1e-9);
1049 assert_eq!(shares.mv_sd, 0.0);
1050 assert_eq!(shares.other, 0.0);
1051 assert_eq!(shares.dominant(), Some(WezErrorBucket::WindCall));
1052 }
1053}