Skip to main content

winds_engine/
analytic.rs

1//! Analytic boundary-layer profiles: logarithmic and Ekman spiral.
2
3use crate::field::{Wind, WindField};
4use crate::{Error, Result};
5
6/// Log-law boundary layer: `u(z) = u_ref · ln(z/z0) / ln(z_ref/z0)` for
7/// `z > z0`, zero below the roughness length `z0`. Direction is constant
8/// with height.
9#[derive(Debug, Clone, Copy)]
10pub struct LogProfile {
11    /// Wind speed at the reference height, m/s.
12    pub reference_speed_mps: f64,
13    /// Reference height, metres (above the roughness length).
14    pub reference_height_m: f64,
15    /// Aerodynamic roughness length z0, metres.
16    pub roughness_m: f64,
17    /// Meteorological direction the air comes FROM, degrees (constant with
18    /// height).
19    pub direction_from_deg: f64,
20}
21
22impl LogProfile {
23    /// Validate the parameters.
24    pub fn new(
25        reference_speed_mps: f64,
26        reference_height_m: f64,
27        roughness_m: f64,
28        direction_from_deg: f64,
29    ) -> Result<Self> {
30        let profile = Self {
31            reference_speed_mps,
32            reference_height_m,
33            roughness_m,
34            direction_from_deg,
35        };
36        profile.validate()?;
37        Ok(profile)
38    }
39
40    fn validate(&self) -> Result<()> {
41        if !self.reference_speed_mps.is_finite() || self.reference_speed_mps < 0.0 {
42            return Err(Error::invalid(
43                "reference_speed_mps",
44                "must be a nonnegative number",
45            ));
46        }
47        if !self.reference_height_m.is_finite() || self.reference_height_m <= 0.0 {
48            return Err(Error::invalid("reference_height_m", "must be positive"));
49        }
50        if !self.roughness_m.is_finite()
51            || self.roughness_m <= 0.0
52            || self.roughness_m >= self.reference_height_m
53        {
54            return Err(Error::invalid(
55                "roughness_m",
56                "must be positive and below the reference height",
57            ));
58        }
59        Ok(())
60    }
61
62    /// Wind speed at an altitude, m/s.
63    pub fn speed_at(&self, altitude_m: f64) -> f64 {
64        if altitude_m <= self.roughness_m {
65            return 0.0;
66        }
67        let z0 = self.roughness_m;
68        self.reference_speed_mps * (altitude_m / z0).ln() / (self.reference_height_m / z0).ln()
69    }
70}
71
72impl WindField for LogProfile {
73    fn wind(&self, _time_s: f64, position_m: [f64; 3]) -> Wind {
74        Wind::from_speed_direction(self.speed_at(position_m[1]), self.direction_from_deg)
75    }
76}
77
78/// Ekman spiral: the classic solution for a neutrally stratified boundary
79/// layer with constant eddy viscosity `K`. At the ground the wind is zero;
80/// aloft it approaches the geostrophic speed `G`, backing (NH) or veering
81/// (SH) with height. See Holton, *An Introduction to Dynamic Meteorology*.
82///
83/// `u = G(1 − e^(−γz)·cos(γz))`, `v = sign(f)·G·e^(−γz)·sin(γz)` with
84/// `γ = sqrt(|f| / 2K)` and Coriolis `f = 2Ω·sin(latitude)`. The result is
85/// rotated so the geostrophic direction matches `direction_from_deg` aloft.
86#[derive(Debug, Clone, Copy)]
87pub struct EkmanProfile {
88    /// Geostrophic wind speed aloft, m/s.
89    pub geostrophic_mps: f64,
90    /// Latitude, degrees (sign sets the turning sense).
91    pub latitude_deg: f64,
92    /// Constant eddy viscosity K, m²/s.
93    pub eddy_viscosity_m2s: f64,
94    /// Meteorological direction the geostrophic wind comes FROM aloft,
95    /// degrees.
96    pub direction_from_deg: f64,
97}
98
99impl EkmanProfile {
100    /// Earth's rotation rate, rad/s.
101    const OMEGA: f64 = 7.2921159e-5;
102
103    /// Validate the parameters.
104    pub fn new(
105        geostrophic_mps: f64,
106        latitude_deg: f64,
107        eddy_viscosity_m2s: f64,
108        direction_from_deg: f64,
109    ) -> Result<Self> {
110        let profile = Self {
111            geostrophic_mps,
112            latitude_deg,
113            eddy_viscosity_m2s,
114            direction_from_deg,
115        };
116        profile.validate()?;
117        Ok(profile)
118    }
119
120    fn validate(&self) -> Result<()> {
121        if !self.geostrophic_mps.is_finite() || self.geostrophic_mps < 0.0 {
122            return Err(Error::invalid(
123                "geostrophic_mps",
124                "must be a nonnegative number",
125            ));
126        }
127        if !self.latitude_deg.is_finite() || self.latitude_deg.abs() >= 90.0 {
128            return Err(Error::invalid(
129                "latitude_deg",
130                "must be finite and away from the poles",
131            ));
132        }
133        if !self.eddy_viscosity_m2s.is_finite() || self.eddy_viscosity_m2s <= 0.0 {
134            return Err(Error::invalid("eddy_viscosity_m2s", "must be positive"));
135        }
136        Ok(())
137    }
138
139    fn coriolis(&self) -> f64 {
140        2.0 * Self::OMEGA * self.latitude_deg.to_radians().sin()
141    }
142
143    /// Ekman components `(u, v)` in the geostrophic frame at an altitude.
144    fn components_at(&self, altitude_m: f64) -> (f64, f64) {
145        let f = self.coriolis();
146        if f == 0.0 || altitude_m <= 0.0 {
147            return (0.0, 0.0);
148        }
149        let gamma = (f.abs() / (2.0 * self.eddy_viscosity_m2s)).sqrt();
150        let decay = (-gamma * altitude_m).exp();
151        let g = self.geostrophic_mps;
152        (
153            g * (1.0 - decay * (gamma * altitude_m).cos()),
154            f.signum() * g * decay * (gamma * altitude_m).sin(),
155        )
156    }
157
158    /// Wind at an altitude, rotated so the geostrophic direction aloft
159    /// matches `direction_from_deg`.
160    pub fn wind_at(&self, altitude_m: f64) -> Wind {
161        let (u, v) = self.components_at(altitude_m);
162        // Frame rotation: the solution's u points along the geostrophic
163        // flow; rotate into the requested geostrophic FROM-direction.
164        let flow_dir = self.direction_from_deg + 180.0;
165        let rot = flow_dir.to_radians();
166        let x = u * rot.cos() - v * rot.sin();
167        let z = u * rot.sin() + v * rot.cos();
168        Wind {
169            x_mps: x,
170            y_mps: 0.0,
171            z_mps: z,
172        }
173    }
174}
175
176impl WindField for EkmanProfile {
177    fn wind(&self, _time_s: f64, position_m: [f64; 3]) -> Wind {
178        self.wind_at(position_m[1])
179    }
180}