1use nalgebra::Vector3;
29use std::f64::consts::PI;
30
31const APPROX_MUZZLE_HEIGHT_AGL_M: f64 = 1.5;
35
36#[derive(Debug, Clone, Copy, PartialEq)]
38pub enum WindShearModel {
39 None,
40 Logarithmic,
41 PowerLaw,
42 EkmanSpiral,
43 CustomLayers,
44}
45
46#[derive(Debug, Clone, Copy)]
48pub struct WindLayer {
49 pub altitude_m: f64,
50 pub speed_mps: f64,
51 pub direction_deg: f64, }
53
54impl WindLayer {
55 pub fn to_vector(&self) -> Vector3<f64> {
58 let ang = self.direction_deg.to_radians();
59 crate::wind::wind_vector(self.speed_mps, ang, 0.0)
60 }
61}
62
63#[derive(Debug, Clone)]
65pub struct WindShearProfile {
66 pub model: WindShearModel,
67 pub surface_wind: WindLayer,
68 pub reference_height: f64, pub roughness_length: f64, pub power_exponent: f64, pub geostrophic_wind: Option<WindLayer>, pub custom_layers: Vec<WindLayer>,
73}
74
75impl Default for WindShearProfile {
76 fn default() -> Self {
77 Self {
78 model: WindShearModel::None,
79 surface_wind: WindLayer {
80 altitude_m: 0.0,
81 speed_mps: 0.0,
82 direction_deg: 0.0,
83 },
84 reference_height: 10.0,
85 roughness_length: 0.03,
86 power_exponent: 1.0 / 7.0,
87 geostrophic_wind: None,
88 custom_layers: Vec::new(),
89 }
90 }
91}
92
93impl WindShearProfile {
94 pub fn get_wind_at_altitude(&self, altitude_m: f64) -> Vector3<f64> {
96 match self.model {
97 WindShearModel::None => self.surface_wind.to_vector(),
98 WindShearModel::Logarithmic => self.logarithmic_profile(altitude_m),
99 WindShearModel::PowerLaw => self.power_law_profile(altitude_m),
100 WindShearModel::EkmanSpiral => self.ekman_spiral_profile(altitude_m),
101 WindShearModel::CustomLayers => self.interpolate_layers(altitude_m),
102 }
103 }
104
105 fn logarithmic_profile(&self, altitude_m: f64) -> Vector3<f64> {
108 let effective_altitude = if altitude_m < 0.0 {
111 0.001 } else if altitude_m < 0.001 {
114 0.001
116 } else {
117 altitude_m
118 };
119
120 if effective_altitude <= self.roughness_length {
122 return Vector3::zeros();
123 }
124
125 let speed_ratio = if effective_altitude > self.roughness_length
127 && self.reference_height > self.roughness_length
128 {
129 (effective_altitude / self.roughness_length).ln()
130 / (self.reference_height / self.roughness_length).ln()
131 } else {
132 1.0
133 };
134
135 self.surface_wind.to_vector() * speed_ratio.max(0.0)
137 }
138
139 fn power_law_profile(&self, altitude_m: f64) -> Vector3<f64> {
141 if altitude_m <= 0.0 {
142 return Vector3::zeros();
143 }
144
145 let speed_ratio = (altitude_m / self.reference_height).powf(self.power_exponent);
147
148 self.surface_wind.to_vector() * speed_ratio
150 }
151
152 fn ekman_spiral_profile(&self, altitude_m: f64) -> Vector3<f64> {
157 let geo_wind = self.geostrophic_wind.unwrap_or({
159 WindLayer {
160 altitude_m: 1000.0,
161 speed_mps: self.surface_wind.speed_mps * 1.5,
162 direction_deg: self.surface_wind.direction_deg + 30.0, }
164 });
165
166 let ekman_depth = 1000.0; if altitude_m >= ekman_depth {
170 return geo_wind.to_vector();
171 }
172
173 let ratio = altitude_m / ekman_depth;
175
176 let speed = self.surface_wind.speed_mps * (1.0 - ratio) + geo_wind.speed_mps * ratio;
178
179 let dir1 = self.surface_wind.direction_deg.to_radians();
181 let mut dir2 = geo_wind.direction_deg.to_radians();
182
183 if (dir2 - dir1).abs() > PI {
185 if dir2 > dir1 {
186 dir2 -= 2.0 * PI;
187 } else {
188 dir2 += 2.0 * PI;
189 }
190 }
191
192 let direction_rad = dir1 * (1.0 - ratio) + dir2 * ratio;
193
194 crate::wind::wind_vector(speed, direction_rad, 0.0)
196 }
197
198 fn interpolate_layers(&self, altitude_m: f64) -> Vector3<f64> {
200 if self.custom_layers.is_empty() {
201 return self.surface_wind.to_vector();
202 }
203
204 let last = self.custom_layers.len() - 1;
210 if altitude_m >= self.custom_layers[last].altitude_m {
211 return self.custom_layers[last].to_vector();
212 }
213
214 let mut low_idx = 0;
216 let mut high_idx = 0;
217
218 for (i, layer) in self.custom_layers.iter().enumerate() {
219 if layer.altitude_m <= altitude_m {
220 low_idx = i;
221 }
222 if layer.altitude_m >= altitude_m {
223 high_idx = i;
224 break;
225 }
226 }
227
228 if low_idx == high_idx {
230 return self.custom_layers[low_idx].to_vector();
231 }
232
233 let low_layer = &self.custom_layers[low_idx];
235 let high_layer = &self.custom_layers[high_idx];
236
237 let altitude_diff = high_layer.altitude_m - low_layer.altitude_m;
239 if altitude_diff.abs() < 1e-9 {
240 return low_layer.to_vector();
241 }
242
243 let ratio = (altitude_m - low_layer.altitude_m) / altitude_diff;
244
245 let low_vec = low_layer.to_vector();
247 let high_vec = high_layer.to_vector();
248
249 low_vec * (1.0 - ratio) + high_vec * ratio
250 }
251}
252
253#[derive(Debug, Clone)]
255pub struct WindShearWindSock {
256 pub segments: Vec<(f64, f64, f64)>, pub shear_profile: Option<WindShearProfile>,
258 pub shooter_altitude_m: f64,
261}
262
263impl WindShearWindSock {
264 pub fn new(segments: Vec<(f64, f64, f64)>, shear_profile: Option<WindShearProfile>) -> Self {
265 Self {
266 segments,
267 shear_profile,
268 shooter_altitude_m: 0.0,
269 }
270 }
271
272 pub fn with_shooter_altitude(
273 segments: Vec<(f64, f64, f64)>,
274 shear_profile: Option<WindShearProfile>,
275 shooter_altitude_m: f64,
276 ) -> Self {
277 Self {
278 segments,
279 shear_profile,
280 shooter_altitude_m,
281 }
282 }
283
284 pub fn vector_for_position(&self, position: Vector3<f64>) -> Vector3<f64> {
291 let range_m = position.x; let altitude_m = position.y; let base_wind = self.get_range_wind(range_m);
296
297 if let Some(profile) = &self.shear_profile {
298 if profile.model != WindShearModel::None {
299 if matches!(
300 profile.model,
301 WindShearModel::Logarithmic | WindShearModel::PowerLaw
302 ) {
303 let speed_ratio = profile_boundary_layer_speed_ratio(profile, altitude_m);
308 let operative_wind = if base_wind.norm() > 0.0 {
309 base_wind
310 } else {
311 profile.surface_wind.to_vector()
312 };
313 return operative_wind * speed_ratio;
314 }
315
316 let altitude_vec = profile.get_wind_at_altitude(altitude_m);
317
318 if base_wind.norm() > 0.0 {
320 let scale_factor =
321 altitude_vec.norm() / profile.surface_wind.speed_mps.max(1e-9);
322 return base_wind * scale_factor;
323 }
324
325 return altitude_vec;
326 }
327 }
328
329 base_wind
330 }
331
332 fn get_range_wind(&self, range_m: f64) -> Vector3<f64> {
335 if range_m.is_nan() || self.segments.is_empty() {
336 return Vector3::zeros();
337 }
338
339 for &(speed_mps, angle_deg, until_dist) in &self.segments {
341 if range_m <= until_dist {
342 let ang = angle_deg.to_radians();
343 return crate::wind::wind_vector(speed_mps, ang, 0.0);
344 }
345 }
346
347 Vector3::zeros()
349 }
350}
351
352fn profile_boundary_layer_speed_ratio(profile: &WindShearProfile, height_rel_launch_m: f64) -> f64 {
353 let minimum_height_m = profile.roughness_length.max(0.0) * 1.000_1;
354 let height_agl_m = (height_rel_launch_m + APPROX_MUZZLE_HEIGHT_AGL_M).max(minimum_height_m);
355 let sampled_speed_mps = profile.get_wind_at_altitude(height_agl_m).norm();
356 let reference_speed_mps = profile.surface_wind.speed_mps.abs().max(1e-9);
357
358 (sampled_speed_mps / reference_speed_mps).max(1.0)
359}
360
361pub fn boundary_layer_speed_ratio(height_rel_launch_m: f64, model: WindShearModel) -> f64 {
381 boundary_layer_speed_ratio_agl(height_rel_launch_m + APPROX_MUZZLE_HEIGHT_AGL_M, model)
382}
383
384pub(crate) fn frame_datum_height_agl_m(muzzle_height_m: f64) -> f64 {
391 if muzzle_height_m == 0.0 {
392 APPROX_MUZZLE_HEIGHT_AGL_M
393 } else {
394 0.0
395 }
396}
397
398pub(crate) fn shear_height_agl_m(y_m: f64, muzzle_height_m: f64) -> f64 {
406 y_m + frame_datum_height_agl_m(muzzle_height_m)
407}
408
409pub(crate) fn boundary_layer_speed_ratio_agl(height_agl_m: f64, model: WindShearModel) -> f64 {
412 const Z0: f64 = 0.03; const H_REF: f64 = 10.0; let height_agl = height_agl_m.max(Z0 * 1.000_1);
416 let ratio = match model {
417 WindShearModel::PowerLaw => (height_agl / H_REF).powf(1.0 / 7.0),
418 WindShearModel::Logarithmic => (height_agl / Z0).ln() / (H_REF / Z0).ln(),
419 _ => 1.0,
421 };
422 ratio.max(1.0)
423}
424
425pub(crate) fn boundary_layer_model_from_name(model: &str) -> WindShearModel {
426 match model {
427 "logarithmic" => WindShearModel::Logarithmic,
428 "power_law" | "powerlaw" => WindShearModel::PowerLaw,
429 "ekman_spiral" | "ekman" => WindShearModel::EkmanSpiral,
430 "custom_layers" | "custom" => WindShearModel::CustomLayers,
431 _ => WindShearModel::None,
432 }
433}
434
435pub(crate) fn apply_boundary_layer_shear(
438 base_wind: Vector3<f64>,
439 height_rel_launch_m: f64,
440 model: WindShearModel,
441) -> Vector3<f64> {
442 apply_boundary_layer_shear_agl(
443 base_wind,
444 height_rel_launch_m + APPROX_MUZZLE_HEIGHT_AGL_M,
445 model,
446 )
447}
448
449pub(crate) fn apply_boundary_layer_shear_agl(
453 base_wind: Vector3<f64>,
454 height_agl_m: f64,
455 model: WindShearModel,
456) -> Vector3<f64> {
457 let vertical = base_wind.y;
463 let mut sheared = base_wind * boundary_layer_speed_ratio_agl(height_agl_m, model);
464 sheared.y = vertical;
465 sheared
466}
467
468pub fn get_wind_at_position(
487 position: &Vector3<f64>,
488 wind_segments: &[crate::wind::WindSegment],
489 enable_wind_shear: bool,
490 wind_shear_model: &str,
491 shooter_altitude_m: f64,
492) -> Vector3<f64> {
493 let range_m = position[0];
495 let altitude_m = position[1]; let base_wind = if wind_segments.is_empty() {
500 (0.0, 0.0, 0.0)
501 } else {
502 wind_segments
503 .iter()
504 .find(|seg| range_m < seg.until_m)
505 .map(|seg| (seg.speed_kmh, seg.angle_deg, seg.vertical_mps))
506 .unwrap_or((0.0, 0.0, 0.0))
507 };
508
509 let base_speed_mps = base_wind.0 * 0.2777778; let base_direction_deg = base_wind.1;
512 let base_vertical_mps = base_wind.2;
513
514 if !enable_wind_shear || wind_shear_model == "none" {
515 let ang = base_direction_deg.to_radians();
517 return crate::wind::wind_vector(base_speed_mps, ang, base_vertical_mps);
518 }
519
520 let _ = shooter_altitude_m;
531
532 let ang = base_direction_deg.to_radians();
533 let base_vector = crate::wind::wind_vector(base_speed_mps, ang, base_vertical_mps);
534 apply_boundary_layer_shear(
536 base_vector,
537 altitude_m,
538 boundary_layer_model_from_name(wind_shear_model),
539 )
540}
541
542#[cfg(test)]
543mod tests {
544 use super::*;
545
546 #[test]
547 fn test_wind_layer() {
548 let layer_headwind = WindLayer {
553 altitude_m: 100.0,
554 speed_mps: 10.0,
555 direction_deg: 0.0, };
557
558 let vec = layer_headwind.to_vector();
559 assert!(
560 (vec.x - (-10.0)).abs() < 1e-6,
561 "0° wind should be headwind (negative X downrange)"
562 );
563 assert_eq!(vec.y, 0.0);
564 assert!(
565 (vec.z).abs() < 1e-6,
566 "0° wind (headwind) should have zero lateral (Z) component"
567 );
568
569 let layer_crosswind = WindLayer {
571 altitude_m: 100.0,
572 speed_mps: 10.0,
573 direction_deg: 90.0, };
575
576 let vec_cross = layer_crosswind.to_vector();
577 assert!(
578 (vec_cross.z - (-10.0)).abs() < 1e-6,
579 "90° wind should have negative Z lateral (from right)"
580 );
581 assert_eq!(vec_cross.y, 0.0);
582 assert!(
583 (vec_cross.x).abs() < 1e-6,
584 "90° wind (crosswind) should have zero downrange (X) component"
585 );
586 }
587
588 #[test]
589 fn test_logarithmic_profile() {
590 let profile = WindShearProfile {
591 model: WindShearModel::Logarithmic,
592 surface_wind: WindLayer {
593 altitude_m: 0.0,
594 speed_mps: 10.0,
595 direction_deg: 0.0,
596 },
597 ..Default::default()
598 };
599
600 let v10 = profile.get_wind_at_altitude(10.0).norm();
602 let v50 = profile.get_wind_at_altitude(50.0).norm();
603 let v100 = profile.get_wind_at_altitude(100.0).norm();
604
605 assert!(v10 > 0.0);
606 assert!(v50 > v10);
607 assert!(v100 > v50);
608 }
609
610 #[test]
611 fn test_boundary_layer_speed_ratio_flat_fire_full_wind() {
612 for &h in &[-15.0, -11.3, -1.0, -0.2, 0.0, 0.14, 1.5, 5.0, 8.0] {
615 let r_log = boundary_layer_speed_ratio(h, WindShearModel::Logarithmic);
616 let r_pow = boundary_layer_speed_ratio(h, WindShearModel::PowerLaw);
617 assert!(
618 (r_log - 1.0).abs() < 1e-9,
619 "logarithmic ratio at h={h} should be 1.0 (full wind), got {r_log}"
620 );
621 assert!(
622 (r_pow - 1.0).abs() < 1e-9,
623 "power-law ratio at h={h} should be 1.0 (full wind), got {r_pow}"
624 );
625 }
626 }
627
628 #[test]
629 fn test_boundary_layer_speed_ratio_increases_aloft() {
630 let r100 = boundary_layer_speed_ratio(100.0, WindShearModel::Logarithmic);
632 let r300 = boundary_layer_speed_ratio(300.0, WindShearModel::Logarithmic);
633 assert!(r100 > 1.0, "ratio at 100 m should exceed 1.0, got {r100}");
634 assert!(
635 r300 > r100,
636 "ratio should increase with altitude: {r300} !> {r100}"
637 );
638 assert!(
640 (r100 - 1.40).abs() < 0.10,
641 "ratio at ~100 m should be ~1.4, got {r100}"
642 );
643 }
644
645 #[test]
646 fn test_power_law_profile() {
647 let profile = WindShearProfile {
648 model: WindShearModel::PowerLaw,
649 surface_wind: WindLayer {
650 altitude_m: 0.0,
651 speed_mps: 10.0,
652 direction_deg: 0.0,
653 },
654 ..Default::default()
655 };
656
657 let v100 = profile.get_wind_at_altitude(100.0).norm();
659 let expected = 10.0 * (100.0_f64 / 10.0).powf(1.0 / 7.0);
660 assert!((v100 - expected).abs() < 0.01);
661 }
662
663 #[test]
664 fn default_ekman_profile_veers_with_height() {
665 let profile = |surface_direction| WindShearProfile {
666 model: WindShearModel::EkmanSpiral,
667 surface_wind: WindLayer {
668 altitude_m: 0.0,
669 speed_mps: 10.0,
670 direction_deg: surface_direction,
671 },
672 ..Default::default()
673 };
674
675 for (surface_direction, halfway_direction, top_direction) in
676 [(0.0, 15.0, 30.0), (350.0, 365.0, 380.0)]
677 {
678 let profile = profile(surface_direction);
679 let halfway = profile.get_wind_at_altitude(500.0);
680 let expected_halfway = WindLayer {
681 altitude_m: 500.0,
682 speed_mps: 12.5,
683 direction_deg: halfway_direction,
684 }
685 .to_vector();
686 let top = profile.get_wind_at_altitude(1000.0);
687 let expected_top = WindLayer {
688 altitude_m: 1000.0,
689 speed_mps: 15.0,
690 direction_deg: top_direction,
691 }
692 .to_vector();
693
694 assert!((halfway - expected_halfway).norm() < 1e-12);
695 assert!((top - expected_top).norm() < 1e-12);
696 }
697
698 let wrapped_geostrophic = WindLayer {
699 altitude_m: 1000.0,
700 speed_mps: 8.0,
701 direction_deg: 30.0,
702 };
703 let wrapped = WindShearProfile {
704 geostrophic_wind: Some(wrapped_geostrophic),
705 ..profile(350.0)
706 };
707 let wrapped_halfway = WindLayer {
708 altitude_m: 500.0,
709 speed_mps: 9.0,
710 direction_deg: 10.0,
711 }
712 .to_vector();
713
714 assert!((wrapped.get_wind_at_altitude(500.0) - wrapped_halfway).norm() < 1e-12);
715 assert!(
716 (wrapped.get_wind_at_altitude(1000.0) - wrapped_geostrophic.to_vector()).norm() < 1e-12
717 );
718
719 let backing_geostrophic = WindLayer {
720 altitude_m: 1000.0,
721 speed_mps: 18.0,
722 direction_deg: 320.0,
723 };
724 let backing = WindShearProfile {
725 geostrophic_wind: Some(backing_geostrophic),
726 ..profile(350.0)
727 };
728 assert!(
729 (backing.get_wind_at_altitude(1000.0) - backing_geostrophic.to_vector()).norm() < 1e-12
730 );
731 }
732
733 #[test]
734 fn test_windsock_shear_is_independent_of_site_elevation() {
735 for model in [WindShearModel::Logarithmic, WindShearModel::PowerLaw] {
736 let profile = WindShearProfile {
737 model,
738 surface_wind: WindLayer {
739 altitude_m: 10.0,
740 speed_mps: 10.0,
741 direction_deg: 90.0,
742 },
743 ..Default::default()
744 };
745 let segments = vec![(10.0, 90.0, 1_000.0)];
746 let position = Vector3::new(100.0, 10.0, 0.0);
747
748 let sea_level = WindShearWindSock::with_shooter_altitude(
749 segments.clone(),
750 Some(profile.clone()),
751 0.0,
752 )
753 .vector_for_position(position);
754 let elevated =
755 WindShearWindSock::with_shooter_altitude(segments, Some(profile), 1_600.0)
756 .vector_for_position(position);
757
758 assert!(
759 (sea_level - elevated).norm() < 1e-12,
760 "{model:?} shear must use height above local ground, not site elevation: sea={sea_level:?}, elevated={elevated:?}"
761 );
762 let expected_speed = 10.0 * boundary_layer_speed_ratio(10.0, model);
763 assert!((elevated.norm() - expected_speed).abs() < 1e-12);
764 }
765 }
766
767 #[test]
768 fn test_windsock_flat_fire_preserves_operative_wind() {
769 for model in [WindShearModel::Logarithmic, WindShearModel::PowerLaw] {
770 let profile = WindShearProfile {
771 model,
772 surface_wind: WindLayer {
773 altitude_m: 10.0,
774 speed_mps: 10.0,
775 direction_deg: 90.0,
776 },
777 ..Default::default()
778 };
779 let sock = WindShearWindSock::new(vec![(10.0, 90.0, 1_000.0)], Some(profile));
780
781 for height_rel_launch_m in [-1.0, 0.0, 1.0, 5.0] {
782 let wind = sock.vector_for_position(Vector3::new(100.0, height_rel_launch_m, 0.0));
783 assert!(
784 (wind.norm() - 10.0).abs() < 1e-12,
785 "{model:?} flat-fire wind at relative height {height_rel_launch_m} m must retain the operative 10 m/s input, got {wind:?}"
786 );
787 }
788
789 let aloft = sock.vector_for_position(Vector3::new(100.0, 100.0, 0.0));
790 assert!(
791 aloft.norm() > 10.0,
792 "{model:?} shear must still increase wind well above the launch height"
793 );
794 }
795 }
796
797 #[test]
798 fn apply_boundary_layer_shear_scales_horizontal_preserves_vertical() {
799 let height_rel_launch_m = 100.0;
803 let base = Vector3::new(-3.0, 5.0, -4.0);
804
805 for model in [WindShearModel::Logarithmic, WindShearModel::PowerLaw] {
806 let ratio = boundary_layer_speed_ratio(height_rel_launch_m, model);
807 assert!(
808 ratio > 1.02,
809 "{model:?} ratio at {height_rel_launch_m} m should be meaningfully > 1.0, got {ratio}"
810 );
811
812 let sheared = apply_boundary_layer_shear(base, height_rel_launch_m, model);
813
814 assert!(
815 sheared.x != base.x && sheared.z != base.z,
816 "{model:?}: horizontal x/z must be scaled, got sheared={sheared:?} base={base:?}"
817 );
818 assert!(
819 (sheared.x / base.x - ratio).abs() < 1e-9,
820 "{model:?}: x should scale by the boundary-layer ratio {ratio}, got {sheared:?}"
821 );
822 assert!(
823 (sheared.z / base.z - ratio).abs() < 1e-9,
824 "{model:?}: z should scale by the boundary-layer ratio {ratio}, got {sheared:?}"
825 );
826
827 assert_eq!(
829 sheared.y.to_bits(),
830 base.y.to_bits(),
831 "{model:?}: vertical must pass through unscaled bit-for-bit, got {sheared:?}"
832 );
833 assert_eq!(sheared.y, 5.0);
834 }
835 }
836
837 #[test]
838 fn apply_boundary_layer_shear_zero_vertical_stays_zero() {
839 let base = Vector3::new(-3.0, 0.0, -4.0);
841 for model in [WindShearModel::Logarithmic, WindShearModel::PowerLaw] {
842 let sheared = apply_boundary_layer_shear(base, 100.0, model);
843 assert_eq!(sheared.y, 0.0);
844 }
845 }
846}
847
848#[cfg(test)]
849mod fix_validation_tests {
850 use super::*;
851 use nalgebra::Vector3;
852
853 #[test]
854 fn test_get_wind_at_position_flat_fire_full_crosswind() {
855 let pos = Vector3::new(457.0, -1.0, 0.0); let segs = [crate::wind::WindSegment::new(16.09344, 90.0, 1000.0)];
859 let w = get_wind_at_position(&pos, &segs, true, "logarithmic", 0.0);
860 let expected = 16.09344 * 0.2777778; println!("flat-fire wind vec = {:?}, |Z| = {}", w, w.z.abs());
862 assert!(
863 (w.z.abs() - expected).abs() < 0.05,
864 "lateral wind should be ~full {expected:.3} m/s, got {:.3}",
865 w.z.abs()
866 );
867 }
868}