Skip to content

Commit 583561c

Browse files
sunt05claude
andcommitted
feat: add wind speed height correction for EPW data handling
Addresses #149 - wind speed discrepancies when using EPW files as SUEWS forcing Changes: - Add correct_wind_height() utility for logarithmic wind profile corrections - Enhance read_epw() with optional wind_height and z0m parameters - Extend convert_UMEPf2epw() with height correction capabilities - Add comprehensive test coverage - Update documentation and CHANGELOG Technical details: - Uses logarithmic wind profile: U(z)/U(z_ref) = ln((z+z0m)/z0m) / ln((z_ref+z0m)/z0m) - EPW standard: wind at 10m, T/RH at 2m AGL - Backward compatible: all new parameters are optional - Consistent with existing ERA5 height correction approach 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 093df77 commit 583561c

File tree

5 files changed

+272
-6
lines changed

5 files changed

+272
-6
lines changed

CHANGELOG.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121

2222
| Year | Features | Bugfixes | Changes | Maintenance | Docs | Total |
2323
|------|----------|----------|---------|-------------|------|-------|
24-
| 2025 | 36 | 25 | 13 | 34 | 17 | 124 |
24+
| 2025 | 37 | 25 | 13 | 34 | 17 | 125 |
2525
| 2024 | 12 | 17 | 1 | 12 | 1 | 43 |
2626
| 2023 | 11 | 14 | 3 | 9 | 1 | 38 |
2727
| 2022 | 15 | 18 | 0 | 7 | 0 | 40 |
@@ -73,6 +73,12 @@
7373
- Improved validation for complex nested arrays in vertical layers configuration
7474

7575
### 16 Oct 2025
76+
- [feature] Added wind speed height correction for EPW data handling (#149)
77+
- Introduced `correct_wind_height()` utility function in `supy.util._atm` for logarithmic wind profile corrections
78+
- Enhanced `read_epw()` with optional `wind_height` and `z0m` parameters for automatic height adjustment
79+
- Extended `convert_UMEPf2epw()` with height correction parameters to properly handle non-standard measurement heights
80+
- Addresses issue where EPW standard height (10 m) differs from SUEWS forcing height configuration
81+
- Comprehensive test coverage for height correction functionality
7682
- [bugfix] Fixed Sphinx configuration errors preventing ReadTheDocs builds (648a83b)
7783
- Defined path_source variable before use in RTD build section
7884
- Converted rst_prolog to raw f-string to fix escape sequence warnings

src/supy/util/_UMEP2epw.py

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,23 @@
1818
from pathlib import Path
1919

2020

21-
def convert_UMEPf2epw(path_txt, lat, lon, tz, alt=None, path_epw=None):
21+
def convert_UMEPf2epw(
22+
path_txt,
23+
lat,
24+
lon,
25+
tz,
26+
alt=None,
27+
path_epw=None,
28+
wind_height_input=None,
29+
wind_height_output=10.0,
30+
z0m=0.1,
31+
):
2232
"""
2333
Converts and saves a UMEP-generated, year-long forcing file (.txt)
2434
with hourly resolution to .epw
2535
2636
NOTE: A small function is added now at the end to
27-
clear up .epw file formatting and it's header.
37+
clear up .epw file formatting and its header.
2838
It can be removed or some aspects integrated into supy.util.gen_epw.
2939
3040
Parameters
@@ -39,15 +49,35 @@ def convert_UMEPf2epw(path_txt, lat, lon, tz, alt=None, path_epw=None):
3949
Time zone expressed as a difference from UTC+0, such as -8 for UTC-8.
4050
alt : float, optional
4151
Altitude of the site.
42-
path_epw : path,optional
52+
path_epw : path, optional
4353
Path to the new .epw file.
54+
wind_height_input : float, optional
55+
Height [m] at which input UMEP wind speed was measured/modelled, by default None.
56+
If None, no height correction is applied to input data.
57+
If specified, wind speed will be corrected from this height to wind_height_output.
58+
wind_height_output : float, optional
59+
Target height [m] for output EPW wind speed, by default 10.0 (EPW standard).
60+
EPW standard is 10 m above ground level.
61+
z0m : float, optional
62+
Roughness length for momentum [m], by default 0.1.
63+
Only used if wind_height_input is specified.
4464
4565
Returns
4666
-------
4767
df_epw, text_meta, path_epw: Tuple[pd.DataFrame, str, Path]
4868
- df_epw: uTMY result
4969
- text_meta: meta-info text
5070
- path_epw: path to generated `epw` file
71+
72+
Notes
73+
-----
74+
EPW files follow standard meteorological measurement heights:
75+
- Wind speed: 10 m above ground level
76+
- Temperature, relative humidity, dew point: 2 m above ground level
77+
78+
UMEP forcing files may have wind speed at different heights depending on
79+
the source data and processing. Use wind_height_input to specify the
80+
actual height of UMEP wind data for proper conversion to EPW standard.
5181
"""
5282
# Dictionary for parameter naming conversion (UMEP to SUEWS)
5383
# NOTE: To be used with with 1.A column naming option, otherwise redundant.
@@ -185,6 +215,17 @@ def convert_UMEPf2epw(path_txt, lat, lon, tz, alt=None, path_epw=None):
185215
spec_hum = specific_humidity_from_mixing_ratio(mixing_ratio) * units("kg/kg")
186216
df_data["Q2"] = spec_hum.to("g/kg")
187217

218+
# (5.5) Apply wind speed height correction if needed
219+
if wind_height_input is not None:
220+
from supy.util._atm import correct_wind_height
221+
222+
df_data["U10"] = correct_wind_height(
223+
df_data["U10"],
224+
z_meas=wind_height_input,
225+
z_target=wind_height_output,
226+
z0m=z0m,
227+
)
228+
188229
# (6) Save data with supy.util.gen_epw
189230
data_epw, header_epw, path_2epw = supy.util.gen_epw(df_data, lat, lon, tz, path_epw)
190231

src/supy/util/_atm.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,60 @@ def cal_Lob(QH, UStar, Temp_C, RH_pct, Pres_hPa, g=9.8, k=0.4):
292292
return Lob
293293

294294

295+
# wind speed height correction
296+
def correct_wind_height(ws, z_meas, z_target, z0m=0.1, stability="neutral"):
297+
"""
298+
Correct wind speed from measurement height to target height using logarithmic wind profile.
299+
300+
Parameters
301+
----------
302+
ws : float or array-like
303+
Wind speed at measurement height [m s-1]
304+
z_meas : float
305+
Measurement height above ground level [m]
306+
z_target : float
307+
Target height above ground level [m]
308+
z0m : float, optional
309+
Roughness length for momentum [m], by default 0.1
310+
stability : str, optional
311+
Atmospheric stability condition: 'neutral', 'stable', or 'unstable', by default 'neutral'
312+
Note: stability corrections require additional parameters and are currently simplified
313+
314+
Returns
315+
-------
316+
float or array-like
317+
Wind speed corrected to target height [m s-1]
318+
319+
Notes
320+
-----
321+
This function uses the logarithmic wind profile under neutral conditions:
322+
U(z) / U(z_ref) = ln((z + z0m) / z0m) / ln((z_ref + z0m) / z0m)
323+
324+
For non-neutral conditions, stability corrections should be applied but are
325+
currently simplified in this implementation.
326+
327+
Examples
328+
--------
329+
>>> # Correct wind speed from standard EPW height (10 m) to forcing height (50 m)
330+
>>> ws_10m = 5.0 # m/s
331+
>>> ws_50m = correct_wind_height(ws_10m, z_meas=10, z_target=50, z0m=0.1)
332+
333+
>>> # Correct from 2 m to 10 m
334+
>>> ws_2m = 3.0
335+
>>> ws_10m = correct_wind_height(ws_2m, z_meas=2, z_target=10)
336+
"""
337+
# von Karman constant
338+
k = 0.4
339+
340+
# logarithmic wind profile under neutral conditions
341+
# U(z) / U(z_ref) = ln((z + z0m) / z0m) / ln((z_ref + z0m) / z0m)
342+
ws_corrected = ws * (
343+
np.log((z_target + z0m) / z0m) / np.log((z_meas + z0m) / z0m)
344+
)
345+
346+
return ws_corrected
347+
348+
295349
# aerodynamic resistance
296350
def cal_ra_obs(zm, zd, z0m, z0v, ws, Lob):
297351
"""

src/supy/util/_tmy.py

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -311,19 +311,50 @@ def conv_0to24(df_TMY):
311311

312312

313313
# function to read in EPW file
314-
def read_epw(path_epw: Path) -> pd.DataFrame:
314+
def read_epw(
315+
path_epw: Path,
316+
wind_height: float = None,
317+
z0m: float = 0.1,
318+
) -> pd.DataFrame:
315319
"""Read in `epw` file as a DataFrame
316320
317321
Parameters
318322
----------
319323
path_epw : Path
320324
path to `epw` file
325+
wind_height : float, optional
326+
Target height [m] to correct wind speed to, by default None.
327+
EPW wind speed is measured at 10 m above ground level by standard.
328+
If specified, wind speed will be corrected from 10 m to this height
329+
using logarithmic wind profile under neutral conditions.
330+
z0m : float, optional
331+
Roughness length for momentum [m], by default 0.1.
332+
Only used if wind_height is specified.
321333
322334
Returns
323335
-------
324336
df_tmy: pd.DataFrame
325-
TMY results of `epw` file
337+
TMY results of `epw` file with optional wind speed height correction
338+
339+
Notes
340+
-----
341+
EPW files follow standard meteorological measurement heights:
342+
- Wind speed: 10 m above ground level
343+
- Temperature, relative humidity, dew point: 2 m above ground level
344+
345+
When using EPW data as forcing for SUEWS, consider whether wind height
346+
correction is needed based on your forcing height configuration.
347+
348+
Examples
349+
--------
350+
>>> # Read EPW file without height correction
351+
>>> df_epw = read_epw("weather.epw")
352+
353+
>>> # Read EPW file and correct wind speed to 50 m
354+
>>> df_epw = read_epw("weather.epw", wind_height=50, z0m=0.1)
326355
"""
356+
from ._atm import correct_wind_height
357+
327358
df_tmy = pd.read_csv(path_epw, skiprows=8, sep=",", header=None)
328359
df_tmy.columns = [x.strip() for x in header_EPW.split("\n")[1:-1]]
329360
df_tmy["DateTime"] = pd.to_datetime(
@@ -334,6 +365,18 @@ def read_epw(path_epw: Path) -> pd.DataFrame:
334365
+ pd.to_timedelta(df_tmy["Hour"], unit="h")
335366
)
336367
df_tmy = df_tmy.set_index("DateTime")
368+
369+
# Apply wind speed height correction if requested
370+
if wind_height is not None:
371+
# EPW standard: wind speed at 10 m
372+
z_meas_epw = 10.0
373+
df_tmy["Wind Speed"] = correct_wind_height(
374+
df_tmy["Wind Speed"],
375+
z_meas=z_meas_epw,
376+
z_target=wind_height,
377+
z0m=z0m,
378+
)
379+
337380
return df_tmy
338381

339382

test/core/test_util_atm.py

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
cal_lat_vap,
2020
cal_qa,
2121
cal_rh,
22+
correct_wind_height,
2223
)
2324

2425

@@ -252,5 +253,126 @@ def test_pressure_variations(self):
252253
print(f"✓ qa at 3000m: {qa_alt * 1000:.3f} g/kg")
253254

254255

256+
class TestWindHeightCorrection(TestCase):
257+
"""Test wind speed height correction function."""
258+
259+
def test_wind_height_correction_basic(self):
260+
"""Test basic wind speed height correction."""
261+
print("\n========================================")
262+
print("Testing wind speed height correction...")
263+
264+
# Test correction from 10 m to 50 m
265+
ws_10m = 5.0 # m/s
266+
z0m = 0.1 # m
267+
268+
ws_50m = correct_wind_height(ws_10m, z_meas=10, z_target=50, z0m=z0m)
269+
270+
# Wind speed should increase with height
271+
self.assertGreater(ws_50m, ws_10m)
272+
# Should be physically reasonable (not more than 2x at these heights)
273+
self.assertLess(ws_50m, ws_10m * 2)
274+
275+
print(f"✓ Wind speed at 10 m: {ws_10m:.2f} m/s")
276+
print(f"✓ Wind speed at 50 m: {ws_50m:.2f} m/s")
277+
print(f"✓ Ratio: {ws_50m / ws_10m:.2f}")
278+
279+
def test_wind_height_correction_epw_standard(self):
280+
"""Test correction from EPW standard height (10 m) to 2 m."""
281+
print("\n========================================")
282+
print("Testing EPW to screen height correction...")
283+
284+
ws_10m = 5.0
285+
z0m = 0.1
286+
287+
# Correct down to 2 m (screen height)
288+
ws_2m = correct_wind_height(ws_10m, z_meas=10, z_target=2, z0m=z0m)
289+
290+
# Wind speed should decrease when going down
291+
self.assertLess(ws_2m, ws_10m)
292+
self.assertGreater(ws_2m, 0)
293+
294+
print(f"✓ Wind speed at 10 m (EPW): {ws_10m:.2f} m/s")
295+
print(f"✓ Wind speed at 2 m (screen): {ws_2m:.2f} m/s")
296+
297+
def test_wind_height_correction_reversibility(self):
298+
"""Test that forward and reverse corrections are consistent."""
299+
print("\n========================================")
300+
print("Testing wind height correction reversibility...")
301+
302+
ws_10m_original = 5.0
303+
z0m = 0.1
304+
305+
# Correct from 10 m to 50 m
306+
ws_50m = correct_wind_height(ws_10m_original, z_meas=10, z_target=50, z0m=z0m)
307+
308+
# Correct back from 50 m to 10 m
309+
ws_10m_back = correct_wind_height(ws_50m, z_meas=50, z_target=10, z0m=z0m)
310+
311+
# Should get back approximately the original value
312+
self.assertAlmostEqual(ws_10m_back, ws_10m_original, delta=1e-6)
313+
314+
print(f"✓ Original wind speed at 10 m: {ws_10m_original:.6f} m/s")
315+
print(f"✓ After correction to 50 m and back: {ws_10m_back:.6f} m/s")
316+
print(f"✓ Difference: {abs(ws_10m_back - ws_10m_original):.10f} m/s")
317+
318+
def test_wind_height_correction_same_height(self):
319+
"""Test that correction to same height returns original value."""
320+
print("\n========================================")
321+
print("Testing wind height correction with same height...")
322+
323+
ws_original = 5.0
324+
z0m = 0.1
325+
326+
ws_corrected = correct_wind_height(
327+
ws_original, z_meas=10, z_target=10, z0m=z0m
328+
)
329+
330+
self.assertAlmostEqual(ws_corrected, ws_original, delta=1e-10)
331+
332+
print(f"✓ Original wind speed: {ws_original:.2f} m/s")
333+
print(f"✓ Corrected (same height): {ws_corrected:.2f} m/s")
334+
335+
def test_wind_height_correction_array(self):
336+
"""Test wind speed height correction with array inputs."""
337+
print("\n========================================")
338+
print("Testing wind speed height correction with arrays...")
339+
340+
# Create array of wind speeds
341+
ws_10m_array = np.array([3.0, 5.0, 7.0, 10.0])
342+
z0m = 0.1
343+
344+
ws_50m_array = correct_wind_height(
345+
ws_10m_array, z_meas=10, z_target=50, z0m=z0m
346+
)
347+
348+
# All values should increase
349+
self.assertTrue(np.all(ws_50m_array > ws_10m_array))
350+
# Shape should be preserved
351+
self.assertEqual(ws_50m_array.shape, ws_10m_array.shape)
352+
353+
print(f"✓ Wind speeds at 10 m: {ws_10m_array}")
354+
print(f"✓ Wind speeds at 50 m: {ws_50m_array}")
355+
356+
def test_wind_height_correction_roughness(self):
357+
"""Test effect of different roughness lengths."""
358+
print("\n========================================")
359+
print("Testing wind height correction with different roughness...")
360+
361+
ws_10m = 5.0
362+
363+
# Urban surface (higher roughness)
364+
ws_50m_urban = correct_wind_height(ws_10m, z_meas=10, z_target=50, z0m=1.0)
365+
366+
# Grass surface (lower roughness)
367+
ws_50m_grass = correct_wind_height(ws_10m, z_meas=10, z_target=50, z0m=0.01)
368+
369+
# Higher roughness should lead to larger wind speed increase with height
370+
self.assertGreater(ws_50m_urban, ws_50m_grass)
371+
372+
print(f"✓ Wind speed at 10 m: {ws_10m:.2f} m/s")
373+
print(f"✓ Wind speed at 50 m (urban, z0m=1.0): {ws_50m_urban:.2f} m/s")
374+
print(f"✓ Wind speed at 50 m (grass, z0m=0.01): {ws_50m_grass:.2f} m/s")
375+
376+
255377
if __name__ == "__main__":
256378
unittest.main()

0 commit comments

Comments
 (0)