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(profile: &WindShearProfile, height_rel_launch_m: f64) -> f64 {
327 let minimum_height_m = profile.roughness_length.max(0.0) * 1.000_1;
328 let height_agl_m = (height_rel_launch_m + APPROX_MUZZLE_HEIGHT_AGL_M).max(minimum_height_m);
329 let sampled_speed_mps = profile.get_wind_at_altitude(height_agl_m).norm();
330 let reference_speed_mps = profile.surface_wind.speed_mps.abs().max(1e-9);
331
332 (sampled_speed_mps / reference_speed_mps).max(1.0)
333}
334
335pub fn boundary_layer_speed_ratio(height_rel_launch_m: f64, model: WindShearModel) -> f64 {
352 const Z0: f64 = 0.03; const H_REF: f64 = 10.0; let height_agl = (height_rel_launch_m + APPROX_MUZZLE_HEIGHT_AGL_M).max(Z0 * 1.000_1);
356 let ratio = match model {
357 WindShearModel::PowerLaw => (height_agl / H_REF).powf(1.0 / 7.0),
358 WindShearModel::Logarithmic => (height_agl / Z0).ln() / (H_REF / Z0).ln(),
359 _ => 1.0,
361 };
362 ratio.max(1.0)
363}
364
365pub(crate) fn boundary_layer_model_from_name(model: &str) -> WindShearModel {
366 match model {
367 "logarithmic" => WindShearModel::Logarithmic,
368 "power_law" | "powerlaw" => WindShearModel::PowerLaw,
369 "ekman_spiral" | "ekman" => WindShearModel::EkmanSpiral,
370 "custom_layers" | "custom" => WindShearModel::CustomLayers,
371 _ => WindShearModel::None,
372 }
373}
374
375pub(crate) fn apply_boundary_layer_shear(
376 base_wind: Vector3<f64>,
377 height_rel_launch_m: f64,
378 model: WindShearModel,
379) -> Vector3<f64> {
380 let vertical = base_wind.y;
386 let mut sheared = base_wind * boundary_layer_speed_ratio(height_rel_launch_m, model);
387 sheared.y = vertical;
388 sheared
389}
390
391pub fn get_wind_at_position(
406 position: &Vector3<f64>,
407 wind_segments: &[crate::wind::WindSegment],
408 enable_wind_shear: bool,
409 wind_shear_model: &str,
410 shooter_altitude_m: f64,
411) -> Vector3<f64> {
412 let range_m = position[0];
414 let altitude_m = position[1]; let base_wind = if wind_segments.is_empty() {
419 (0.0, 0.0, 0.0)
420 } else {
421 wind_segments
422 .iter()
423 .find(|seg| range_m < seg.until_m)
424 .map(|seg| (seg.speed_kmh, seg.angle_deg, seg.vertical_mps))
425 .unwrap_or((0.0, 0.0, 0.0))
426 };
427
428 let base_speed_mps = base_wind.0 * 0.2777778; let base_direction_deg = base_wind.1;
431 let base_vertical_mps = base_wind.2;
432
433 if !enable_wind_shear || wind_shear_model == "none" {
434 let ang = base_direction_deg.to_radians();
436 return crate::wind::wind_vector(base_speed_mps, ang, base_vertical_mps);
437 }
438
439 let _ = shooter_altitude_m;
449
450 let ang = base_direction_deg.to_radians();
451 let base_vector = crate::wind::wind_vector(base_speed_mps, ang, base_vertical_mps);
452 apply_boundary_layer_shear(
454 base_vector,
455 altitude_m,
456 boundary_layer_model_from_name(wind_shear_model),
457 )
458}
459
460#[cfg(test)]
461mod tests {
462 use super::*;
463
464 #[test]
465 fn test_wind_layer() {
466 let layer_headwind = WindLayer {
471 altitude_m: 100.0,
472 speed_mps: 10.0,
473 direction_deg: 0.0, };
475
476 let vec = layer_headwind.to_vector();
477 assert!(
478 (vec.x - (-10.0)).abs() < 1e-6,
479 "0° wind should be headwind (negative X downrange)"
480 );
481 assert_eq!(vec.y, 0.0);
482 assert!(
483 (vec.z).abs() < 1e-6,
484 "0° wind (headwind) should have zero lateral (Z) component"
485 );
486
487 let layer_crosswind = WindLayer {
489 altitude_m: 100.0,
490 speed_mps: 10.0,
491 direction_deg: 90.0, };
493
494 let vec_cross = layer_crosswind.to_vector();
495 assert!(
496 (vec_cross.z - (-10.0)).abs() < 1e-6,
497 "90° wind should have negative Z lateral (from right)"
498 );
499 assert_eq!(vec_cross.y, 0.0);
500 assert!(
501 (vec_cross.x).abs() < 1e-6,
502 "90° wind (crosswind) should have zero downrange (X) component"
503 );
504 }
505
506 #[test]
507 fn test_logarithmic_profile() {
508 let profile = WindShearProfile {
509 model: WindShearModel::Logarithmic,
510 surface_wind: WindLayer {
511 altitude_m: 0.0,
512 speed_mps: 10.0,
513 direction_deg: 0.0,
514 },
515 ..Default::default()
516 };
517
518 let v10 = profile.get_wind_at_altitude(10.0).norm();
520 let v50 = profile.get_wind_at_altitude(50.0).norm();
521 let v100 = profile.get_wind_at_altitude(100.0).norm();
522
523 assert!(v10 > 0.0);
524 assert!(v50 > v10);
525 assert!(v100 > v50);
526 }
527
528 #[test]
529 fn test_boundary_layer_speed_ratio_flat_fire_full_wind() {
530 for &h in &[-15.0, -11.3, -1.0, -0.2, 0.0, 0.14, 1.5, 5.0, 8.0] {
533 let r_log = boundary_layer_speed_ratio(h, WindShearModel::Logarithmic);
534 let r_pow = boundary_layer_speed_ratio(h, WindShearModel::PowerLaw);
535 assert!(
536 (r_log - 1.0).abs() < 1e-9,
537 "logarithmic ratio at h={h} should be 1.0 (full wind), got {r_log}"
538 );
539 assert!(
540 (r_pow - 1.0).abs() < 1e-9,
541 "power-law ratio at h={h} should be 1.0 (full wind), got {r_pow}"
542 );
543 }
544 }
545
546 #[test]
547 fn test_boundary_layer_speed_ratio_increases_aloft() {
548 let r100 = boundary_layer_speed_ratio(100.0, WindShearModel::Logarithmic);
550 let r300 = boundary_layer_speed_ratio(300.0, WindShearModel::Logarithmic);
551 assert!(r100 > 1.0, "ratio at 100 m should exceed 1.0, got {r100}");
552 assert!(
553 r300 > r100,
554 "ratio should increase with altitude: {r300} !> {r100}"
555 );
556 assert!(
558 (r100 - 1.40).abs() < 0.10,
559 "ratio at ~100 m should be ~1.4, got {r100}"
560 );
561 }
562
563 #[test]
564 fn test_power_law_profile() {
565 let profile = WindShearProfile {
566 model: WindShearModel::PowerLaw,
567 surface_wind: WindLayer {
568 altitude_m: 0.0,
569 speed_mps: 10.0,
570 direction_deg: 0.0,
571 },
572 ..Default::default()
573 };
574
575 let v100 = profile.get_wind_at_altitude(100.0).norm();
577 let expected = 10.0 * (100.0_f64 / 10.0).powf(1.0 / 7.0);
578 assert!((v100 - expected).abs() < 0.01);
579 }
580
581 #[test]
582 fn default_ekman_profile_veers_with_height() {
583 let profile = |surface_direction| WindShearProfile {
584 model: WindShearModel::EkmanSpiral,
585 surface_wind: WindLayer {
586 altitude_m: 0.0,
587 speed_mps: 10.0,
588 direction_deg: surface_direction,
589 },
590 ..Default::default()
591 };
592
593 for (surface_direction, halfway_direction, top_direction) in
594 [(0.0, 15.0, 30.0), (350.0, 365.0, 380.0)]
595 {
596 let profile = profile(surface_direction);
597 let halfway = profile.get_wind_at_altitude(500.0);
598 let expected_halfway = WindLayer {
599 altitude_m: 500.0,
600 speed_mps: 12.5,
601 direction_deg: halfway_direction,
602 }
603 .to_vector();
604 let top = profile.get_wind_at_altitude(1000.0);
605 let expected_top = WindLayer {
606 altitude_m: 1000.0,
607 speed_mps: 15.0,
608 direction_deg: top_direction,
609 }
610 .to_vector();
611
612 assert!((halfway - expected_halfway).norm() < 1e-12);
613 assert!((top - expected_top).norm() < 1e-12);
614 }
615
616 let wrapped_geostrophic = WindLayer {
617 altitude_m: 1000.0,
618 speed_mps: 8.0,
619 direction_deg: 30.0,
620 };
621 let wrapped = WindShearProfile {
622 geostrophic_wind: Some(wrapped_geostrophic),
623 ..profile(350.0)
624 };
625 let wrapped_halfway = WindLayer {
626 altitude_m: 500.0,
627 speed_mps: 9.0,
628 direction_deg: 10.0,
629 }
630 .to_vector();
631
632 assert!((wrapped.get_wind_at_altitude(500.0) - wrapped_halfway).norm() < 1e-12);
633 assert!(
634 (wrapped.get_wind_at_altitude(1000.0) - wrapped_geostrophic.to_vector()).norm() < 1e-12
635 );
636
637 let backing_geostrophic = WindLayer {
638 altitude_m: 1000.0,
639 speed_mps: 18.0,
640 direction_deg: 320.0,
641 };
642 let backing = WindShearProfile {
643 geostrophic_wind: Some(backing_geostrophic),
644 ..profile(350.0)
645 };
646 assert!(
647 (backing.get_wind_at_altitude(1000.0) - backing_geostrophic.to_vector()).norm() < 1e-12
648 );
649 }
650
651 #[test]
652 fn test_windsock_shear_is_independent_of_site_elevation() {
653 for model in [WindShearModel::Logarithmic, WindShearModel::PowerLaw] {
654 let profile = WindShearProfile {
655 model,
656 surface_wind: WindLayer {
657 altitude_m: 10.0,
658 speed_mps: 10.0,
659 direction_deg: 90.0,
660 },
661 ..Default::default()
662 };
663 let segments = vec![(10.0, 90.0, 1_000.0)];
664 let position = Vector3::new(100.0, 10.0, 0.0);
665
666 let sea_level = WindShearWindSock::with_shooter_altitude(
667 segments.clone(),
668 Some(profile.clone()),
669 0.0,
670 )
671 .vector_for_position(position);
672 let elevated =
673 WindShearWindSock::with_shooter_altitude(segments, Some(profile), 1_600.0)
674 .vector_for_position(position);
675
676 assert!(
677 (sea_level - elevated).norm() < 1e-12,
678 "{model:?} shear must use height above local ground, not site elevation: sea={sea_level:?}, elevated={elevated:?}"
679 );
680 let expected_speed = 10.0 * boundary_layer_speed_ratio(10.0, model);
681 assert!((elevated.norm() - expected_speed).abs() < 1e-12);
682 }
683 }
684
685 #[test]
686 fn test_windsock_flat_fire_preserves_operative_wind() {
687 for model in [WindShearModel::Logarithmic, WindShearModel::PowerLaw] {
688 let profile = WindShearProfile {
689 model,
690 surface_wind: WindLayer {
691 altitude_m: 10.0,
692 speed_mps: 10.0,
693 direction_deg: 90.0,
694 },
695 ..Default::default()
696 };
697 let sock = WindShearWindSock::new(vec![(10.0, 90.0, 1_000.0)], Some(profile));
698
699 for height_rel_launch_m in [-1.0, 0.0, 1.0, 5.0] {
700 let wind = sock.vector_for_position(Vector3::new(100.0, height_rel_launch_m, 0.0));
701 assert!(
702 (wind.norm() - 10.0).abs() < 1e-12,
703 "{model:?} flat-fire wind at relative height {height_rel_launch_m} m must retain the operative 10 m/s input, got {wind:?}"
704 );
705 }
706
707 let aloft = sock.vector_for_position(Vector3::new(100.0, 100.0, 0.0));
708 assert!(
709 aloft.norm() > 10.0,
710 "{model:?} shear must still increase wind well above the launch height"
711 );
712 }
713 }
714
715 #[test]
716 fn apply_boundary_layer_shear_scales_horizontal_preserves_vertical() {
717 let height_rel_launch_m = 100.0;
721 let base = Vector3::new(-3.0, 5.0, -4.0);
722
723 for model in [WindShearModel::Logarithmic, WindShearModel::PowerLaw] {
724 let ratio = boundary_layer_speed_ratio(height_rel_launch_m, model);
725 assert!(
726 ratio > 1.02,
727 "{model:?} ratio at {height_rel_launch_m} m should be meaningfully > 1.0, got {ratio}"
728 );
729
730 let sheared = apply_boundary_layer_shear(base, height_rel_launch_m, model);
731
732 assert!(
733 sheared.x != base.x && sheared.z != base.z,
734 "{model:?}: horizontal x/z must be scaled, got sheared={sheared:?} base={base:?}"
735 );
736 assert!(
737 (sheared.x / base.x - ratio).abs() < 1e-9,
738 "{model:?}: x should scale by the boundary-layer ratio {ratio}, got {sheared:?}"
739 );
740 assert!(
741 (sheared.z / base.z - ratio).abs() < 1e-9,
742 "{model:?}: z should scale by the boundary-layer ratio {ratio}, got {sheared:?}"
743 );
744
745 assert_eq!(
747 sheared.y.to_bits(),
748 base.y.to_bits(),
749 "{model:?}: vertical must pass through unscaled bit-for-bit, got {sheared:?}"
750 );
751 assert_eq!(sheared.y, 5.0);
752 }
753 }
754
755 #[test]
756 fn apply_boundary_layer_shear_zero_vertical_stays_zero() {
757 let base = Vector3::new(-3.0, 0.0, -4.0);
759 for model in [WindShearModel::Logarithmic, WindShearModel::PowerLaw] {
760 let sheared = apply_boundary_layer_shear(base, 100.0, model);
761 assert_eq!(sheared.y, 0.0);
762 }
763 }
764}
765
766#[cfg(test)]
767mod fix_validation_tests {
768 use super::*;
769 use nalgebra::Vector3;
770
771 #[test]
772 fn test_get_wind_at_position_flat_fire_full_crosswind() {
773 let pos = Vector3::new(457.0, -1.0, 0.0); let segs = [crate::wind::WindSegment::new(16.09344, 90.0, 1000.0)];
777 let w = get_wind_at_position(&pos, &segs, true, "logarithmic", 0.0);
778 let expected = 16.09344 * 0.2777778; println!("flat-fire wind vec = {:?}, |Z| = {}", w, w.z.abs());
780 assert!(
781 (w.z.abs() - expected).abs() < 0.05,
782 "lateral wind should be ~full {expected:.3} m/s, got {:.3}",
783 w.z.abs()
784 );
785 }
786}