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 mut profile = WindShearProfile::default();
514 profile.model = WindShearModel::Logarithmic;
515 profile.surface_wind = WindLayer {
516 altitude_m: 0.0,
517 speed_mps: 10.0,
518 direction_deg: 0.0,
519 };
520
521 let v10 = profile.get_wind_at_altitude(10.0).norm();
523 let v50 = profile.get_wind_at_altitude(50.0).norm();
524 let v100 = profile.get_wind_at_altitude(100.0).norm();
525
526 assert!(v10 > 0.0);
527 assert!(v50 > v10);
528 assert!(v100 > v50);
529 }
530
531 #[test]
532 fn test_boundary_layer_speed_ratio_flat_fire_full_wind() {
533 for &h in &[-15.0, -11.3, -1.0, -0.2, 0.0, 0.14, 1.5, 5.0, 8.0] {
536 let r_log = boundary_layer_speed_ratio(h, WindShearModel::Logarithmic);
537 let r_pow = boundary_layer_speed_ratio(h, WindShearModel::PowerLaw);
538 assert!(
539 (r_log - 1.0).abs() < 1e-9,
540 "logarithmic ratio at h={h} should be 1.0 (full wind), got {r_log}"
541 );
542 assert!(
543 (r_pow - 1.0).abs() < 1e-9,
544 "power-law ratio at h={h} should be 1.0 (full wind), got {r_pow}"
545 );
546 }
547 }
548
549 #[test]
550 fn test_boundary_layer_speed_ratio_increases_aloft() {
551 let r100 = boundary_layer_speed_ratio(100.0, WindShearModel::Logarithmic);
553 let r300 = boundary_layer_speed_ratio(300.0, WindShearModel::Logarithmic);
554 assert!(r100 > 1.0, "ratio at 100 m should exceed 1.0, got {r100}");
555 assert!(
556 r300 > r100,
557 "ratio should increase with altitude: {r300} !> {r100}"
558 );
559 assert!(
561 (r100 - 1.40).abs() < 0.10,
562 "ratio at ~100 m should be ~1.4, got {r100}"
563 );
564 }
565
566 #[test]
567 fn test_power_law_profile() {
568 let mut profile = WindShearProfile::default();
569 profile.model = WindShearModel::PowerLaw;
570 profile.surface_wind = WindLayer {
571 altitude_m: 0.0,
572 speed_mps: 10.0,
573 direction_deg: 0.0,
574 };
575
576 let v100 = profile.get_wind_at_altitude(100.0).norm();
578 let expected = 10.0 * (100.0_f64 / 10.0).powf(1.0 / 7.0);
579 assert!((v100 - expected).abs() < 0.01);
580 }
581
582 #[test]
583 fn default_ekman_profile_veers_with_height() {
584 let profile = |surface_direction| WindShearProfile {
585 model: WindShearModel::EkmanSpiral,
586 surface_wind: WindLayer {
587 altitude_m: 0.0,
588 speed_mps: 10.0,
589 direction_deg: surface_direction,
590 },
591 ..Default::default()
592 };
593
594 for (surface_direction, halfway_direction, top_direction) in
595 [(0.0, 15.0, 30.0), (350.0, 365.0, 380.0)]
596 {
597 let profile = profile(surface_direction);
598 let halfway = profile.get_wind_at_altitude(500.0);
599 let expected_halfway = WindLayer {
600 altitude_m: 500.0,
601 speed_mps: 12.5,
602 direction_deg: halfway_direction,
603 }
604 .to_vector();
605 let top = profile.get_wind_at_altitude(1000.0);
606 let expected_top = WindLayer {
607 altitude_m: 1000.0,
608 speed_mps: 15.0,
609 direction_deg: top_direction,
610 }
611 .to_vector();
612
613 assert!((halfway - expected_halfway).norm() < 1e-12);
614 assert!((top - expected_top).norm() < 1e-12);
615 }
616
617 let wrapped_geostrophic = WindLayer {
618 altitude_m: 1000.0,
619 speed_mps: 8.0,
620 direction_deg: 30.0,
621 };
622 let wrapped = WindShearProfile {
623 geostrophic_wind: Some(wrapped_geostrophic),
624 ..profile(350.0)
625 };
626 let wrapped_halfway = WindLayer {
627 altitude_m: 500.0,
628 speed_mps: 9.0,
629 direction_deg: 10.0,
630 }
631 .to_vector();
632
633 assert!((wrapped.get_wind_at_altitude(500.0) - wrapped_halfway).norm() < 1e-12);
634 assert!(
635 (wrapped.get_wind_at_altitude(1000.0) - wrapped_geostrophic.to_vector()).norm() < 1e-12
636 );
637
638 let backing_geostrophic = WindLayer {
639 altitude_m: 1000.0,
640 speed_mps: 18.0,
641 direction_deg: 320.0,
642 };
643 let backing = WindShearProfile {
644 geostrophic_wind: Some(backing_geostrophic),
645 ..profile(350.0)
646 };
647 assert!(
648 (backing.get_wind_at_altitude(1000.0) - backing_geostrophic.to_vector()).norm() < 1e-12
649 );
650 }
651
652 #[test]
653 fn test_windsock_shear_is_independent_of_site_elevation() {
654 for model in [WindShearModel::Logarithmic, WindShearModel::PowerLaw] {
655 let profile = WindShearProfile {
656 model,
657 surface_wind: WindLayer {
658 altitude_m: 10.0,
659 speed_mps: 10.0,
660 direction_deg: 90.0,
661 },
662 ..Default::default()
663 };
664 let segments = vec![(10.0, 90.0, 1_000.0)];
665 let position = Vector3::new(100.0, 10.0, 0.0);
666
667 let sea_level = WindShearWindSock::with_shooter_altitude(
668 segments.clone(),
669 Some(profile.clone()),
670 0.0,
671 )
672 .vector_for_position(position);
673 let elevated =
674 WindShearWindSock::with_shooter_altitude(segments, Some(profile), 1_600.0)
675 .vector_for_position(position);
676
677 assert!(
678 (sea_level - elevated).norm() < 1e-12,
679 "{model:?} shear must use height above local ground, not site elevation: sea={sea_level:?}, elevated={elevated:?}"
680 );
681 let expected_speed = 10.0 * boundary_layer_speed_ratio(10.0, model);
682 assert!((elevated.norm() - expected_speed).abs() < 1e-12);
683 }
684 }
685
686 #[test]
687 fn test_windsock_flat_fire_preserves_operative_wind() {
688 for model in [WindShearModel::Logarithmic, WindShearModel::PowerLaw] {
689 let profile = WindShearProfile {
690 model,
691 surface_wind: WindLayer {
692 altitude_m: 10.0,
693 speed_mps: 10.0,
694 direction_deg: 90.0,
695 },
696 ..Default::default()
697 };
698 let sock = WindShearWindSock::new(
699 vec![(10.0, 90.0, 1_000.0)],
700 Some(profile),
701 );
702
703 for height_rel_launch_m in [-1.0, 0.0, 1.0, 5.0] {
704 let wind = sock.vector_for_position(Vector3::new(
705 100.0,
706 height_rel_launch_m,
707 0.0,
708 ));
709 assert!(
710 (wind.norm() - 10.0).abs() < 1e-12,
711 "{model:?} flat-fire wind at relative height {height_rel_launch_m} m must retain the operative 10 m/s input, got {wind:?}"
712 );
713 }
714
715 let aloft = sock.vector_for_position(Vector3::new(100.0, 100.0, 0.0));
716 assert!(
717 aloft.norm() > 10.0,
718 "{model:?} shear must still increase wind well above the launch height"
719 );
720 }
721 }
722
723 #[test]
724 fn apply_boundary_layer_shear_scales_horizontal_preserves_vertical() {
725 let height_rel_launch_m = 100.0;
729 let base = Vector3::new(-3.0, 5.0, -4.0);
730
731 for model in [WindShearModel::Logarithmic, WindShearModel::PowerLaw] {
732 let ratio = boundary_layer_speed_ratio(height_rel_launch_m, model);
733 assert!(
734 ratio > 1.02,
735 "{model:?} ratio at {height_rel_launch_m} m should be meaningfully > 1.0, got {ratio}"
736 );
737
738 let sheared = apply_boundary_layer_shear(base, height_rel_launch_m, model);
739
740 assert!(
741 sheared.x != base.x && sheared.z != base.z,
742 "{model:?}: horizontal x/z must be scaled, got sheared={sheared:?} base={base:?}"
743 );
744 assert!(
745 (sheared.x / base.x - ratio).abs() < 1e-9,
746 "{model:?}: x should scale by the boundary-layer ratio {ratio}, got {sheared:?}"
747 );
748 assert!(
749 (sheared.z / base.z - ratio).abs() < 1e-9,
750 "{model:?}: z should scale by the boundary-layer ratio {ratio}, got {sheared:?}"
751 );
752
753 assert_eq!(
755 sheared.y.to_bits(),
756 base.y.to_bits(),
757 "{model:?}: vertical must pass through unscaled bit-for-bit, got {sheared:?}"
758 );
759 assert_eq!(sheared.y, 5.0);
760 }
761 }
762
763 #[test]
764 fn apply_boundary_layer_shear_zero_vertical_stays_zero() {
765 let base = Vector3::new(-3.0, 0.0, -4.0);
767 for model in [WindShearModel::Logarithmic, WindShearModel::PowerLaw] {
768 let sheared = apply_boundary_layer_shear(base, 100.0, model);
769 assert_eq!(sheared.y, 0.0);
770 }
771 }
772}
773
774#[cfg(test)]
775mod fix_validation_tests {
776 use super::*;
777 use nalgebra::Vector3;
778
779 #[test]
780 fn test_get_wind_at_position_flat_fire_full_crosswind() {
781 let pos = Vector3::new(457.0, -1.0, 0.0); let segs = [crate::wind::WindSegment::new(16.09344, 90.0, 1000.0)];
785 let w = get_wind_at_position(&pos, &segs, true, "logarithmic", 0.0);
786 let expected = 16.09344 * 0.2777778; println!("flat-fire wind vec = {:?}, |Z| = {}", w, w.z.abs());
788 assert!(
789 (w.z.abs() - expected).abs() < 0.05,
790 "lateral wind should be ~full {expected:.3} m/s, got {:.3}",
791 w.z.abs()
792 );
793 }
794}