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 Vector3::new(
38 -self.speed_mps * ang.cos(), 0.0, -self.speed_mps * ang.sin(), )
42 }
43}
44
45#[derive(Debug, Clone)]
47pub struct WindShearProfile {
48 pub model: WindShearModel,
49 pub surface_wind: WindLayer,
50 pub reference_height: f64, pub roughness_length: f64, pub power_exponent: f64, pub geostrophic_wind: Option<WindLayer>, pub custom_layers: Vec<WindLayer>,
55}
56
57impl Default for WindShearProfile {
58 fn default() -> Self {
59 Self {
60 model: WindShearModel::None,
61 surface_wind: WindLayer {
62 altitude_m: 0.0,
63 speed_mps: 0.0,
64 direction_deg: 0.0,
65 },
66 reference_height: 10.0,
67 roughness_length: 0.03,
68 power_exponent: 1.0 / 7.0,
69 geostrophic_wind: None,
70 custom_layers: Vec::new(),
71 }
72 }
73}
74
75impl WindShearProfile {
76 pub fn get_wind_at_altitude(&self, altitude_m: f64) -> Vector3<f64> {
78 match self.model {
79 WindShearModel::None => self.surface_wind.to_vector(),
80 WindShearModel::Logarithmic => self.logarithmic_profile(altitude_m),
81 WindShearModel::PowerLaw => self.power_law_profile(altitude_m),
82 WindShearModel::EkmanSpiral => self.ekman_spiral_profile(altitude_m),
83 WindShearModel::CustomLayers => self.interpolate_layers(altitude_m),
84 }
85 }
86
87 fn logarithmic_profile(&self, altitude_m: f64) -> Vector3<f64> {
90 let effective_altitude = if altitude_m < 0.0 {
93 0.001 } else if altitude_m < 0.001 {
96 0.001
98 } else {
99 altitude_m
100 };
101
102 if effective_altitude <= self.roughness_length {
104 return Vector3::zeros();
105 }
106
107 let speed_ratio = if effective_altitude > self.roughness_length
109 && self.reference_height > self.roughness_length
110 {
111 (effective_altitude / self.roughness_length).ln()
112 / (self.reference_height / self.roughness_length).ln()
113 } else {
114 1.0
115 };
116
117 self.surface_wind.to_vector() * speed_ratio.max(0.0)
119 }
120
121 fn power_law_profile(&self, altitude_m: f64) -> Vector3<f64> {
123 if altitude_m <= 0.0 {
124 return Vector3::zeros();
125 }
126
127 let speed_ratio = (altitude_m / self.reference_height).powf(self.power_exponent);
129
130 self.surface_wind.to_vector() * speed_ratio
132 }
133
134 fn ekman_spiral_profile(&self, altitude_m: f64) -> Vector3<f64> {
139 let geo_wind = self.geostrophic_wind.unwrap_or({
141 WindLayer {
142 altitude_m: 1000.0,
143 speed_mps: self.surface_wind.speed_mps * 1.5,
144 direction_deg: self.surface_wind.direction_deg + 30.0, }
146 });
147
148 let ekman_depth = 1000.0; if altitude_m >= ekman_depth {
152 return geo_wind.to_vector();
153 }
154
155 let ratio = altitude_m / ekman_depth;
157
158 let speed = self.surface_wind.speed_mps * (1.0 - ratio) + geo_wind.speed_mps * ratio;
160
161 let dir1 = self.surface_wind.direction_deg.to_radians();
163 let mut dir2 = geo_wind.direction_deg.to_radians();
164
165 if (dir2 - dir1).abs() > PI {
167 if dir2 > dir1 {
168 dir2 -= 2.0 * PI;
169 } else {
170 dir2 += 2.0 * PI;
171 }
172 }
173
174 let direction_rad = dir1 * (1.0 - ratio) + dir2 * ratio;
175
176 Vector3::new(
178 -speed * direction_rad.cos(), 0.0,
180 -speed * direction_rad.sin(), )
182 }
183
184 fn interpolate_layers(&self, altitude_m: f64) -> Vector3<f64> {
186 if self.custom_layers.is_empty() {
187 return self.surface_wind.to_vector();
188 }
189
190 let last = self.custom_layers.len() - 1;
196 if altitude_m >= self.custom_layers[last].altitude_m {
197 return self.custom_layers[last].to_vector();
198 }
199
200 let mut low_idx = 0;
202 let mut high_idx = 0;
203
204 for (i, layer) in self.custom_layers.iter().enumerate() {
205 if layer.altitude_m <= altitude_m {
206 low_idx = i;
207 }
208 if layer.altitude_m >= altitude_m {
209 high_idx = i;
210 break;
211 }
212 }
213
214 if low_idx == high_idx {
216 return self.custom_layers[low_idx].to_vector();
217 }
218
219 let low_layer = &self.custom_layers[low_idx];
221 let high_layer = &self.custom_layers[high_idx];
222
223 let altitude_diff = high_layer.altitude_m - low_layer.altitude_m;
225 if altitude_diff.abs() < 1e-9 {
226 return low_layer.to_vector();
227 }
228
229 let ratio = (altitude_m - low_layer.altitude_m) / altitude_diff;
230
231 let low_vec = low_layer.to_vector();
233 let high_vec = high_layer.to_vector();
234
235 low_vec * (1.0 - ratio) + high_vec * ratio
236 }
237}
238
239#[derive(Debug, Clone)]
241pub struct WindShearWindSock {
242 pub segments: Vec<(f64, f64, f64)>, pub shear_profile: Option<WindShearProfile>,
244 pub shooter_altitude_m: f64,
247}
248
249impl WindShearWindSock {
250 pub fn new(segments: Vec<(f64, f64, f64)>, shear_profile: Option<WindShearProfile>) -> Self {
251 Self {
252 segments,
253 shear_profile,
254 shooter_altitude_m: 0.0,
255 }
256 }
257
258 pub fn with_shooter_altitude(
259 segments: Vec<(f64, f64, f64)>,
260 shear_profile: Option<WindShearProfile>,
261 shooter_altitude_m: f64,
262 ) -> Self {
263 Self {
264 segments,
265 shear_profile,
266 shooter_altitude_m,
267 }
268 }
269
270 pub fn vector_for_position(&self, position: Vector3<f64>) -> Vector3<f64> {
273 let range_m = position.x; let altitude_m = position.y; let base_wind = self.get_range_wind(range_m);
278
279 if let Some(profile) = &self.shear_profile {
280 if profile.model != WindShearModel::None {
281 if matches!(
282 profile.model,
283 WindShearModel::Logarithmic | WindShearModel::PowerLaw
284 ) {
285 let speed_ratio = profile_boundary_layer_speed_ratio(profile, altitude_m);
290 let operative_wind = if base_wind.norm() > 0.0 {
291 base_wind
292 } else {
293 profile.surface_wind.to_vector()
294 };
295 return operative_wind * speed_ratio;
296 }
297
298 let altitude_vec = profile.get_wind_at_altitude(altitude_m);
299
300 if base_wind.norm() > 0.0 {
302 let scale_factor =
303 altitude_vec.norm() / profile.surface_wind.speed_mps.max(1e-9);
304 return base_wind * scale_factor;
305 }
306
307 return altitude_vec;
308 }
309 }
310
311 base_wind
312 }
313
314 fn get_range_wind(&self, range_m: f64) -> Vector3<f64> {
317 if range_m.is_nan() || self.segments.is_empty() {
318 return Vector3::zeros();
319 }
320
321 for &(speed_mps, angle_deg, until_dist) in &self.segments {
323 if range_m <= until_dist {
324 let ang = angle_deg.to_radians();
325 return Vector3::new(
326 -speed_mps * ang.cos(), 0.0,
328 -speed_mps * ang.sin(), );
330 }
331 }
332
333 Vector3::zeros()
335 }
336}
337
338fn profile_boundary_layer_speed_ratio(
339 profile: &WindShearProfile,
340 height_rel_launch_m: f64,
341) -> f64 {
342 let minimum_height_m = profile.roughness_length.max(0.0) * 1.000_1;
343 let height_agl_m =
344 (height_rel_launch_m + APPROX_MUZZLE_HEIGHT_AGL_M).max(minimum_height_m);
345 let sampled_speed_mps = profile.get_wind_at_altitude(height_agl_m).norm();
346 let reference_speed_mps = profile.surface_wind.speed_mps.abs().max(1e-9);
347
348 (sampled_speed_mps / reference_speed_mps).max(1.0)
349}
350
351pub fn boundary_layer_speed_ratio(height_rel_launch_m: f64, model: WindShearModel) -> f64 {
368 const Z0: f64 = 0.03; const H_REF: f64 = 10.0; let height_agl =
372 (height_rel_launch_m + APPROX_MUZZLE_HEIGHT_AGL_M).max(Z0 * 1.000_1);
373 let ratio = match model {
374 WindShearModel::PowerLaw => (height_agl / H_REF).powf(1.0 / 7.0),
375 WindShearModel::Logarithmic => (height_agl / Z0).ln() / (H_REF / Z0).ln(),
376 _ => 1.0,
378 };
379 ratio.max(1.0)
380}
381
382pub(crate) fn boundary_layer_model_from_name(model: &str) -> WindShearModel {
383 match model {
384 "logarithmic" => WindShearModel::Logarithmic,
385 "power_law" | "powerlaw" => WindShearModel::PowerLaw,
386 "ekman_spiral" | "ekman" => WindShearModel::EkmanSpiral,
387 "custom_layers" | "custom" => WindShearModel::CustomLayers,
388 _ => WindShearModel::None,
389 }
390}
391
392pub(crate) fn apply_boundary_layer_shear(
393 base_wind: Vector3<f64>,
394 height_rel_launch_m: f64,
395 model: WindShearModel,
396) -> Vector3<f64> {
397 base_wind * boundary_layer_speed_ratio(height_rel_launch_m, model)
398}
399
400pub fn get_wind_at_position(
415 position: &Vector3<f64>,
416 wind_segments: &[(f64, f64, f64)], enable_wind_shear: bool,
418 wind_shear_model: &str,
419 shooter_altitude_m: f64,
420) -> Vector3<f64> {
421 let range_m = position[0];
423 let altitude_m = position[1]; let base_wind = if wind_segments.is_empty() {
427 (0.0, 0.0)
428 } else {
429 wind_segments
430 .iter()
431 .find(|seg| range_m < seg.2)
432 .map(|seg| (seg.0, seg.1))
433 .unwrap_or((0.0, 0.0))
434 };
435
436 let base_speed_mps = base_wind.0 * 0.2777778; let base_direction_deg = base_wind.1;
439
440 if !enable_wind_shear || wind_shear_model == "none" {
441 let ang = base_direction_deg.to_radians();
443 return Vector3::new(
444 -base_speed_mps * ang.cos(), 0.0, -base_speed_mps * ang.sin(), );
448 }
449
450 let _ = shooter_altitude_m;
460
461 let ang = base_direction_deg.to_radians();
462 let base_vector = Vector3::new(
463 -base_speed_mps * ang.cos(), 0.0, -base_speed_mps * ang.sin(), );
467 apply_boundary_layer_shear(
468 base_vector,
469 altitude_m,
470 boundary_layer_model_from_name(wind_shear_model),
471 )
472}
473
474#[cfg(test)]
475mod tests {
476 use super::*;
477
478 #[test]
479 fn test_wind_layer() {
480 let layer_headwind = WindLayer {
485 altitude_m: 100.0,
486 speed_mps: 10.0,
487 direction_deg: 0.0, };
489
490 let vec = layer_headwind.to_vector();
491 assert!(
492 (vec.x - (-10.0)).abs() < 1e-6,
493 "0° wind should be headwind (negative X downrange)"
494 );
495 assert_eq!(vec.y, 0.0);
496 assert!(
497 (vec.z).abs() < 1e-6,
498 "0° wind (headwind) should have zero lateral (Z) component"
499 );
500
501 let layer_crosswind = WindLayer {
503 altitude_m: 100.0,
504 speed_mps: 10.0,
505 direction_deg: 90.0, };
507
508 let vec_cross = layer_crosswind.to_vector();
509 assert!(
510 (vec_cross.z - (-10.0)).abs() < 1e-6,
511 "90° wind should have negative Z lateral (from right)"
512 );
513 assert_eq!(vec_cross.y, 0.0);
514 assert!(
515 (vec_cross.x).abs() < 1e-6,
516 "90° wind (crosswind) should have zero downrange (X) component"
517 );
518 }
519
520 #[test]
521 fn test_logarithmic_profile() {
522 let mut profile = WindShearProfile::default();
523 profile.model = WindShearModel::Logarithmic;
524 profile.surface_wind = WindLayer {
525 altitude_m: 0.0,
526 speed_mps: 10.0,
527 direction_deg: 0.0,
528 };
529
530 let v10 = profile.get_wind_at_altitude(10.0).norm();
532 let v50 = profile.get_wind_at_altitude(50.0).norm();
533 let v100 = profile.get_wind_at_altitude(100.0).norm();
534
535 assert!(v10 > 0.0);
536 assert!(v50 > v10);
537 assert!(v100 > v50);
538 }
539
540 #[test]
541 fn test_boundary_layer_speed_ratio_flat_fire_full_wind() {
542 for &h in &[-15.0, -11.3, -1.0, -0.2, 0.0, 0.14, 1.5, 5.0, 8.0] {
545 let r_log = boundary_layer_speed_ratio(h, WindShearModel::Logarithmic);
546 let r_pow = boundary_layer_speed_ratio(h, WindShearModel::PowerLaw);
547 assert!(
548 (r_log - 1.0).abs() < 1e-9,
549 "logarithmic ratio at h={h} should be 1.0 (full wind), got {r_log}"
550 );
551 assert!(
552 (r_pow - 1.0).abs() < 1e-9,
553 "power-law ratio at h={h} should be 1.0 (full wind), got {r_pow}"
554 );
555 }
556 }
557
558 #[test]
559 fn test_boundary_layer_speed_ratio_increases_aloft() {
560 let r100 = boundary_layer_speed_ratio(100.0, WindShearModel::Logarithmic);
562 let r300 = boundary_layer_speed_ratio(300.0, WindShearModel::Logarithmic);
563 assert!(r100 > 1.0, "ratio at 100 m should exceed 1.0, got {r100}");
564 assert!(
565 r300 > r100,
566 "ratio should increase with altitude: {r300} !> {r100}"
567 );
568 assert!(
570 (r100 - 1.40).abs() < 0.10,
571 "ratio at ~100 m should be ~1.4, got {r100}"
572 );
573 }
574
575 #[test]
576 fn test_power_law_profile() {
577 let mut profile = WindShearProfile::default();
578 profile.model = WindShearModel::PowerLaw;
579 profile.surface_wind = WindLayer {
580 altitude_m: 0.0,
581 speed_mps: 10.0,
582 direction_deg: 0.0,
583 };
584
585 let v100 = profile.get_wind_at_altitude(100.0).norm();
587 let expected = 10.0 * (100.0_f64 / 10.0).powf(1.0 / 7.0);
588 assert!((v100 - expected).abs() < 0.01);
589 }
590
591 #[test]
592 fn default_ekman_profile_veers_with_height() {
593 let profile = |surface_direction| WindShearProfile {
594 model: WindShearModel::EkmanSpiral,
595 surface_wind: WindLayer {
596 altitude_m: 0.0,
597 speed_mps: 10.0,
598 direction_deg: surface_direction,
599 },
600 ..Default::default()
601 };
602
603 for (surface_direction, halfway_direction, top_direction) in
604 [(0.0, 15.0, 30.0), (350.0, 365.0, 380.0)]
605 {
606 let profile = profile(surface_direction);
607 let halfway = profile.get_wind_at_altitude(500.0);
608 let expected_halfway = WindLayer {
609 altitude_m: 500.0,
610 speed_mps: 12.5,
611 direction_deg: halfway_direction,
612 }
613 .to_vector();
614 let top = profile.get_wind_at_altitude(1000.0);
615 let expected_top = WindLayer {
616 altitude_m: 1000.0,
617 speed_mps: 15.0,
618 direction_deg: top_direction,
619 }
620 .to_vector();
621
622 assert!((halfway - expected_halfway).norm() < 1e-12);
623 assert!((top - expected_top).norm() < 1e-12);
624 }
625
626 let wrapped_geostrophic = WindLayer {
627 altitude_m: 1000.0,
628 speed_mps: 8.0,
629 direction_deg: 30.0,
630 };
631 let wrapped = WindShearProfile {
632 geostrophic_wind: Some(wrapped_geostrophic),
633 ..profile(350.0)
634 };
635 let wrapped_halfway = WindLayer {
636 altitude_m: 500.0,
637 speed_mps: 9.0,
638 direction_deg: 10.0,
639 }
640 .to_vector();
641
642 assert!((wrapped.get_wind_at_altitude(500.0) - wrapped_halfway).norm() < 1e-12);
643 assert!(
644 (wrapped.get_wind_at_altitude(1000.0) - wrapped_geostrophic.to_vector()).norm() < 1e-12
645 );
646
647 let backing_geostrophic = WindLayer {
648 altitude_m: 1000.0,
649 speed_mps: 18.0,
650 direction_deg: 320.0,
651 };
652 let backing = WindShearProfile {
653 geostrophic_wind: Some(backing_geostrophic),
654 ..profile(350.0)
655 };
656 assert!(
657 (backing.get_wind_at_altitude(1000.0) - backing_geostrophic.to_vector()).norm() < 1e-12
658 );
659 }
660
661 #[test]
662 fn test_windsock_shear_is_independent_of_site_elevation() {
663 for model in [WindShearModel::Logarithmic, WindShearModel::PowerLaw] {
664 let profile = WindShearProfile {
665 model,
666 surface_wind: WindLayer {
667 altitude_m: 10.0,
668 speed_mps: 10.0,
669 direction_deg: 90.0,
670 },
671 ..Default::default()
672 };
673 let segments = vec![(10.0, 90.0, 1_000.0)];
674 let position = Vector3::new(100.0, 10.0, 0.0);
675
676 let sea_level = WindShearWindSock::with_shooter_altitude(
677 segments.clone(),
678 Some(profile.clone()),
679 0.0,
680 )
681 .vector_for_position(position);
682 let elevated =
683 WindShearWindSock::with_shooter_altitude(segments, Some(profile), 1_600.0)
684 .vector_for_position(position);
685
686 assert!(
687 (sea_level - elevated).norm() < 1e-12,
688 "{model:?} shear must use height above local ground, not site elevation: sea={sea_level:?}, elevated={elevated:?}"
689 );
690 let expected_speed = 10.0 * boundary_layer_speed_ratio(10.0, model);
691 assert!((elevated.norm() - expected_speed).abs() < 1e-12);
692 }
693 }
694
695 #[test]
696 fn test_windsock_flat_fire_preserves_operative_wind() {
697 for model in [WindShearModel::Logarithmic, WindShearModel::PowerLaw] {
698 let profile = WindShearProfile {
699 model,
700 surface_wind: WindLayer {
701 altitude_m: 10.0,
702 speed_mps: 10.0,
703 direction_deg: 90.0,
704 },
705 ..Default::default()
706 };
707 let sock = WindShearWindSock::new(
708 vec![(10.0, 90.0, 1_000.0)],
709 Some(profile),
710 );
711
712 for height_rel_launch_m in [-1.0, 0.0, 1.0, 5.0] {
713 let wind = sock.vector_for_position(Vector3::new(
714 100.0,
715 height_rel_launch_m,
716 0.0,
717 ));
718 assert!(
719 (wind.norm() - 10.0).abs() < 1e-12,
720 "{model:?} flat-fire wind at relative height {height_rel_launch_m} m must retain the operative 10 m/s input, got {wind:?}"
721 );
722 }
723
724 let aloft = sock.vector_for_position(Vector3::new(100.0, 100.0, 0.0));
725 assert!(
726 aloft.norm() > 10.0,
727 "{model:?} shear must still increase wind well above the launch height"
728 );
729 }
730 }
731}
732
733#[cfg(test)]
734mod fix_validation_tests {
735 use super::*;
736 use nalgebra::Vector3;
737
738 #[test]
739 fn test_get_wind_at_position_flat_fire_full_crosswind() {
740 let pos = Vector3::new(457.0, -1.0, 0.0); let segs = [(16.09344_f64, 90.0_f64, 1000.0_f64)];
744 let w = get_wind_at_position(&pos, &segs, true, "logarithmic", 0.0);
745 let expected = 16.09344 * 0.2777778; println!("flat-fire wind vec = {:?}, |Z| = {}", w, w.z.abs());
747 assert!(
748 (w.z.abs() - expected).abs() < 0.05,
749 "lateral wind should be ~full {expected:.3} m/s, got {:.3}",
750 w.z.abs()
751 );
752 }
753}