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}