use nalgebra::Vector3;
use std::f64::consts::PI;
const APPROX_MUZZLE_HEIGHT_AGL_M: f64 = 1.5;
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum WindShearModel {
None,
Logarithmic,
PowerLaw,
EkmanSpiral,
CustomLayers,
}
#[derive(Debug, Clone, Copy)]
pub struct WindLayer {
pub altitude_m: f64,
pub speed_mps: f64,
pub direction_deg: f64, }
impl WindLayer {
pub fn to_vector(&self) -> Vector3<f64> {
let ang = self.direction_deg.to_radians();
Vector3::new(
-self.speed_mps * ang.cos(), 0.0, -self.speed_mps * ang.sin(), )
}
}
#[derive(Debug, Clone)]
pub struct WindShearProfile {
pub model: WindShearModel,
pub surface_wind: WindLayer,
pub reference_height: f64, pub roughness_length: f64, pub power_exponent: f64, pub geostrophic_wind: Option<WindLayer>, pub custom_layers: Vec<WindLayer>,
}
impl Default for WindShearProfile {
fn default() -> Self {
Self {
model: WindShearModel::None,
surface_wind: WindLayer {
altitude_m: 0.0,
speed_mps: 0.0,
direction_deg: 0.0,
},
reference_height: 10.0,
roughness_length: 0.03,
power_exponent: 1.0 / 7.0,
geostrophic_wind: None,
custom_layers: Vec::new(),
}
}
}
impl WindShearProfile {
pub fn get_wind_at_altitude(&self, altitude_m: f64) -> Vector3<f64> {
match self.model {
WindShearModel::None => self.surface_wind.to_vector(),
WindShearModel::Logarithmic => self.logarithmic_profile(altitude_m),
WindShearModel::PowerLaw => self.power_law_profile(altitude_m),
WindShearModel::EkmanSpiral => self.ekman_spiral_profile(altitude_m),
WindShearModel::CustomLayers => self.interpolate_layers(altitude_m),
}
}
fn logarithmic_profile(&self, altitude_m: f64) -> Vector3<f64> {
let effective_altitude = if altitude_m < 0.0 {
0.001 } else if altitude_m < 0.001 {
0.001
} else {
altitude_m
};
if effective_altitude <= self.roughness_length {
return Vector3::zeros();
}
let speed_ratio = if effective_altitude > self.roughness_length
&& self.reference_height > self.roughness_length
{
(effective_altitude / self.roughness_length).ln()
/ (self.reference_height / self.roughness_length).ln()
} else {
1.0
};
self.surface_wind.to_vector() * speed_ratio.max(0.0)
}
fn power_law_profile(&self, altitude_m: f64) -> Vector3<f64> {
if altitude_m <= 0.0 {
return Vector3::zeros();
}
let speed_ratio = (altitude_m / self.reference_height).powf(self.power_exponent);
self.surface_wind.to_vector() * speed_ratio
}
fn ekman_spiral_profile(&self, altitude_m: f64) -> Vector3<f64> {
let geo_wind = self.geostrophic_wind.unwrap_or({
WindLayer {
altitude_m: 1000.0,
speed_mps: self.surface_wind.speed_mps * 1.5,
direction_deg: self.surface_wind.direction_deg + 30.0, }
});
let ekman_depth = 1000.0;
if altitude_m >= ekman_depth {
return geo_wind.to_vector();
}
let ratio = altitude_m / ekman_depth;
let speed = self.surface_wind.speed_mps * (1.0 - ratio) + geo_wind.speed_mps * ratio;
let dir1 = self.surface_wind.direction_deg.to_radians();
let mut dir2 = geo_wind.direction_deg.to_radians();
if (dir2 - dir1).abs() > PI {
if dir2 > dir1 {
dir2 -= 2.0 * PI;
} else {
dir2 += 2.0 * PI;
}
}
let direction_rad = dir1 * (1.0 - ratio) + dir2 * ratio;
Vector3::new(
-speed * direction_rad.cos(), 0.0,
-speed * direction_rad.sin(), )
}
fn interpolate_layers(&self, altitude_m: f64) -> Vector3<f64> {
if self.custom_layers.is_empty() {
return self.surface_wind.to_vector();
}
let last = self.custom_layers.len() - 1;
if altitude_m >= self.custom_layers[last].altitude_m {
return self.custom_layers[last].to_vector();
}
let mut low_idx = 0;
let mut high_idx = 0;
for (i, layer) in self.custom_layers.iter().enumerate() {
if layer.altitude_m <= altitude_m {
low_idx = i;
}
if layer.altitude_m >= altitude_m {
high_idx = i;
break;
}
}
if low_idx == high_idx {
return self.custom_layers[low_idx].to_vector();
}
let low_layer = &self.custom_layers[low_idx];
let high_layer = &self.custom_layers[high_idx];
let altitude_diff = high_layer.altitude_m - low_layer.altitude_m;
if altitude_diff.abs() < 1e-9 {
return low_layer.to_vector();
}
let ratio = (altitude_m - low_layer.altitude_m) / altitude_diff;
let low_vec = low_layer.to_vector();
let high_vec = high_layer.to_vector();
low_vec * (1.0 - ratio) + high_vec * ratio
}
}
#[derive(Debug, Clone)]
pub struct WindShearWindSock {
pub segments: Vec<(f64, f64, f64)>, pub shear_profile: Option<WindShearProfile>,
pub shooter_altitude_m: f64,
}
impl WindShearWindSock {
pub fn new(segments: Vec<(f64, f64, f64)>, shear_profile: Option<WindShearProfile>) -> Self {
Self {
segments,
shear_profile,
shooter_altitude_m: 0.0,
}
}
pub fn with_shooter_altitude(
segments: Vec<(f64, f64, f64)>,
shear_profile: Option<WindShearProfile>,
shooter_altitude_m: f64,
) -> Self {
Self {
segments,
shear_profile,
shooter_altitude_m,
}
}
pub fn vector_for_position(&self, position: Vector3<f64>) -> Vector3<f64> {
let range_m = position.x; let altitude_m = position.y;
let base_wind = self.get_range_wind(range_m);
if let Some(profile) = &self.shear_profile {
if profile.model != WindShearModel::None {
if matches!(
profile.model,
WindShearModel::Logarithmic | WindShearModel::PowerLaw
) {
let speed_ratio = profile_boundary_layer_speed_ratio(profile, altitude_m);
let operative_wind = if base_wind.norm() > 0.0 {
base_wind
} else {
profile.surface_wind.to_vector()
};
return operative_wind * speed_ratio;
}
let altitude_vec = profile.get_wind_at_altitude(altitude_m);
if base_wind.norm() > 0.0 {
let scale_factor =
altitude_vec.norm() / profile.surface_wind.speed_mps.max(1e-9);
return base_wind * scale_factor;
}
return altitude_vec;
}
}
base_wind
}
fn get_range_wind(&self, range_m: f64) -> Vector3<f64> {
if range_m.is_nan() || self.segments.is_empty() {
return Vector3::zeros();
}
for &(speed_mps, angle_deg, until_dist) in &self.segments {
if range_m <= until_dist {
let ang = angle_deg.to_radians();
return Vector3::new(
-speed_mps * ang.cos(), 0.0,
-speed_mps * ang.sin(), );
}
}
Vector3::zeros()
}
}
fn profile_boundary_layer_speed_ratio(
profile: &WindShearProfile,
height_rel_launch_m: f64,
) -> f64 {
let minimum_height_m = profile.roughness_length.max(0.0) * 1.000_1;
let height_agl_m =
(height_rel_launch_m + APPROX_MUZZLE_HEIGHT_AGL_M).max(minimum_height_m);
let sampled_speed_mps = profile.get_wind_at_altitude(height_agl_m).norm();
let reference_speed_mps = profile.surface_wind.speed_mps.abs().max(1e-9);
(sampled_speed_mps / reference_speed_mps).max(1.0)
}
pub fn boundary_layer_speed_ratio(height_rel_launch_m: f64, model: WindShearModel) -> f64 {
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);
let ratio = match model {
WindShearModel::PowerLaw => (height_agl / H_REF).powf(1.0 / 7.0),
WindShearModel::Logarithmic => (height_agl / Z0).ln() / (H_REF / Z0).ln(),
_ => 1.0,
};
ratio.max(1.0)
}
pub(crate) fn boundary_layer_model_from_name(model: &str) -> WindShearModel {
match model {
"logarithmic" => WindShearModel::Logarithmic,
"power_law" | "powerlaw" => WindShearModel::PowerLaw,
"ekman_spiral" | "ekman" => WindShearModel::EkmanSpiral,
"custom_layers" | "custom" => WindShearModel::CustomLayers,
_ => WindShearModel::None,
}
}
pub(crate) fn apply_boundary_layer_shear(
base_wind: Vector3<f64>,
height_rel_launch_m: f64,
model: WindShearModel,
) -> Vector3<f64> {
base_wind * boundary_layer_speed_ratio(height_rel_launch_m, model)
}
pub fn get_wind_at_position(
position: &Vector3<f64>,
wind_segments: &[(f64, f64, f64)], enable_wind_shear: bool,
wind_shear_model: &str,
shooter_altitude_m: f64,
) -> Vector3<f64> {
let range_m = position[0];
let altitude_m = position[1];
let base_wind = if wind_segments.is_empty() {
(0.0, 0.0)
} else {
wind_segments
.iter()
.find(|seg| range_m < seg.2)
.map(|seg| (seg.0, seg.1))
.unwrap_or((0.0, 0.0))
};
let base_speed_mps = base_wind.0 * 0.2777778; let base_direction_deg = base_wind.1;
if !enable_wind_shear || wind_shear_model == "none" {
let ang = base_direction_deg.to_radians();
return Vector3::new(
-base_speed_mps * ang.cos(), 0.0, -base_speed_mps * ang.sin(), );
}
let _ = shooter_altitude_m;
let ang = base_direction_deg.to_radians();
let base_vector = Vector3::new(
-base_speed_mps * ang.cos(), 0.0, -base_speed_mps * ang.sin(), );
apply_boundary_layer_shear(
base_vector,
altitude_m,
boundary_layer_model_from_name(wind_shear_model),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_wind_layer() {
let layer_headwind = WindLayer {
altitude_m: 100.0,
speed_mps: 10.0,
direction_deg: 0.0, };
let vec = layer_headwind.to_vector();
assert!(
(vec.x - (-10.0)).abs() < 1e-6,
"0° wind should be headwind (negative X downrange)"
);
assert_eq!(vec.y, 0.0);
assert!(
(vec.z).abs() < 1e-6,
"0° wind (headwind) should have zero lateral (Z) component"
);
let layer_crosswind = WindLayer {
altitude_m: 100.0,
speed_mps: 10.0,
direction_deg: 90.0, };
let vec_cross = layer_crosswind.to_vector();
assert!(
(vec_cross.z - (-10.0)).abs() < 1e-6,
"90° wind should have negative Z lateral (from right)"
);
assert_eq!(vec_cross.y, 0.0);
assert!(
(vec_cross.x).abs() < 1e-6,
"90° wind (crosswind) should have zero downrange (X) component"
);
}
#[test]
fn test_logarithmic_profile() {
let mut profile = WindShearProfile::default();
profile.model = WindShearModel::Logarithmic;
profile.surface_wind = WindLayer {
altitude_m: 0.0,
speed_mps: 10.0,
direction_deg: 0.0,
};
let v10 = profile.get_wind_at_altitude(10.0).norm();
let v50 = profile.get_wind_at_altitude(50.0).norm();
let v100 = profile.get_wind_at_altitude(100.0).norm();
assert!(v10 > 0.0);
assert!(v50 > v10);
assert!(v100 > v50);
}
#[test]
fn test_boundary_layer_speed_ratio_flat_fire_full_wind() {
for &h in &[-15.0, -11.3, -1.0, -0.2, 0.0, 0.14, 1.5, 5.0, 8.0] {
let r_log = boundary_layer_speed_ratio(h, WindShearModel::Logarithmic);
let r_pow = boundary_layer_speed_ratio(h, WindShearModel::PowerLaw);
assert!(
(r_log - 1.0).abs() < 1e-9,
"logarithmic ratio at h={h} should be 1.0 (full wind), got {r_log}"
);
assert!(
(r_pow - 1.0).abs() < 1e-9,
"power-law ratio at h={h} should be 1.0 (full wind), got {r_pow}"
);
}
}
#[test]
fn test_boundary_layer_speed_ratio_increases_aloft() {
let r100 = boundary_layer_speed_ratio(100.0, WindShearModel::Logarithmic);
let r300 = boundary_layer_speed_ratio(300.0, WindShearModel::Logarithmic);
assert!(r100 > 1.0, "ratio at 100 m should exceed 1.0, got {r100}");
assert!(
r300 > r100,
"ratio should increase with altitude: {r300} !> {r100}"
);
assert!(
(r100 - 1.40).abs() < 0.10,
"ratio at ~100 m should be ~1.4, got {r100}"
);
}
#[test]
fn test_power_law_profile() {
let mut profile = WindShearProfile::default();
profile.model = WindShearModel::PowerLaw;
profile.surface_wind = WindLayer {
altitude_m: 0.0,
speed_mps: 10.0,
direction_deg: 0.0,
};
let v100 = profile.get_wind_at_altitude(100.0).norm();
let expected = 10.0 * (100.0_f64 / 10.0).powf(1.0 / 7.0);
assert!((v100 - expected).abs() < 0.01);
}
#[test]
fn default_ekman_profile_veers_with_height() {
let profile = |surface_direction| WindShearProfile {
model: WindShearModel::EkmanSpiral,
surface_wind: WindLayer {
altitude_m: 0.0,
speed_mps: 10.0,
direction_deg: surface_direction,
},
..Default::default()
};
for (surface_direction, halfway_direction, top_direction) in
[(0.0, 15.0, 30.0), (350.0, 365.0, 380.0)]
{
let profile = profile(surface_direction);
let halfway = profile.get_wind_at_altitude(500.0);
let expected_halfway = WindLayer {
altitude_m: 500.0,
speed_mps: 12.5,
direction_deg: halfway_direction,
}
.to_vector();
let top = profile.get_wind_at_altitude(1000.0);
let expected_top = WindLayer {
altitude_m: 1000.0,
speed_mps: 15.0,
direction_deg: top_direction,
}
.to_vector();
assert!((halfway - expected_halfway).norm() < 1e-12);
assert!((top - expected_top).norm() < 1e-12);
}
let wrapped_geostrophic = WindLayer {
altitude_m: 1000.0,
speed_mps: 8.0,
direction_deg: 30.0,
};
let wrapped = WindShearProfile {
geostrophic_wind: Some(wrapped_geostrophic),
..profile(350.0)
};
let wrapped_halfway = WindLayer {
altitude_m: 500.0,
speed_mps: 9.0,
direction_deg: 10.0,
}
.to_vector();
assert!((wrapped.get_wind_at_altitude(500.0) - wrapped_halfway).norm() < 1e-12);
assert!(
(wrapped.get_wind_at_altitude(1000.0) - wrapped_geostrophic.to_vector()).norm() < 1e-12
);
let backing_geostrophic = WindLayer {
altitude_m: 1000.0,
speed_mps: 18.0,
direction_deg: 320.0,
};
let backing = WindShearProfile {
geostrophic_wind: Some(backing_geostrophic),
..profile(350.0)
};
assert!(
(backing.get_wind_at_altitude(1000.0) - backing_geostrophic.to_vector()).norm() < 1e-12
);
}
#[test]
fn test_windsock_shear_is_independent_of_site_elevation() {
for model in [WindShearModel::Logarithmic, WindShearModel::PowerLaw] {
let profile = WindShearProfile {
model,
surface_wind: WindLayer {
altitude_m: 10.0,
speed_mps: 10.0,
direction_deg: 90.0,
},
..Default::default()
};
let segments = vec![(10.0, 90.0, 1_000.0)];
let position = Vector3::new(100.0, 10.0, 0.0);
let sea_level = WindShearWindSock::with_shooter_altitude(
segments.clone(),
Some(profile.clone()),
0.0,
)
.vector_for_position(position);
let elevated =
WindShearWindSock::with_shooter_altitude(segments, Some(profile), 1_600.0)
.vector_for_position(position);
assert!(
(sea_level - elevated).norm() < 1e-12,
"{model:?} shear must use height above local ground, not site elevation: sea={sea_level:?}, elevated={elevated:?}"
);
let expected_speed = 10.0 * boundary_layer_speed_ratio(10.0, model);
assert!((elevated.norm() - expected_speed).abs() < 1e-12);
}
}
#[test]
fn test_windsock_flat_fire_preserves_operative_wind() {
for model in [WindShearModel::Logarithmic, WindShearModel::PowerLaw] {
let profile = WindShearProfile {
model,
surface_wind: WindLayer {
altitude_m: 10.0,
speed_mps: 10.0,
direction_deg: 90.0,
},
..Default::default()
};
let sock = WindShearWindSock::new(
vec![(10.0, 90.0, 1_000.0)],
Some(profile),
);
for height_rel_launch_m in [-1.0, 0.0, 1.0, 5.0] {
let wind = sock.vector_for_position(Vector3::new(
100.0,
height_rel_launch_m,
0.0,
));
assert!(
(wind.norm() - 10.0).abs() < 1e-12,
"{model:?} flat-fire wind at relative height {height_rel_launch_m} m must retain the operative 10 m/s input, got {wind:?}"
);
}
let aloft = sock.vector_for_position(Vector3::new(100.0, 100.0, 0.0));
assert!(
aloft.norm() > 10.0,
"{model:?} shear must still increase wind well above the launch height"
);
}
}
}
#[cfg(test)]
mod fix_validation_tests {
use super::*;
use nalgebra::Vector3;
#[test]
fn test_get_wind_at_position_flat_fire_full_crosswind() {
let pos = Vector3::new(457.0, -1.0, 0.0); let segs = [(16.09344_f64, 90.0_f64, 1000.0_f64)];
let w = get_wind_at_position(&pos, &segs, true, "logarithmic", 0.0);
let expected = 16.09344 * 0.2777778; println!("flat-fire wind vec = {:?}, |Z| = {}", w, w.z.abs());
assert!(
(w.z.abs() - expected).abs() < 0.05,
"lateral wind should be ~full {expected:.3} m/s, got {:.3}",
w.z.abs()
);
}
}