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