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