Skip to main content

wind_profile/
lib.rs

1//! # wind-profile
2//!
3//! Vertical wind-profile extrapolation for wind energy: take the wind a weather model
4//! gives you at its fixed output heights and move it to the height a turbine actually
5//! harvests at, then correct for what the air weighs when it gets there.
6//!
7//! ## Why hub height is a first-class problem
8//!
9//! Numerical weather models publish wind at a handful of fixed heights: 10 m always,
10//! and depending on the model an 80 m or 100 m level (GFS carries 80 m, the ECMWF open
11//! data carries 100 m). Modern turbine hubs sit at 80-160 m and climbing. Turbine power
12//! goes with the cube of wind speed below rated, so a relative error in extrapolated
13//! speed shows up three times as large in energy: 5% short on wind is roughly 15% short
14//! on generation. The vertical profile is not a rounding step, it is where the money is.
15//!
16//! ## Which law, when
17//!
18//! - **Power law**, [`wind_speed_m_s_power_law`]: the wind-energy engineering standard,
19//!   `u(z) = u_ref * (z / z_ref)^alpha`. Use [`shear_exponent_from_two_levels`] to derive
20//!   `alpha` from two model heights when the model provides them; that locality beats any
21//!   tabulated exponent. With only one height, [`SHEAR_EXPONENT_ONE_SEVENTH`] is the
22//!   classic neutral-onshore default (offshore shear is typically nearer 0.10).
23//! - **Log law**, [`wind_speed_m_s_log_law`]: the surface-layer similarity form,
24//!   `u(z) = u_ref * ln(z / z0) / ln(z_ref / z0)`. Use it when you know the terrain and
25//!   can pick a roughness length; the `Z0_*` constants carry the Davenport-Wieringa
26//!   classification from open sea to city centre.
27//!
28//! Both are neutral-stability forms, the honest baseline when all you have is model
29//! output. Diabatic (Monin-Obukhov) corrections need surface-flux inputs the target user
30//! rarely has and are deliberately out of scope for now.
31//!
32//! ```
33//! use wind_profile as wp;
34//!
35//! // A GFS-style pair: 6.2 m/s at 10 m, 8.9 m/s at 80 m. Derive the local shear...
36//! let alpha = wp::shear_exponent_from_two_levels(6.2, 10.0, 8.9, 80.0);
37//! assert!((alpha - 0.1738).abs() < 1e-3);
38//!
39//! // ...and carry the 80 m wind up to a 120 m hub.
40//! let u_hub = wp::wind_speed_m_s_power_law(8.9, 80.0, 120.0, alpha);
41//! assert!((u_hub - 9.55).abs() < 0.01);
42//! ```
43//!
44//! ## Air density, the other half of the power calculation
45//!
46//! Power also scales linearly with air density, and density at a warm low-pressure site
47//! is easily 10% off the 1.225 kg/m^3 reference that power curves are quoted at.
48//! [`air_density_kg_m3`] is the IEC 61400-12-1 equation (humidity-corrected, via the
49//! standard's own vapor-pressure fit), and [`density_corrected_wind_speed_m_s`] is the
50//! standard's `(rho / rho_ref)^(1/3)` wind-speed normalization that makes a measured
51//! speed comparable against a reference-density power curve.
52//!
53//! ```
54//! use wind_profile as wp;
55//!
56//! // Dry air at ISA sea level is the IEC reference density exactly.
57//! let rho = wp::air_density_kg_m3(288.15, 101_325.0, 0.0);
58//! assert!((rho - wp::REFERENCE_AIR_DENSITY_KG_M3).abs() < 1e-3);
59//! ```
60//!
61//! Units are explicit in every function name: `_m_s` metres per second, `_m` metres,
62//! `_k` kelvin, `_pa` pascals, `_kg_m3` kilograms per cubic metre. Inputs are physical
63//! quantities (heights above ground positive and above the roughness length, speeds
64//! positive where a ratio is taken); outside that domain the arithmetic yields NaN or
65//! infinity rather than a guess. No dependencies, no `unsafe`.
66
67#![forbid(unsafe_code)]
68#![warn(missing_docs)]
69
70/// Specific gas constant of dry air (J/(kg*K)), the value fixed by IEC 61400-12-1.
71pub const R_DRY_AIR: f64 = 287.05;
72/// Specific gas constant of water vapor (J/(kg*K)), per IEC 61400-12-1.
73pub const R_WATER_VAPOR: f64 = 461.5;
74/// The reference air density power curves are quoted at (kg/m^3): ISA sea level.
75pub const REFERENCE_AIR_DENSITY_KG_M3: f64 = 1.225;
76/// The classic neutral-stability onshore shear exponent, 1/7. A default for when the
77/// model gives only one wind height; prefer [`shear_exponent_from_two_levels`] whenever
78/// two heights exist. Offshore shear is typically nearer 0.10.
79pub const SHEAR_EXPONENT_ONE_SEVENTH: f64 = 1.0 / 7.0;
80
81/// Roughness length (m), Davenport-Wieringa class 1 "sea": open water, tidal flat.
82pub const Z0_SEA_M: f64 = 0.0002;
83/// Roughness length (m), Davenport-Wieringa class 2 "smooth": featureless land, ice.
84pub const Z0_SMOOTH_M: f64 = 0.005;
85/// Roughness length (m), Davenport-Wieringa class 3 "open": flat grassland, few obstacles.
86pub const Z0_OPEN_M: f64 = 0.03;
87/// Roughness length (m), Davenport-Wieringa class 4 "roughly open": low crops, scattered obstacles.
88pub const Z0_ROUGHLY_OPEN_M: f64 = 0.10;
89/// Roughness length (m), Davenport-Wieringa class 5 "rough": high crops, obstacle rows.
90pub const Z0_ROUGH_M: f64 = 0.25;
91/// Roughness length (m), Davenport-Wieringa class 6 "very rough": orchards, bushland.
92pub const Z0_VERY_ROUGH_M: f64 = 0.5;
93/// Roughness length (m), Davenport-Wieringa class 7 "closed": forest, suburb.
94pub const Z0_CLOSED_M: f64 = 1.0;
95/// Roughness length (m), Davenport-Wieringa class 8 "chaotic": city centre, high-rise.
96pub const Z0_CHAOTIC_M: f64 = 2.0;
97
98/// The shear exponent `alpha` implied by wind speeds observed at two heights:
99/// `alpha = ln(u_hi / u_lo) / ln(z_hi / z_lo)`.
100///
101/// This is the local, in-the-moment exponent, and with modern model output (10 m plus
102/// 80 m or 100 m) it beats any climatological default: it carries the actual stability
103/// and terrain of the hour. Feed it straight into [`wind_speed_m_s_power_law`].
104///
105/// Both speeds and both heights must be positive, with the heights distinct; the
106/// exponent is meaningful (and the profile monotone) when both speeds sit on the same
107/// side of the profile, the overwhelmingly common case.
108pub fn shear_exponent_from_two_levels(
109    u_lo_m_s: f64,
110    z_lo_m: f64,
111    u_hi_m_s: f64,
112    z_hi_m: f64,
113) -> f64 {
114    (u_hi_m_s / u_lo_m_s).ln() / (z_hi_m / z_lo_m).ln()
115}
116
117/// Wind speed at height `z_m` by the power law: `u_ref * (z / z_ref)^alpha`.
118///
119/// The wind-energy engineering standard for vertical extrapolation. `shear_exponent`
120/// comes from [`shear_exponent_from_two_levels`] when the model provides two heights,
121/// or [`SHEAR_EXPONENT_ONE_SEVENTH`] as the neutral onshore default. Heights are above
122/// ground and must be positive; at `z_m == z_ref_m` the input speed comes back exactly.
123pub fn wind_speed_m_s_power_law(
124    u_ref_m_s: f64,
125    z_ref_m: f64,
126    z_m: f64,
127    shear_exponent: f64,
128) -> f64 {
129    u_ref_m_s * (z_m / z_ref_m).powf(shear_exponent)
130}
131
132/// Wind speed at height `z_m` by the neutral log law:
133/// `u_ref * ln(z / z0) / ln(z_ref / z0)`.
134///
135/// The surface-layer similarity profile over terrain of roughness length
136/// `roughness_length_m` (pick a `Z0_*` constant, or supply a site value). Valid for
137/// heights well above the roughness length, under near-neutral stratification. Both
138/// heights must exceed `roughness_length_m`, which must be positive; at
139/// `z_m == z_ref_m` the input speed comes back exactly.
140pub fn wind_speed_m_s_log_law(
141    u_ref_m_s: f64,
142    z_ref_m: f64,
143    z_m: f64,
144    roughness_length_m: f64,
145) -> f64 {
146    u_ref_m_s * (z_m / roughness_length_m).ln() / (z_ref_m / roughness_length_m).ln()
147}
148
149/// Air density (kg/m^3) from temperature (K), pressure (Pa), and relative humidity
150/// (0 to 1), by the IEC 61400-12-1 equation.
151///
152/// `rho = (1/T) * (B/R0 - phi * Pw * (1/R0 - 1/Rw))`, with `Pw` the standard's own
153/// vapor-pressure fit `0.0000205 * exp(0.0631846 * T)` (Pa). Humid air is lighter than
154/// dry air at the same state, so the correction always lowers density. With
155/// `relative_humidity = 0` this reduces to the dry ideal-gas law.
156///
157/// Feed the temperature and pressure at hub height. The fit targets ordinary surface
158/// meteorology (roughly 258-313 K); it is not a general psychrometric model.
159pub fn air_density_kg_m3(temperature_k: f64, pressure_pa: f64, relative_humidity: f64) -> f64 {
160    let vapor_pressure_pa = 0.000_020_5 * (0.063_184_6 * temperature_k).exp();
161    (pressure_pa / R_DRY_AIR
162        - relative_humidity * vapor_pressure_pa * (1.0 / R_DRY_AIR - 1.0 / R_WATER_VAPOR))
163        / temperature_k
164}
165
166/// Wind speed normalized to a reference air density, per IEC 61400-12-1:
167/// `u * (rho / rho_ref)^(1/3)`.
168///
169/// Turbine power scales with `rho * u^3`, so a measured speed in air of density
170/// `rho_kg_m3` delivers the power that a speed of this value would deliver at
171/// `rho_ref_kg_m3`. Normalize site wind with this before reading a power curve quoted
172/// at [`REFERENCE_AIR_DENSITY_KG_M3`]. Thin air (lower density) corrects the speed
173/// downward.
174pub fn density_corrected_wind_speed_m_s(u_m_s: f64, rho_kg_m3: f64, rho_ref_kg_m3: f64) -> f64 {
175    u_m_s * (rho_kg_m3 / rho_ref_kg_m3).powf(1.0 / 3.0)
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181
182    fn approx(a: f64, b: f64, tol: f64) {
183        assert!((a - b).abs() < tol, "{a} vs {b} (tol {tol})");
184    }
185
186    #[test]
187    fn shear_exponent_recovers_the_constructed_profile() {
188        let (u_lo, z_lo, z_hi): (f64, f64, f64) = (5.0, 10.0, 120.0);
189        let u_hi = u_lo * (z_hi / z_lo).powf(0.2);
190        approx(
191            shear_exponent_from_two_levels(u_lo, z_lo, u_hi, z_hi),
192            0.2,
193            1e-12,
194        );
195    }
196
197    #[test]
198    fn one_seventh_law_matches_the_textbook_number() {
199        // 8 m/s at 10 m under the 1/7 law is 8 * 8^(1/7) = 10.767 m/s at 80 m.
200        let u = wind_speed_m_s_power_law(8.0, 10.0, 80.0, SHEAR_EXPONENT_ONE_SEVENTH);
201        approx(u, 10.767, 0.001);
202    }
203
204    #[test]
205    fn log_law_matches_the_hand_computation() {
206        // 8 m/s at 10 m over open grassland to 100 m: 8 * ln(100/0.03) / ln(10/0.03).
207        let u = wind_speed_m_s_log_law(8.0, 10.0, 100.0, Z0_OPEN_M);
208        approx(u, 11.171, 0.001);
209    }
210
211    #[test]
212    fn both_laws_return_the_input_at_the_reference_height() {
213        approx(wind_speed_m_s_power_law(7.3, 80.0, 80.0, 0.19), 7.3, 1e-12);
214        approx(
215            wind_speed_m_s_log_law(7.3, 80.0, 80.0, Z0_ROUGH_M),
216            7.3,
217            1e-12,
218        );
219    }
220
221    #[test]
222    fn dry_air_at_isa_sea_level_is_the_iec_reference_density() {
223        approx(
224            air_density_kg_m3(288.15, 101_325.0, 0.0),
225            REFERENCE_AIR_DENSITY_KG_M3,
226            1e-3,
227        );
228    }
229
230    #[test]
231    fn humid_air_is_lighter_than_dry_air() {
232        let dry = air_density_kg_m3(293.15, 101_325.0, 0.0);
233        let humid = air_density_kg_m3(293.15, 101_325.0, 1.0);
234        approx(dry, 1.2041, 5e-4); // ideal-gas value at 20 C
235        approx(humid, 1.1939, 5e-4); // saturated, per the IEC vapor-pressure fit
236        assert!(humid < dry);
237    }
238
239    #[test]
240    fn density_correction_is_the_cube_root_ratio() {
241        // At reference density the speed is untouched.
242        approx(
243            density_corrected_wind_speed_m_s(10.0, 1.225, 1.225),
244            10.0,
245            1e-12,
246        );
247        // 10% thin air corrects the speed down by the cube root: 0.9^(1/3) = 0.96549.
248        let u = density_corrected_wind_speed_m_s(10.0, 0.9 * 1.225, 1.225);
249        approx(u, 9.6549, 1e-3);
250    }
251
252    #[test]
253    fn derived_shear_carries_a_model_pair_to_hub_height() {
254        // The crate-docs example, end to end at full precision.
255        let alpha = shear_exponent_from_two_levels(6.2, 10.0, 8.9, 80.0);
256        let u_hub = wind_speed_m_s_power_law(8.9, 80.0, 120.0, alpha);
257        approx(u_hub, 9.5499, 1e-3);
258        // Extrapolating from the 10 m level with the same alpha lands on the same
259        // profile, so the two answers must agree.
260        approx(
261            wind_speed_m_s_power_law(6.2, 10.0, 120.0, alpha),
262            u_hub,
263            1e-9,
264        );
265    }
266}