1use nalgebra::Vector3;
10use std::f64::consts::PI;
11
12const APPROX_MUZZLE_HEIGHT_AGL_M: f64 = 1.5;
13
14#[derive(Debug, Clone, Copy, PartialEq)]
16pub enum WindShearModel {
17 None,
18 Logarithmic,
19 PowerLaw,
20 EkmanSpiral,
21 CustomLayers,
22}
23
24#[derive(Debug, Clone, Copy)]
26pub struct WindLayer {
27 pub altitude_m: f64,
28 pub speed_mps: f64,
29 pub direction_deg: f64, }
31
32impl WindLayer {
33 pub fn to_vector(&self) -> Vector3<f64> {
36 let ang = self.direction_deg.to_radians();
37 crate::wind::wind_vector(self.speed_mps, ang, 0.0)
38 }
39}
40
41#[derive(Debug, Clone)]
43pub struct WindShearProfile {
44 pub model: WindShearModel,
45 pub surface_wind: WindLayer,
46 pub reference_height: f64, pub roughness_length: f64, pub power_exponent: f64, pub geostrophic_wind: Option<WindLayer>, pub custom_layers: Vec<WindLayer>,
51}
52
53impl Default for WindShearProfile {
54 fn default() -> Self {
55 Self {
56 model: WindShearModel::None,
57 surface_wind: WindLayer {
58 altitude_m: 0.0,
59 speed_mps: 0.0,
60 direction_deg: 0.0,
61 },
62 reference_height: 10.0,
63 roughness_length: 0.03,
64 power_exponent: 1.0 / 7.0,
65 geostrophic_wind: None,
66 custom_layers: Vec::new(),
67 }
68 }
69}
70
71impl WindShearProfile {
72 pub fn get_wind_at_altitude(&self, altitude_m: f64) -> Vector3<f64> {
74 match self.model {
75 WindShearModel::None => self.surface_wind.to_vector(),
76 WindShearModel::Logarithmic => self.logarithmic_profile(altitude_m),
77 WindShearModel::PowerLaw => self.power_law_profile(altitude_m),
78 WindShearModel::EkmanSpiral => self.ekman_spiral_profile(altitude_m),
79 WindShearModel::CustomLayers => self.interpolate_layers(altitude_m),
80 }
81 }
82
83 fn logarithmic_profile(&self, altitude_m: f64) -> Vector3<f64> {
86 let effective_altitude = if altitude_m < 0.0 {
89 0.001 } else if altitude_m < 0.001 {
92 0.001
94 } else {
95 altitude_m
96 };
97
98 if effective_altitude <= self.roughness_length {
100 return Vector3::zeros();
101 }
102
103 let speed_ratio = if effective_altitude > self.roughness_length
105 && self.reference_height > self.roughness_length
106 {
107 (effective_altitude / self.roughness_length).ln()
108 / (self.reference_height / self.roughness_length).ln()
109 } else {
110 1.0
111 };
112
113 self.surface_wind.to_vector() * speed_ratio.max(0.0)
115 }
116
117 fn power_law_profile(&self, altitude_m: f64) -> Vector3<f64> {
119 if altitude_m <= 0.0 {
120 return Vector3::zeros();
121 }
122
123 let speed_ratio = (altitude_m / self.reference_height).powf(self.power_exponent);
125
126 self.surface_wind.to_vector() * speed_ratio
128 }
129
130 fn ekman_spiral_profile(&self, altitude_m: f64) -> Vector3<f64> {
135 let geo_wind = self.geostrophic_wind.unwrap_or({
137 WindLayer {
138 altitude_m: 1000.0,
139 speed_mps: self.surface_wind.speed_mps * 1.5,
140 direction_deg: self.surface_wind.direction_deg + 30.0, }
142 });
143
144 let ekman_depth = 1000.0; if altitude_m >= ekman_depth {
148 return geo_wind.to_vector();
149 }
150
151 let ratio = altitude_m / ekman_depth;
153
154 let speed = self.surface_wind.speed_mps * (1.0 - ratio) + geo_wind.speed_mps * ratio;
156
157 let dir1 = self.surface_wind.direction_deg.to_radians();
159 let mut dir2 = geo_wind.direction_deg.to_radians();
160
161 if (dir2 - dir1).abs() > PI {
163 if dir2 > dir1 {
164 dir2 -= 2.0 * PI;
165 } else {
166 dir2 += 2.0 * PI;
167 }
168 }
169
170 let direction_rad = dir1 * (1.0 - ratio) + dir2 * ratio;
171
172 crate::wind::wind_vector(speed, direction_rad, 0.0)
174 }
175
176 fn interpolate_layers(&self, altitude_m: f64) -> Vector3<f64> {
178 if self.custom_layers.is_empty() {
179 return self.surface_wind.to_vector();
180 }
181
182 let last = self.custom_layers.len() - 1;
188 if altitude_m >= self.custom_layers[last].altitude_m {
189 return self.custom_layers[last].to_vector();
190 }
191
192 let mut low_idx = 0;
194 let mut high_idx = 0;
195
196 for (i, layer) in self.custom_layers.iter().enumerate() {
197 if layer.altitude_m <= altitude_m {
198 low_idx = i;
199 }
200 if layer.altitude_m >= altitude_m {
201 high_idx = i;
202 break;
203 }
204 }
205
206 if low_idx == high_idx {
208 return self.custom_layers[low_idx].to_vector();
209 }
210
211 let low_layer = &self.custom_layers[low_idx];
213 let high_layer = &self.custom_layers[high_idx];
214
215 let altitude_diff = high_layer.altitude_m - low_layer.altitude_m;
217 if altitude_diff.abs() < 1e-9 {
218 return low_layer.to_vector();
219 }
220
221 let ratio = (altitude_m - low_layer.altitude_m) / altitude_diff;
222
223 let low_vec = low_layer.to_vector();
225 let high_vec = high_layer.to_vector();
226
227 low_vec * (1.0 - ratio) + high_vec * ratio
228 }
229}
230
231#[derive(Debug, Clone)]
233pub struct WindShearWindSock {
234 pub segments: Vec<(f64, f64, f64)>, pub shear_profile: Option<WindShearProfile>,
236 pub shooter_altitude_m: f64,
239}
240
241impl WindShearWindSock {
242 pub fn new(segments: Vec<(f64, f64, f64)>, shear_profile: Option<WindShearProfile>) -> Self {
243 Self {
244 segments,
245 shear_profile,
246 shooter_altitude_m: 0.0,
247 }
248 }
249
250 pub fn with_shooter_altitude(
251 segments: Vec<(f64, f64, f64)>,
252 shear_profile: Option<WindShearProfile>,
253 shooter_altitude_m: f64,
254 ) -> Self {
255 Self {
256 segments,
257 shear_profile,
258 shooter_altitude_m,
259 }
260 }
261
262 pub fn vector_for_position(&self, position: Vector3<f64>) -> Vector3<f64> {
265 let range_m = position.x; let altitude_m = position.y; let base_wind = self.get_range_wind(range_m);
270
271 if let Some(profile) = &self.shear_profile {
272 if profile.model != WindShearModel::None {
273 if matches!(
274 profile.model,
275 WindShearModel::Logarithmic | WindShearModel::PowerLaw
276 ) {
277 let speed_ratio = profile_boundary_layer_speed_ratio(profile, altitude_m);
282 let operative_wind = if base_wind.norm() > 0.0 {
283 base_wind
284 } else {
285 profile.surface_wind.to_vector()
286 };
287 return operative_wind * speed_ratio;
288 }
289
290 let altitude_vec = profile.get_wind_at_altitude(altitude_m);
291
292 if base_wind.norm() > 0.0 {
294 let scale_factor =
295 altitude_vec.norm() / profile.surface_wind.speed_mps.max(1e-9);
296 return base_wind * scale_factor;
297 }
298
299 return altitude_vec;
300 }
301 }
302
303 base_wind
304 }
305
306 fn get_range_wind(&self, range_m: f64) -> Vector3<f64> {
309 if range_m.is_nan() || self.segments.is_empty() {
310 return Vector3::zeros();
311 }
312
313 for &(speed_mps, angle_deg, until_dist) in &self.segments {
315 if range_m <= until_dist {
316 let ang = angle_deg.to_radians();
317 return crate::wind::wind_vector(speed_mps, ang, 0.0);
318 }
319 }
320
321 Vector3::zeros()
323 }
324}
325
326fn profile_boundary_layer_speed_ratio(
327 profile: &WindShearProfile,
328 height_rel_launch_m: f64,
329) -> f64 {
330 let minimum_height_m = profile.roughness_length.max(0.0) * 1.000_1;
331 let height_agl_m =
332 (height_rel_launch_m + APPROX_MUZZLE_HEIGHT_AGL_M).max(minimum_height_m);
333 let sampled_speed_mps = profile.get_wind_at_altitude(height_agl_m).norm();
334 let reference_speed_mps = profile.surface_wind.speed_mps.abs().max(1e-9);
335
336 (sampled_speed_mps / reference_speed_mps).max(1.0)
337}
338
339pub fn boundary_layer_speed_ratio(height_rel_launch_m: f64, model: WindShearModel) -> f64 {
356 const Z0: f64 = 0.03; const H_REF: f64 = 10.0; let height_agl =
360 (height_rel_launch_m + APPROX_MUZZLE_HEIGHT_AGL_M).max(Z0 * 1.000_1);
361 let ratio = match model {
362 WindShearModel::PowerLaw => (height_agl / H_REF).powf(1.0 / 7.0),
363 WindShearModel::Logarithmic => (height_agl / Z0).ln() / (H_REF / Z0).ln(),
364 _ => 1.0,
366 };
367 ratio.max(1.0)
368}
369
370pub(crate) fn boundary_layer_model_from_name(model: &str) -> WindShearModel {
371 match model {
372 "logarithmic" => WindShearModel::Logarithmic,
373 "power_law" | "powerlaw" => WindShearModel::PowerLaw,
374 "ekman_spiral" | "ekman" => WindShearModel::EkmanSpiral,
375 "custom_layers" | "custom" => WindShearModel::CustomLayers,
376 _ => WindShearModel::None,
377 }
378}
379
380pub(crate) fn apply_boundary_layer_shear(
381 base_wind: Vector3<f64>,
382 height_rel_launch_m: f64,
383 model: WindShearModel,
384) -> Vector3<f64> {
385 let vertical = base_wind.y;
391 let mut sheared = base_wind * boundary_layer_speed_ratio(height_rel_launch_m, model);
392 sheared.y = vertical;
393 sheared
394}
395
396pub fn get_wind_at_position(
411 position: &Vector3<f64>,
412 wind_segments: &[crate::wind::WindSegment],
413 enable_wind_shear: bool,
414 wind_shear_model: &str,
415 shooter_altitude_m: f64,
416) -> Vector3<f64> {
417 let range_m = position[0];
419 let altitude_m = position[1]; let base_wind = if wind_segments.is_empty() {
424 (0.0, 0.0, 0.0)
425 } else {
426 wind_segments
427 .iter()
428 .find(|seg| range_m < seg.until_m)
429 .map(|seg| (seg.speed_kmh, seg.angle_deg, seg.vertical_mps))
430 .unwrap_or((0.0, 0.0, 0.0))
431 };
432
433 let base_speed_mps = base_wind.0 * 0.2777778; let base_direction_deg = base_wind.1;
436 let base_vertical_mps = base_wind.2;
437
438 if !enable_wind_shear || wind_shear_model == "none" {
439 let ang = base_direction_deg.to_radians();
441 return crate::wind::wind_vector(base_speed_mps, ang, base_vertical_mps);
442 }
443
444 let _ = shooter_altitude_m;
454
455 let ang = base_direction_deg.to_radians();
456 let base_vector = crate::wind::wind_vector(base_speed_mps, ang, base_vertical_mps);
457 apply_boundary_layer_shear(
459 base_vector,
460 altitude_m,
461 boundary_layer_model_from_name(wind_shear_model),
462 )
463}
464
465#[cfg(test)]
466mod tests {
467 use super::*;
468
469 #[test]
470 fn test_wind_layer() {
471 let layer_headwind = WindLayer {
476 altitude_m: 100.0,
477 speed_mps: 10.0,
478 direction_deg: 0.0, };
480
481 let vec = layer_headwind.to_vector();
482 assert!(
483 (vec.x - (-10.0)).abs() < 1e-6,
484 "0° wind should be headwind (negative X downrange)"
485 );
486 assert_eq!(vec.y, 0.0);
487 assert!(
488 (vec.z).abs() < 1e-6,
489 "0° wind (headwind) should have zero lateral (Z) component"
490 );
491
492 let layer_crosswind = WindLayer {
494 altitude_m: 100.0,
495 speed_mps: 10.0,
496 direction_deg: 90.0, };
498
499 let vec_cross = layer_crosswind.to_vector();
500 assert!(
501 (vec_cross.z - (-10.0)).abs() < 1e-6,
502 "90° wind should have negative Z lateral (from right)"
503 );
504 assert_eq!(vec_cross.y, 0.0);
505 assert!(
506 (vec_cross.x).abs() < 1e-6,
507 "90° wind (crosswind) should have zero downrange (X) component"
508 );
509 }
510
511 #[test]
512 fn test_logarithmic_profile() {
513 let profile = WindShearProfile {
514 model: WindShearModel::Logarithmic,
515 surface_wind: WindLayer {
516 altitude_m: 0.0,
517 speed_mps: 10.0,
518 direction_deg: 0.0,
519 },
520 ..Default::default()
521 };
522
523 let v10 = profile.get_wind_at_altitude(10.0).norm();
525 let v50 = profile.get_wind_at_altitude(50.0).norm();
526 let v100 = profile.get_wind_at_altitude(100.0).norm();
527
528 assert!(v10 > 0.0);
529 assert!(v50 > v10);
530 assert!(v100 > v50);
531 }
532
533 #[test]
534 fn test_boundary_layer_speed_ratio_flat_fire_full_wind() {
535 for &h in &[-15.0, -11.3, -1.0, -0.2, 0.0, 0.14, 1.5, 5.0, 8.0] {
538 let r_log = boundary_layer_speed_ratio(h, WindShearModel::Logarithmic);
539 let r_pow = boundary_layer_speed_ratio(h, WindShearModel::PowerLaw);
540 assert!(
541 (r_log - 1.0).abs() < 1e-9,
542 "logarithmic ratio at h={h} should be 1.0 (full wind), got {r_log}"
543 );
544 assert!(
545 (r_pow - 1.0).abs() < 1e-9,
546 "power-law ratio at h={h} should be 1.0 (full wind), got {r_pow}"
547 );
548 }
549 }
550
551 #[test]
552 fn test_boundary_layer_speed_ratio_increases_aloft() {
553 let r100 = boundary_layer_speed_ratio(100.0, WindShearModel::Logarithmic);
555 let r300 = boundary_layer_speed_ratio(300.0, WindShearModel::Logarithmic);
556 assert!(r100 > 1.0, "ratio at 100 m should exceed 1.0, got {r100}");
557 assert!(
558 r300 > r100,
559 "ratio should increase with altitude: {r300} !> {r100}"
560 );
561 assert!(
563 (r100 - 1.40).abs() < 0.10,
564 "ratio at ~100 m should be ~1.4, got {r100}"
565 );
566 }
567
568 #[test]
569 fn test_power_law_profile() {
570 let profile = WindShearProfile {
571 model: WindShearModel::PowerLaw,
572 surface_wind: WindLayer {
573 altitude_m: 0.0,
574 speed_mps: 10.0,
575 direction_deg: 0.0,
576 },
577 ..Default::default()
578 };
579
580 let v100 = profile.get_wind_at_altitude(100.0).norm();
582 let expected = 10.0 * (100.0_f64 / 10.0).powf(1.0 / 7.0);
583 assert!((v100 - expected).abs() < 0.01);
584 }
585
586 #[test]
587 fn default_ekman_profile_veers_with_height() {
588 let profile = |surface_direction| WindShearProfile {
589 model: WindShearModel::EkmanSpiral,
590 surface_wind: WindLayer {
591 altitude_m: 0.0,
592 speed_mps: 10.0,
593 direction_deg: surface_direction,
594 },
595 ..Default::default()
596 };
597
598 for (surface_direction, halfway_direction, top_direction) in
599 [(0.0, 15.0, 30.0), (350.0, 365.0, 380.0)]
600 {
601 let profile = profile(surface_direction);
602 let halfway = profile.get_wind_at_altitude(500.0);
603 let expected_halfway = WindLayer {
604 altitude_m: 500.0,
605 speed_mps: 12.5,
606 direction_deg: halfway_direction,
607 }
608 .to_vector();
609 let top = profile.get_wind_at_altitude(1000.0);
610 let expected_top = WindLayer {
611 altitude_m: 1000.0,
612 speed_mps: 15.0,
613 direction_deg: top_direction,
614 }
615 .to_vector();
616
617 assert!((halfway - expected_halfway).norm() < 1e-12);
618 assert!((top - expected_top).norm() < 1e-12);
619 }
620
621 let wrapped_geostrophic = WindLayer {
622 altitude_m: 1000.0,
623 speed_mps: 8.0,
624 direction_deg: 30.0,
625 };
626 let wrapped = WindShearProfile {
627 geostrophic_wind: Some(wrapped_geostrophic),
628 ..profile(350.0)
629 };
630 let wrapped_halfway = WindLayer {
631 altitude_m: 500.0,
632 speed_mps: 9.0,
633 direction_deg: 10.0,
634 }
635 .to_vector();
636
637 assert!((wrapped.get_wind_at_altitude(500.0) - wrapped_halfway).norm() < 1e-12);
638 assert!(
639 (wrapped.get_wind_at_altitude(1000.0) - wrapped_geostrophic.to_vector()).norm() < 1e-12
640 );
641
642 let backing_geostrophic = WindLayer {
643 altitude_m: 1000.0,
644 speed_mps: 18.0,
645 direction_deg: 320.0,
646 };
647 let backing = WindShearProfile {
648 geostrophic_wind: Some(backing_geostrophic),
649 ..profile(350.0)
650 };
651 assert!(
652 (backing.get_wind_at_altitude(1000.0) - backing_geostrophic.to_vector()).norm() < 1e-12
653 );
654 }
655
656 #[test]
657 fn test_windsock_shear_is_independent_of_site_elevation() {
658 for model in [WindShearModel::Logarithmic, WindShearModel::PowerLaw] {
659 let profile = WindShearProfile {
660 model,
661 surface_wind: WindLayer {
662 altitude_m: 10.0,
663 speed_mps: 10.0,
664 direction_deg: 90.0,
665 },
666 ..Default::default()
667 };
668 let segments = vec![(10.0, 90.0, 1_000.0)];
669 let position = Vector3::new(100.0, 10.0, 0.0);
670
671 let sea_level = WindShearWindSock::with_shooter_altitude(
672 segments.clone(),
673 Some(profile.clone()),
674 0.0,
675 )
676 .vector_for_position(position);
677 let elevated =
678 WindShearWindSock::with_shooter_altitude(segments, Some(profile), 1_600.0)
679 .vector_for_position(position);
680
681 assert!(
682 (sea_level - elevated).norm() < 1e-12,
683 "{model:?} shear must use height above local ground, not site elevation: sea={sea_level:?}, elevated={elevated:?}"
684 );
685 let expected_speed = 10.0 * boundary_layer_speed_ratio(10.0, model);
686 assert!((elevated.norm() - expected_speed).abs() < 1e-12);
687 }
688 }
689
690 #[test]
691 fn test_windsock_flat_fire_preserves_operative_wind() {
692 for model in [WindShearModel::Logarithmic, WindShearModel::PowerLaw] {
693 let profile = WindShearProfile {
694 model,
695 surface_wind: WindLayer {
696 altitude_m: 10.0,
697 speed_mps: 10.0,
698 direction_deg: 90.0,
699 },
700 ..Default::default()
701 };
702 let sock = WindShearWindSock::new(
703 vec![(10.0, 90.0, 1_000.0)],
704 Some(profile),
705 );
706
707 for height_rel_launch_m in [-1.0, 0.0, 1.0, 5.0] {
708 let wind = sock.vector_for_position(Vector3::new(
709 100.0,
710 height_rel_launch_m,
711 0.0,
712 ));
713 assert!(
714 (wind.norm() - 10.0).abs() < 1e-12,
715 "{model:?} flat-fire wind at relative height {height_rel_launch_m} m must retain the operative 10 m/s input, got {wind:?}"
716 );
717 }
718
719 let aloft = sock.vector_for_position(Vector3::new(100.0, 100.0, 0.0));
720 assert!(
721 aloft.norm() > 10.0,
722 "{model:?} shear must still increase wind well above the launch height"
723 );
724 }
725 }
726
727 #[test]
728 fn apply_boundary_layer_shear_scales_horizontal_preserves_vertical() {
729 let height_rel_launch_m = 100.0;
733 let base = Vector3::new(-3.0, 5.0, -4.0);
734
735 for model in [WindShearModel::Logarithmic, WindShearModel::PowerLaw] {
736 let ratio = boundary_layer_speed_ratio(height_rel_launch_m, model);
737 assert!(
738 ratio > 1.02,
739 "{model:?} ratio at {height_rel_launch_m} m should be meaningfully > 1.0, got {ratio}"
740 );
741
742 let sheared = apply_boundary_layer_shear(base, height_rel_launch_m, model);
743
744 assert!(
745 sheared.x != base.x && sheared.z != base.z,
746 "{model:?}: horizontal x/z must be scaled, got sheared={sheared:?} base={base:?}"
747 );
748 assert!(
749 (sheared.x / base.x - ratio).abs() < 1e-9,
750 "{model:?}: x should scale by the boundary-layer ratio {ratio}, got {sheared:?}"
751 );
752 assert!(
753 (sheared.z / base.z - ratio).abs() < 1e-9,
754 "{model:?}: z should scale by the boundary-layer ratio {ratio}, got {sheared:?}"
755 );
756
757 assert_eq!(
759 sheared.y.to_bits(),
760 base.y.to_bits(),
761 "{model:?}: vertical must pass through unscaled bit-for-bit, got {sheared:?}"
762 );
763 assert_eq!(sheared.y, 5.0);
764 }
765 }
766
767 #[test]
768 fn apply_boundary_layer_shear_zero_vertical_stays_zero() {
769 let base = Vector3::new(-3.0, 0.0, -4.0);
771 for model in [WindShearModel::Logarithmic, WindShearModel::PowerLaw] {
772 let sheared = apply_boundary_layer_shear(base, 100.0, model);
773 assert_eq!(sheared.y, 0.0);
774 }
775 }
776}
777
778#[cfg(test)]
779mod fix_validation_tests {
780 use super::*;
781 use nalgebra::Vector3;
782
783 #[test]
784 fn test_get_wind_at_position_flat_fire_full_crosswind() {
785 let pos = Vector3::new(457.0, -1.0, 0.0); let segs = [crate::wind::WindSegment::new(16.09344, 90.0, 1000.0)];
789 let w = get_wind_at_position(&pos, &segs, true, "logarithmic", 0.0);
790 let expected = 16.09344 * 0.2777778; println!("flat-fire wind vec = {:?}, |Z| = {}", w, w.z.abs());
792 assert!(
793 (w.z.abs() - expected).abs() < 0.05,
794 "lateral wind should be ~full {expected:.3} m/s, got {:.3}",
795 w.z.abs()
796 );
797 }
798}