Grid Convergence and Wind-Direction Consistency#

Before reading this tutorial, read the WindKit grid-convergence example, which introduces true north, grid north, natural grid convergence, and effective grid convergence.

This tutorial focuses on the PyWAsP consequences: how direction reference frames affect binned wind climates, generalized wind climates, downscaling, direct prediction, and migration from older files.

Wind resource assessment depends on knowing which direction the wind is coming from. Direction is always measured relative to a north reference. In PyWAsP workflows that reference may be true north, the grid north of the map used for site effects, or the grid north of a mesoscale model. A 10-20 degree frame mismatch can shift energy between WAsP sectors and change roughness, shelter, or orographic corrections.

References

Hahmann, A. N. et al. (2020). The making of the New European Wind Atlas - Part 1: Model sensitivity. Geosci. Model Dev., 13, 5053-5078. https://gmd.copernicus.org/articles/13/5053/2020/

Global Wind Atlas: https://globalwindatlas.info/

Learning goals#

After working through this tutorial you will be able to:

  • Identify whether wind directions are true-north-relative or grid-relative before using them in PyWAsP.

  • Explain why WAsP sector bins make direction-frame errors visible in BWC, GWC, and WWC results.

  • Explain how the WRF ALPHA variable relates to the NEWA post-2018 direction-frame diagnosis.

  • Describe how PyWAsP 2.1 stores wind_dir_crs, uses automatic direction-frame alignment in generalize*, downscale*, and predict_* workflows, and applies explicit alignment with align_direction_crs(...).

  • Choose a practical correction and migration strategy for old GWC/GeoWC files and WAsP GUI exports.

Part 1 - Why Grid Convergence Matters in PyWAsP#

The WindKit example covers the projection geometry. For PyWAsP, the operational issue is simpler: WAsP site effects are evaluated in sectors tied to the map or site-effect CRS, while input wind directions may arrive in a different frame.

A binned wind climate (BWC) stores frequencies in discrete directional sectors, typically 12 sectors of 30 degrees. If a wind rose is true-north-relative but the site effects are grid-relative, the sector frequencies are applied to the wrong terrain directions. The correction must therefore be consistent with the CRS used for the map and site-effect calculation.

The figure below shows the practical effect: a frame rotation moves probability mass between sectors. That movement can change speedup and shelter corrections even when the wind speeds themselves are unchanged.

Wind direction frequency

Part 2 - Historical Context: WRF, ALPHA, and NEWA#

2.1 WRF grid rotation and the ALPHA variable#

The WRF mesoscale model can store wind directions relative to its computational grid. For the New European Wind Atlas (NEWA), WRF was run with a Lambert Conformal Conic projection, and each grid cell includes ALPHA: the angle, in degrees and positive counter-clockwise, by which the WRF grid is rotated relative to geographic coordinates.

ALPHA uses the opposite sign from the WindKit/PyWAsP grid-convergence convention. A positive ALPHA means WRF grid north points west of true north. To convert WRF-grid-relative wind direction to true-north-relative direction, subtract ALPHA:

WD_earth = (WD_wrf - ALPHA) % 360

WRF ALPHA grid rotation

2.2 What is NEWA?#

The New European Wind Atlas (NEWA) is a mesoscale wind resource dataset for Europe produced with the WRF model at 3 km horizontal resolution. The original atlas covers 1989-2018 and is described in detail in Hahmann et al. (2020).

NEWA provides, at each grid cell and height level, half-hourly time series of wind speed and wind direction. The wind directions in the original dataset were correctly rotated from WRF grid-relative to true-north-relative using the per-cell ALPHA variable.

2.3 The manual 2019-2023 extension#

After 2018, the dataset was extended manually to 2024 to include the most recent data. During this update process an error was introduced: the wind direction rotation step was partially omitted. The wind directions for 2019-2023 were therefore stored grid-relative in WRF coordinates rather than true-north-relative. This error only becomes clear when you compare against an independent true-north-relative dataset such as ERA5 over the full period.

Part 3 - Diagnosing the error#

3.1 Time series of direction difference#

The analysis used a representative site in the far North-West (longitude ≈ −13 °, latitude ≈ 62 °, height 100 m) where the WRF ALPHA is large (~18 °). ERA5 wind directions at the same point serve as a reference.

The monthly-averaged difference between the NEWA (WRF) and ERA5 wind directions is plotted below — both for the corrected NEWA data and for the original uncorrected data.

When ERA5 is in true-north coordinates and NEWA is expressed in in grid-north coordinates, their direction difference should reflect the small residual between the two models’ dynamics. The mean of that difference should align with the mean local meridian convergence of the WRF projection at the site, which is shown with the red line.

WRF vs ERA5 wind-direction difference through time

Figure 1. Monthly mean wind-direction difference (WRF − ERA5) at 100 m height for a site with large ALPHA (~18 °). The corrected line is stable over the full 1994–2024 period, meaning the physical relationship between the two datasets is consistent. The uncorrected line shows a step-change from 2019-2023 equal to roughly the local ALPHA value — the signature of the missing grid-to-earth rotation.

3.3 Using different projections#

As shown in the previous section it is easy to forget about the map rotation and just use the wind speed and direction without noticing anything strange. Usually in a WAsP context, you use the same map for the generalization and the downscaling. This was not the case for the New European Wind Atlas, where different maps were used for the generalization and downscaling, both with large meridian convergence angles.

However, for the microscale projection the proper meridian convergence was never treated correctly. This means that true-north relative generalized wind climates were used on a map with large meridian convergence. The impact of this is further illustrated in Part 6.

Part 4 - PyWAsP 2.1 Behavior#

PyWAsP 2.1 makes the direction reference frame explicit on BWC, GWC, and GeoWC datasets, and provides one alignment name in two places:

  • align_direction_crs=True is the automatic workflow option on downscale* and predict_* functions.

  • pw.align_direction_crs(...) is the explicit helper for rotating an existing TSWC, BWC, WWC, or GWC into another direction CRS.

  • bwc_from_tswc(..., wind_dir_crs=...) stores the projected grid frame used for binning. generalize* outputs always store wind_dir_crs.

  • With rotate_to_true_north=False, directions remain relative to the input grid and wind_dir_crs stores the projected source CRS WKT.

  • With rotate_to_true_north=True, directions are rotated to true north and wind_dir_crs stores the geodetic CRS WKT.

  • predict_* functions use a BWC wind_dir_crs to align directions with the input map before applying the per-output downscale correction.

If a BWC has no wind_dir_crs, PyWAsP warns and assumes its directions are already relative to the input topography grid. A geographic BWC wind_dir_crs is rejected because generalization and prediction require grid-relative BWC directions. Older GWC/GeoWC files without metadata retain their backward-compatible downscaling behavior, but true-north files should be repaired before use. GeoWC is not supported by pw.align_direction_crs(...) because it contains additional geostrophic variables that cannot be rotated by the WWC sector-rotation routine.

4.1 Affected APIs#

API family

Direction-frame behavior

bwc_from_tswc

With projected wind_dir_crs=, rotates true-north TSWC directions into that grid frame and stores its WKT on the BWC. Missing metadata is accepted with a warning later; geographic values are rejected.

generalize*

rotate_to_true_north=False by default. Output directions stay in the input grid frame and wind_dir_crs stores the projected source CRS WKT. With True, output directions are true-north-relative and wind_dir_crs stores geodetic CRS WKT.

downscale*

align_direction_crs=True by default. The output correction is derived from wind_dir_crs and the output site-effect CRS. Missing wind_dir_crs falls back to no rotation.

predict_*

align_direction_crs=True by default. Direct prediction uses a BWC wind_dir_crs, when present, to align directions with the input map and then applies the output correction for each target location.

align_direction_crs(...)

Explicitly rotates a TSWC, BWC, WWC, or GWC from source_wind_dir_crs or existing wind_dir_crs metadata into target_wind_dir_crs, then stores the target frame as wind_dir_crs. GeoWC is not supported.

The implementation uses WindKit’s grid_convergence(...) machinery internally. In PyWAsP terms, the important point is that wind_dir_crs records the source frame of stored BWC, GWC, and GeoWC directions, and automatic downscaling uses that frame to rotate directions into the output site-effect CRS. The explicit align_direction_crs(...) helper uses the same CRS-based rotation for standalone TSWC, BWC, WWC, and GWC datasets.

Part 6 - Extreme Projection Demonstration#

This executable example predicts and downscales a single-sector wind climate from UTM Zone 31N (EPSG:32631) to UTM Zone 41N (EPSG:32641) at 75 degrees N, 15 degrees E. These are intentionally extreme projections for the point; the purpose is to make the frame rotation easy to see.

The input BWC has 100% wind frequency in Sector 0. Without direction-frame correction, the peak stays in the original sector. With correction, the peak shifts by the inverse of the source-to-output grid-frame rotation. The direct site-effects prediction used here is equivalent to the direct predict_* path with adapt_gwc=False, and at this 100 m output height it should agree with generalize(..., rotate_to_true_north=False) + downscale(..., align_direction_crs=True).

[1]:
import sys

import matplotlib.pyplot as plt
import numpy as np
import windkit as wk

from pywasp import align_direction_crs
from pywasp.wasp.wind_atlas import (
    downscale_from_site_effects,
    generalize_from_site_effects,
    predict_wwc_from_site_effects,
)

# 1. Define coordinate reference systems (CRS) and location (75° N, 15° E)
pt_geo = wk.spatial.create_point(west_east=[15.0], south_north=[75.0], height=[100.0], crs=4326)
pt_32631 = wk.spatial.reproject(pt_geo, 32631)
pt_32641 = wk.spatial.reproject(pt_geo, 32641)

# 2. Calculate natural and effective grid-frame rotation
mc_in_natural = wk.spatial.grid_convergence(pt_32631).values[0]
mc_out_natural = wk.spatial.grid_convergence(pt_32641).values[0]
source_to_output_rotation = wk.spatial.grid_convergence(pt_32641, source=pt_32631).values[0]
# Downscaling applies the inverse grid-frame rotation to wind directions.
wind_direction_correction = -source_to_output_rotation

print(f"Input  (EPSG:32631) Natural MC: {mc_in_natural:+.4f}°")
print(f"Output (EPSG:32641) Natural MC: {mc_out_natural:+.4f}°")
print(f"Source-to-output grid-frame rotation: {source_to_output_rotation:+.4f}°")
print(f"Applied wind-direction correction: {wind_direction_correction:+.4f}° (clockwise plotted rotation)")
Input  (EPSG:32631) Natural MC: +11.6024°
Output (EPSG:32641) Natural MC: -47.0112°
Source-to-output grid-frame rotation: -58.6136°
Applied wind-direction correction: +58.6136° (clockwise plotted rotation)
[2]:
# 3. Create a single-sector BWC (100% wind frequency concentrated in Sector 0)
# We use a 12-sector BWC where Sector 0 is centered at 0° (range -15° to +15°)
bwc = wk.create_bwc(pt_32631, n_sectors=12, n_wsbins=20, not_empty=True).copy(deep=True)
bwc.attrs["wind_dir_crs"] = 32631  # Set the CRS for wind direction
bwc["wdfreq"][:] = 0.0
bwc["wdfreq"][0, 0] = 1.0

# Explicitly align the stored BWC into the output direction frame.
bwc_output_frame = align_direction_crs(bwc, target_wind_dir_crs=32641)

# 4. Create dummy Site Effects (flat terrain, open land z0 = 0.03)
def create_dummy_site_effects(ds):
    se = wk.create_wasp_site_effects(ds, n_sectors=12, not_empty=False, site_effects='all').copy(deep=True)
    se = se.fillna(0.0)
    se["user_def_speedups"][:] = 1.0
    se["orographic_speedups"][:] = 1.0
    se["obstacle_speedups"][:] = 1.0
    se["roughness_speedups"][:] = 1.0
    se["z0meso"][:] = 0.03
    return se

se_in = create_dummy_site_effects(pt_32631)
se_out = create_dummy_site_effects(pt_32641)
[3]:
# 5. Run direct WWC predictions (with and without direction-frame correction)
wwc_predict_no_mc = predict_wwc_from_site_effects(bwc, se_in, se_out, align_direction_crs=False)
wwc_predict_mc = predict_wwc_from_site_effects(bwc, se_in, se_out, align_direction_crs=True)

sectors = wwc_predict_no_mc.sector.values
bar_width = 30.0
plt.bar(sectors, wwc_predict_no_mc.wdfreq.values.flatten(), width=bar_width, alpha=0.5, label="WWC Predicted (No MC Correction)")
plt.bar(sectors, wwc_predict_mc.wdfreq.values.flatten(), width=bar_width, alpha=0.5, label="WWC Predicted (With MC Correction)")
plt.xticks(wwc_predict_no_mc.sector.values)
plt.xlabel("Wind direction sector (degrees)")
plt.ylabel("Frequency")
plt.legend(loc='center', bbox_to_anchor=(0.55, 0.6))
At line 555 of file ../../modules/waspcore/Rvea0288/Internal/generalization.f90
Fortran runtime warning: An array temporary was created for argument 'gd' of procedure 'gmom_ntb'
At line 555 of file ../../modules/waspcore/Rvea0288/Internal/generalization.f90
Fortran runtime warning: An array temporary was created for argument 'gf' of procedure 'gmom_ntb'
At line 586 of file ../../modules/waspcore/Rvea0288/Internal/generalization.f90
Fortran runtime warning: An array temporary was created for argument 'gd' of procedure 'gmom_ntb'
At line 586 of file ../../modules/waspcore/Rvea0288/Internal/generalization.f90
Fortran runtime warning: An array temporary was created for argument 'gf' of procedure 'gmom_ntb'
[3]:
<matplotlib.legend.Legend at 0x7ff03b73c6e0>
../_images/tutorials_tutorial9_nb_16_2.png

6.1 Geostrophic turning and residual probability mass#

The movement between the two WWC results is the meridian-convergence correction. The no-correction WWC can still retain probability in Sector 0; that residual is not a meridian-convergence effect. It comes from geostrophic turning and sector-model behaviour in WAsP, including interpolation on binned 30-degree sectors.

6.2 Single-step versus true-north two-step rotation#

The comparison below keeps the PyWAsP-specific lesson visible. With rotate_to_true_north=False, the two-step workflow stores the source CRS in wind_dir_crs and applies one effective output correction during downscaling. With rotate_to_true_north=True, the workflow rotates to true north during generalization and then into the output grid during downscaling. Because these rotations act on binned sectors, the true-north two-step path can smear the wind rose more.

[4]:
# 6. Run Generalize & Downscale Workflows (Option B vs Option C)

# Option B: rotate_to_true_north=False (Recommended two-step workflow)
gwc_false = generalize_from_site_effects(bwc, se_in, rotate_to_true_north=False)
gwc_false_reprojected = wk.spatial.reproject(gwc_false, 32641)
wwc_false_mc = downscale_from_site_effects(gwc_false_reprojected, se_out, align_direction_crs=True)

# Option C: rotate_to_true_north=True (Two-step workflow via True North)
gwc_true = generalize_from_site_effects(bwc, se_in, rotate_to_true_north=True)
gwc_true_reprojected = wk.spatial.reproject(gwc_true, 32641)
wwc_true_mc = downscale_from_site_effects(gwc_true_reprojected, se_out, align_direction_crs=True)

max_abs_diff = float(np.nanmax(np.abs(wwc_predict_mc.wdfreq.values - wwc_false_mc.wdfreq.values)))
print(f"Max abs frequency difference, direct WWC vs grid-relative two-step: {max_abs_diff:.3e}")

sectors = wwc_false_mc.sector.values
bar_width = 30.0
plt.bar(sectors, wwc_false_mc.wdfreq.values.flatten(), width=bar_width, alpha=0.5, label="Option B (rotate_to_true_north=False)")
plt.bar(sectors, wwc_true_mc.wdfreq.values.flatten(), width=bar_width, alpha=0.5, label="Option C (rotate_to_true_north=True)")
plt.xticks(wwc_false_mc.sector.values)
plt.xlabel("Wind direction sector (degrees)")
plt.ylabel("Frequency")
plt.legend();
Max abs frequency difference, direct WWC vs grid-relative two-step: 0.000e+00
../_images/tutorials_tutorial9_nb_19_1.png

6.3 Polar comparison#

The polar plots below are drawn in local grid-relative frames because that is the frame used by WAsP site effects. The dashed reference lines show how the same physical direction appears under the two projection grids.

[5]:
# 12 sectors, centered at 0, 30, 60, ..., 330 degrees
angles_rad = np.radians(np.arange(0, 360, 30))

# Extract the frequency values from xarray objects for the custom polar visualization
freq_wwc_no_mc = wwc_predict_no_mc.wdfreq.values.flatten()
freq_wwc_mc = wwc_predict_mc.wdfreq.values.flatten()
freq_false_mc = wwc_false_mc.wdfreq.values.flatten()
freq_true_mc = wwc_true_mc.wdfreq.values.flatten()

fig, axs = plt.subplots(2, 2, figsize=(12, 12), subplot_kw={'projection': 'polar'})

# Helper to configure polar plot aesthetics
def setup_polar_ax(ax, title):
    ax.set_theta_zero_location("N")
    ax.set_theta_direction(-1) # Clockwise
    ax.set_title(title, fontsize=10, pad=12, weight='bold')
    ax.set_xticks(np.radians(np.arange(0, 360, 30)))
    ax.set_xticklabels([f"{d}°" for d in range(0, 360, 30)], fontsize=8)
    ax.set_ylim(0, 1.05)
    ax.set_yticks([0.2, 0.4, 0.6, 0.8, 1.0])
    ax.set_yticklabels(["20%", "40%", "60%", "80%", "100%"], fontsize=7, color="gray")

# Panel 1: Predict WWC WITHOUT MC
setup_polar_ax(axs[0, 0], "Predict WWC WITHOUT MC\n(Ref Frame: UTM 41N Grid-North)")
axs[0, 0].bar(angles_rad, freq_wwc_no_mc, width=np.radians(30), bottom=0.0,
              edgecolor='#1a365d', facecolor='#2b6cb0', alpha=0.7)
line_tn = axs[0, 0].axvline(np.radians(-mc_in_natural), color='#e53e3e', linestyle='--', linewidth=2, label="Geographic True North")
line_out_n = axs[0, 0].axvline(np.radians(source_to_output_rotation), color='#38a169', linestyle=':', linewidth=2, label="UTM 41N Grid North")

def add_reference_lines_output(ax):
    ax.axvline(np.radians(-mc_out_natural), color='#e53e3e', linestyle='--', linewidth=2)
    ax.axvline(np.radians(wind_direction_correction), color='#3182ce', linestyle=':', linewidth=2)

# Panel 2: Predict WWC WITH MC
setup_polar_ax(axs[0, 1], "Predict WWC WITH MC\n(Ref Frame: UTM 41N Grid-North)")
axs[0, 1].bar(angles_rad, freq_wwc_mc, width=np.radians(30), bottom=0.0,
              edgecolor='#2c5282', facecolor='#3182ce', alpha=0.7)
add_reference_lines_output(axs[0, 1])
line_in_n = axs[0, 1].axvline(np.radians(wind_direction_correction), color='#3182ce', linestyle=':', linewidth=2, label="UTM 31N Grid North")

# Panel 4: Part C (Gen & Downscale, Option B)
setup_polar_ax(axs[1, 0], "Gen & Downscale (rotate_to_true_north=False)\n(Single-step Rotation: Sharp Peak)")
axs[1, 0].bar(angles_rad, freq_false_mc, width=np.radians(30), bottom=0.0,
              edgecolor='#22543d', facecolor='#38a169', alpha=0.7)
add_reference_lines_output(axs[1, 0])

# Panel 5: Part D (Gen & Downscale, Option C)
setup_polar_ax(axs[1, 1], "Gen & Downscale (rotate_to_true_north=True)\n(Double-step Rotation: Smeared)")
axs[1, 1].bar(angles_rad, freq_true_mc, width=np.radians(30), bottom=0.0,
              edgecolor='#744210', facecolor='#dd6b20', alpha=0.7)
add_reference_lines_output(axs[1, 1])

fig.legend(handles=[line_tn, line_in_n, line_out_n], loc='lower center',
           ncol=3, fontsize=10, frameon=True, facecolor='white', edgecolor='#e2e8f0')

plt.subplots_adjust(bottom=0.12, hspace=0.35, wspace=0.25)
../_images/tutorials_tutorial9_nb_21_0.png

Part 7 - Practical Checklist#

  1. Check whether every input direction dataset is true-north-relative or grid-relative before ingestion.

  2. For true-north observed time series, call bwc_from_tswc(..., wind_dir_crs=topo_map.elev_map.crs) so the BWC is explicitly grid-relative before generalization or prediction.

  3. Leave align_direction_crs=True unless you are intentionally reproducing legacy behavior.

  4. For the primary two-step workflow, use the default generalize(..., rotate_to_true_north=False) followed by downscale(..., align_direction_crs=True).

  5. When you want the predict_bwc result to match the equivalent two-step workflow, use predict_bwc or predict_wwc with adapt_gwc=False and the default direction-frame alignment. Above 100 m, also pass gen_heights=[10, 25, 50, 100, 250] to generalize.

  6. Verify or repair wind_dir_crs on old GWC/GeoWC files before downscaling. For TSWC, BWC, WWC, or GWC, use pw.align_direction_crs(...) when you need to convert a stored climate to a target direction frame explicitly.

  7. Compare predicted climates and observations in the same direction frame. Align one of them first if needed.

Part 8 - Migration Guide: PyWAsP < 2.1 and WAsP GUI Wind Climates#

GWC and GeoWC files produced before PyWAsP 2.1, or exported from WAsP GUI, may not contain wind_dir_crs. Without this attribute PyWAsP cannot know whether stored directions are true-north-relative or grid-relative. For backward compatibility, align_direction_crs=True falls back to no rotation when the attribute is absent.

That fallback is appropriate for many old WAsP GUI workflows where the GWC was created and reused in the same projected map frame. It is not appropriate for imported true-north GWC/GeoWC files, such as files from numerical wind atlas workflows, when downscaling on a projected microscale map.

Set or repair wind_dir_crs before downscaling:

import pyproj
import pywasp as pw
import windkit as wk

gwc = wk.read_gwc("my_old_atlas.gwc")

# True-north-relative GWC/GeoWC: store a geodetic CRS WKT.
gwc.attrs["wind_dir_crs"] = pyproj.CRS.from_epsg(4326).to_wkt()

wwc = pw.wasp.downscale(
    gwc,
    topo_map,
    target_locs,
    align_direction_crs=True,
)

For a grid-relative old file, store the projected CRS WKT of the map or site effects that define the wind-direction frame:

gwc.attrs["wind_dir_crs"] = wk.spatial.get_crs(site_effects).to_wkt()

If you are deliberately reproducing legacy behavior, set align_direction_crs=False or leave wind_dir_crs absent. Otherwise, make the direction CRS explicit. For old BWC, WWC, or GWC files with a known source frame, pw.align_direction_crs(...) can also rewrite the climate into the target frame before later use. GeoWC files should be repaired by setting wind_dir_crs metadata and then downscaled with align_direction_crs=True.

Glossary#

Term

Definition

``wind_dir_crs``

Attribute on BWC, GWC, and GeoWC datasets that records the CRS frame of stored wind directions. BWC values must be projected CRS WKT strings; GWC/GeoWC may also use geodetic CRS WKT strings for true-north-relative directions.

``rotate_to_true_north``

generalize* option. False keeps directions in the input grid frame and stores the projected source CRS WKT. True rotates directions to true north and stores geodetic CRS WKT.

``align_direction_crs=True``

downscale* and predict_* option. True uses wind_dir_crs and the output site-effect CRS to rotate directions into the output frame.

``pw.align_direction_crs(…)``

Explicit helper that rotates TSWC, BWC, WWC, or GWC directions from source_wind_dir_crs or existing wind_dir_crs metadata into target_wind_dir_crs. GeoWC is not supported.

Grid-relative BWC/GWC/WWC

A wind climate whose directions are measured from the grid north of a specific projected CRS.

True-north-relative GWC/GeoWC

A generalized or geostrophic wind climate whose directions are measured from geographic true north and whose wind_dir_crs should be geodetic CRS WKT.

WRF ``ALPHA``

Per-cell WRF grid-rotation angle, positive counter-clockwise. For NEWA diagnosis, missing ALPHA correction leaves WRF-grid-relative directions in files expected to be true-north-relative.

Appendix A - Internal Direction-Rotation Notes#

From PyWAsP 2.1, bwc_from_tswc(..., wind_dir_crs=...) creates an explicitly grid-relative BWC, and every GWC produced by generalize_from_site_effects() and every GeoWC produced by generalize_from_site_effects_to_geowc() carries wind_dir_crs.

Attribute value

Meaning

Geodetic CRS WKT

Directions are true-north-relative.

Projected CRS WKT

Directions are relative to that projection’s grid north.

absent

Older file; reference frame unknown. align_direction_crs=True falls back to no rotation.

The default rotate_to_true_north=False records the input site-effect CRS in wind_dir_crs. A later downscale can then compute the output correction even if the GWC has been reprojected, because wind_dir_crs records the original direction frame rather than the dataset’s current coordinate CRS.

The public rotation helpers used internally, such as pw.wwc_rotate and pw.bwc_resample_sectors, can also be useful when validating results against observations in a different direction frame.

Appendix B - Direction-sign conventions in the Part 6 example#

This appendix uses the same 100% north wind climate from Part 6. The input BWC has all probability in sector 0, and bwc.attrs["wind_dir_crs"] says that sector 0 is measured relative to UTM 31N grid north.

The important distinction is:

  • grid_convergence(...) returns a north-frame rotation.

  • Wind-direction sector numbers transform with the opposite sign, because the reference axis moves while the physical direction does not.

B.1 Natural versus effective grid convergence#

For a single CRS, grid_convergence(ds) is the rotation from true north to that CRS grid north:

gamma_crs = grid_north_crs - true_north

With source=, WindKit returns the frame rotation from the source CRS grid north to the target CRS grid north, evaluated at the target locations:

source_to_target = gamma_target - gamma_source

That is the value used by align_direction_crs=True when moving a GWC from its stored direction frame into the output site-effect frame. The explicit pw.align_direction_crs(...) helper uses the same sign convention when rewriting a stored TSWC, BWC, WWC, or GWC into a target direction frame.

[6]:
print(f"Input  UTM 31N natural convergence:  {mc_in_natural:+.4f}°")
print(f"Output UTM 41N natural convergence:  {mc_out_natural:+.4f}°")
print(f"Output minus input:                  {mc_out_natural - mc_in_natural:+.4f}°")
print(f"grid_convergence(output, source=input): {source_to_output_rotation:+.4f}°")
print()
print("Wind direction numbers use the opposite sign:")
print(f"  source-to-output frame rotation:   {source_to_output_rotation:+.4f}°")
print(f"  applied direction correction:      {wind_direction_correction:+.4f}°")
Input  UTM 31N natural convergence:  +11.6024°
Output UTM 41N natural convergence:  -47.0112°
Output minus input:                  -58.6136°
grid_convergence(output, source=input): -58.6136°

Wind direction numbers use the opposite sign:
  source-to-output frame rotation:   -58.6136°
  applied direction correction:      +58.6136°

B.2 Why the corrected peak moves from north#

The Part 6 BWC is deliberately simple: 100% frequency in sector 0. That means “wind from north” in the input direction frame, i.e. UTM 31N grid north.

When downscaling to UTM 41N with align_direction_crs=True, PyWAsP computes the effective input-grid to output-grid frame rotation and applies the inverse direction correction:

wd_output = (wd_input - source_to_output_rotation) % 360

For this example wd_input = 0°, so the expected output direction is exactly wind_direction_correction % 360. Because the climate is binned in 30° sectors, the plotted peak appears in the nearest sector center rather than at the exact continuous angle.

[7]:
input_peak_direction = 0.0
expected_output_direction = (input_peak_direction - source_to_output_rotation) % 360.0

def peak_sector_from_wdfreq(wc):
    wdfreq = wc.wdfreq.squeeze(drop=True)
    return float(wdfreq.sector.isel(sector=np.argmax(wdfreq.values)))

print(f"Input BWC peak direction:                    {input_peak_direction:7.3f}°")
print(f"Expected continuous output direction:        {expected_output_direction:7.3f}°")
print(f"Explicitly aligned BWC sector center:        {peak_sector_from_wdfreq(bwc_output_frame):7.3f}°")
print(f"Nearest corrected predict_wwc sector center: {peak_sector_from_wdfreq(wwc_predict_mc):7.3f}°")
print(f"No-correction predict_wwc sector center:     {peak_sector_from_wdfreq(wwc_predict_no_mc):7.3f}°")
Input BWC peak direction:                      0.000°
Expected continuous output direction:         58.614°
Explicitly aligned BWC sector center:         60.000°
Nearest corrected predict_wwc sector center:  60.000°
No-correction predict_wwc sector center:       0.000°

B.3 Function sign summary#

Function or workflow

Sign convention

wk.spatial.grid_convergence(ds)

True-north to ds grid-north frame rotation. Positive means grid north is clockwise of true north.

wk.spatial.grid_convergence(ds, source=...)

Source-grid to target-grid frame rotation: gamma_target - gamma_source.

Direction-number conversion

Opposite sign from the frame rotation: wd_target = (wd_source - effective_gamma) % 360.

generalize(..., rotate_to_true_north=False)

Keeps the GWC in the input map/grid frame and stores that frame in wind_dir_crs.

downscale(..., align_direction_crs=True)

Computes grid_convergence(output_site_effects, source=gwc.wind_dir_crs) and applies the inverse direction correction.

predict_wwc(..., align_direction_crs=True)

Same net convention as the grid-relative two-step workflow: align input to the input map frame, then apply the inverse source-to-output correction.

pw.align_direction_crs(wc, target_wind_dir_crs=...)

Rewrites TSWC, BWC, WWC, or GWC directions into the target frame with the same inverse direction-number convention and updates wind_dir_crs.

In Part 6, this is why the corrected 100% north climate moves by -source_to_output_rotation, while the uncorrected case stays near sector 0.