windrex 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- windrex/__init__.py +57 -0
- windrex/analysis/__init__.py +9 -0
- windrex/analysis/grid.py +117 -0
- windrex/analysis/mcda.py +143 -0
- windrex/config.py +118 -0
- windrex/core/__init__.py +15 -0
- windrex/core/capacity_factor.py +144 -0
- windrex/core/shear.py +80 -0
- windrex/core/temporal.py +83 -0
- windrex/core/wake.py +192 -0
- windrex/core/weibull.py +108 -0
- windrex/core/wind_rose.py +83 -0
- windrex/data/__init__.py +11 -0
- windrex/data/era5.py +146 -0
- windrex/data/lulc.py +164 -0
- windrex/data/nasa_power.py +101 -0
- windrex/data/open_meteo.py +89 -0
- windrex/data/terrain.py +208 -0
- windrex/data/turbine_db.py +184 -0
- windrex/economics/__init__.py +15 -0
- windrex/economics/financial.py +143 -0
- windrex/economics/sensitivity.py +54 -0
- windrex/regional/__init__.py +7 -0
- windrex/regional/analyzer.py +300 -0
- windrex/regional/zones.py +139 -0
- windrex/results.py +95 -0
- windrex-0.1.0.dist-info/METADATA +97 -0
- windrex-0.1.0.dist-info/RECORD +31 -0
- windrex-0.1.0.dist-info/WHEEL +5 -0
- windrex-0.1.0.dist-info/licenses/LICENSE +21 -0
- windrex-0.1.0.dist-info/top_level.txt +1 -0
windrex/__init__.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""
|
|
2
|
+
WindreX — Wind Resource eXchange
|
|
3
|
+
|
|
4
|
+
A Python library for wind resource assessment, wake modeling, turbine database
|
|
5
|
+
management, and capacity factor computation.
|
|
6
|
+
|
|
7
|
+
Modules:
|
|
8
|
+
- windrex.core: Weibull, wind rose, shear, temporal, wake, capacity factor
|
|
9
|
+
- windrex.data: Open-Meteo, NASA POWER, ERA5, terrain, LULC, turbine DB
|
|
10
|
+
- windrex.economics: Financial analysis and LCOE sensitivity
|
|
11
|
+
- windrex.analysis: MCDA engine and grid evaluation
|
|
12
|
+
- windrex.regional: Regional analyzer and zone generation
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
__version__ = "0.1.0"
|
|
16
|
+
__author__ = "WindreX Development Team"
|
|
17
|
+
|
|
18
|
+
from .core.weibull import fit_weibull, weibull_pdf, weibull_mean_power_density
|
|
19
|
+
from .core.wind_rose import compute_wind_rose, WindRoseData
|
|
20
|
+
from .core.shear import compute_wind_shear, extrapolate_speed
|
|
21
|
+
from .core.temporal import compute_diurnal_pattern, compute_seasonal_pattern
|
|
22
|
+
from .core.wake import jensen_wake_deficit, compute_array_efficiency, compute_spacing_curve
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"__version__",
|
|
26
|
+
# Weibull
|
|
27
|
+
"fit_weibull",
|
|
28
|
+
"weibull_pdf",
|
|
29
|
+
"weibull_mean_power_density",
|
|
30
|
+
# Wind rose
|
|
31
|
+
"compute_wind_rose",
|
|
32
|
+
"WindRoseData",
|
|
33
|
+
# Shear
|
|
34
|
+
"compute_wind_shear",
|
|
35
|
+
"extrapolate_speed",
|
|
36
|
+
# Temporal
|
|
37
|
+
"compute_diurnal_pattern",
|
|
38
|
+
"compute_seasonal_pattern",
|
|
39
|
+
# Wake
|
|
40
|
+
"jensen_wake_deficit",
|
|
41
|
+
"compute_array_efficiency",
|
|
42
|
+
"compute_spacing_curve",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def __getattr__(name):
|
|
47
|
+
"""Lazy import for heavy modules."""
|
|
48
|
+
if name == "compute_wind_hourly_cf":
|
|
49
|
+
from .core.capacity_factor import compute_wind_hourly_cf
|
|
50
|
+
return compute_wind_hourly_cf
|
|
51
|
+
if name == "WindAnalyzer":
|
|
52
|
+
from .regional.analyzer import WindAnalyzer
|
|
53
|
+
return WindAnalyzer
|
|
54
|
+
if name == "compute_wind_financials":
|
|
55
|
+
from .economics.financial import compute_wind_financials
|
|
56
|
+
return compute_wind_financials
|
|
57
|
+
raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
|
windrex/analysis/grid.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Grid evaluation and zone generation for wind assessment."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def build_grid(
|
|
11
|
+
bounds: tuple[float, float, float, float],
|
|
12
|
+
resolution: float = 0.25,
|
|
13
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
14
|
+
"""Build regular lat/lon grid within domain bounds.
|
|
15
|
+
|
|
16
|
+
Parameters
|
|
17
|
+
----------
|
|
18
|
+
bounds : tuple
|
|
19
|
+
(south, west, north, east) in degrees.
|
|
20
|
+
resolution : float
|
|
21
|
+
Grid spacing in degrees.
|
|
22
|
+
|
|
23
|
+
Returns
|
|
24
|
+
-------
|
|
25
|
+
tuple[np.ndarray, np.ndarray]
|
|
26
|
+
(lats, lons) — 1D arrays of grid coordinates.
|
|
27
|
+
"""
|
|
28
|
+
south, west, north, east = bounds
|
|
29
|
+
lats = np.arange(south + resolution / 2, north, resolution)
|
|
30
|
+
lons = np.arange(west + resolution / 2, east, resolution)
|
|
31
|
+
if len(lats) == 0:
|
|
32
|
+
lats = np.array([(south + north) / 2])
|
|
33
|
+
if len(lons) == 0:
|
|
34
|
+
lons = np.array([(west + east) / 2])
|
|
35
|
+
return lats, lons
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def compute_distance_to_grid(
|
|
39
|
+
grid_points: list[dict],
|
|
40
|
+
transmission_lines: list[dict],
|
|
41
|
+
bounds: tuple[float, float, float, float],
|
|
42
|
+
) -> list[float]:
|
|
43
|
+
"""Compute minimum distance from each grid point to nearest transmission line.
|
|
44
|
+
|
|
45
|
+
Parameters
|
|
46
|
+
----------
|
|
47
|
+
grid_points : list[dict]
|
|
48
|
+
List of dicts with 'lat' and 'lon' keys.
|
|
49
|
+
transmission_lines : list[dict]
|
|
50
|
+
List of line dicts with 'coords' key (list of [lat, lon] pairs).
|
|
51
|
+
bounds : tuple
|
|
52
|
+
(south, west, north, east) for latitude correction.
|
|
53
|
+
|
|
54
|
+
Returns
|
|
55
|
+
-------
|
|
56
|
+
list[float]
|
|
57
|
+
Distance in km per grid point.
|
|
58
|
+
"""
|
|
59
|
+
n = len(grid_points)
|
|
60
|
+
if not transmission_lines:
|
|
61
|
+
return [0.0] * n
|
|
62
|
+
|
|
63
|
+
line_coords = []
|
|
64
|
+
for line in transmission_lines:
|
|
65
|
+
coords = line.get("coords", [])
|
|
66
|
+
for coord in coords:
|
|
67
|
+
line_coords.append((coord[0], coord[1]))
|
|
68
|
+
|
|
69
|
+
if not line_coords:
|
|
70
|
+
return [0.0] * n
|
|
71
|
+
|
|
72
|
+
south, west, north, east = bounds
|
|
73
|
+
lc = np.array(line_coords)
|
|
74
|
+
km_per_deg_lat = 111.32
|
|
75
|
+
mid_lat = (south + north) / 2
|
|
76
|
+
km_per_deg_lon = 111.32 * math.cos(math.radians(mid_lat))
|
|
77
|
+
|
|
78
|
+
pt_lats = np.array([pt["lat"] for pt in grid_points])
|
|
79
|
+
pt_lons = np.array([pt["lon"] for pt in grid_points])
|
|
80
|
+
|
|
81
|
+
dy = (pt_lats[:, None] - lc[None, :, 0]) * km_per_deg_lat
|
|
82
|
+
dx = (pt_lons[:, None] - lc[None, :, 1]) * km_per_deg_lon
|
|
83
|
+
dist = np.sqrt(dx ** 2 + dy ** 2)
|
|
84
|
+
|
|
85
|
+
return np.min(dist, axis=1).tolist()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def wind_cf_from_speed(
|
|
89
|
+
ws_hourly: np.ndarray,
|
|
90
|
+
pc_wind_speeds: list[float],
|
|
91
|
+
pc_power_mw: list[float],
|
|
92
|
+
rated_mw: float,
|
|
93
|
+
) -> float:
|
|
94
|
+
"""Compute mean capacity factor from hourly wind speeds and power curve.
|
|
95
|
+
|
|
96
|
+
Parameters
|
|
97
|
+
----------
|
|
98
|
+
ws_hourly : np.ndarray
|
|
99
|
+
Hourly wind speed at hub height (m/s).
|
|
100
|
+
pc_wind_speeds : list[float]
|
|
101
|
+
Power curve wind speeds (m/s).
|
|
102
|
+
pc_power_mw : list[float]
|
|
103
|
+
Power curve output (MW).
|
|
104
|
+
rated_mw : float
|
|
105
|
+
Turbine rated power (MW).
|
|
106
|
+
|
|
107
|
+
Returns
|
|
108
|
+
-------
|
|
109
|
+
float
|
|
110
|
+
Mean capacity factor.
|
|
111
|
+
"""
|
|
112
|
+
if not pc_wind_speeds or not pc_power_mw or rated_mw <= 0:
|
|
113
|
+
return 0.0
|
|
114
|
+
|
|
115
|
+
ws = np.asarray(ws_hourly, dtype=float)
|
|
116
|
+
power_out = np.interp(ws, pc_wind_speeds, pc_power_mw, left=0.0, right=0.0)
|
|
117
|
+
return float(np.nanmean(power_out) / rated_mw)
|
windrex/analysis/mcda.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Multi-Criteria Decision Analysis (MCDA) engine.
|
|
2
|
+
|
|
3
|
+
Supports three weighting methods:
|
|
4
|
+
- Shannon entropy weights
|
|
5
|
+
- PCA-based weights (first principal component loadings)
|
|
6
|
+
- Manual weights (user-supplied)
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def compute_mcda_scores(
|
|
15
|
+
grid_points: list[dict],
|
|
16
|
+
criteria: dict[str, dict],
|
|
17
|
+
method: str = "entropy",
|
|
18
|
+
) -> tuple[np.ndarray, dict[str, float]]:
|
|
19
|
+
"""Compute MCDA composite scores for grid points.
|
|
20
|
+
|
|
21
|
+
Parameters
|
|
22
|
+
----------
|
|
23
|
+
grid_points : list[dict]
|
|
24
|
+
List of dicts with criterion values as keys.
|
|
25
|
+
criteria : dict[str, dict]
|
|
26
|
+
Criteria configuration. Each key maps to a dict with:
|
|
27
|
+
- 'enabled': bool
|
|
28
|
+
- 'weight': float (for manual method)
|
|
29
|
+
- 'direction': 'maximize' or 'minimize'
|
|
30
|
+
method : str
|
|
31
|
+
Weighting method: 'entropy', 'pca', or 'manual'.
|
|
32
|
+
|
|
33
|
+
Returns
|
|
34
|
+
-------
|
|
35
|
+
tuple[np.ndarray, dict[str, float]]
|
|
36
|
+
(scores, computed_weights) — composite scores per grid point
|
|
37
|
+
and the weights used.
|
|
38
|
+
"""
|
|
39
|
+
enabled = {name: c for name, c in criteria.items() if c.get("enabled", True)}
|
|
40
|
+
criteria_names = list(enabled.keys())
|
|
41
|
+
n_cells = len(grid_points)
|
|
42
|
+
n_criteria = len(criteria_names)
|
|
43
|
+
|
|
44
|
+
if n_cells == 0 or n_criteria == 0:
|
|
45
|
+
return np.zeros(n_cells), {}
|
|
46
|
+
|
|
47
|
+
# Build raw matrix
|
|
48
|
+
raw_matrix = np.zeros((n_cells, n_criteria))
|
|
49
|
+
for j, name in enumerate(criteria_names):
|
|
50
|
+
for i, pt in enumerate(grid_points):
|
|
51
|
+
raw_matrix[i, j] = pt.get(name, 0.0)
|
|
52
|
+
|
|
53
|
+
# Min-max normalization
|
|
54
|
+
norm_matrix = np.zeros_like(raw_matrix)
|
|
55
|
+
for j in range(n_criteria):
|
|
56
|
+
col = raw_matrix[:, j]
|
|
57
|
+
col_min, col_max = col.min(), col.max()
|
|
58
|
+
if col_max - col_min > 1e-10:
|
|
59
|
+
norm_matrix[:, j] = (col - col_min) / (col_max - col_min)
|
|
60
|
+
else:
|
|
61
|
+
norm_matrix[:, j] = 0.5
|
|
62
|
+
|
|
63
|
+
# Invert for minimize criteria
|
|
64
|
+
if enabled[criteria_names[j]].get("direction", "maximize") == "minimize":
|
|
65
|
+
norm_matrix[:, j] = 1.0 - norm_matrix[:, j]
|
|
66
|
+
|
|
67
|
+
# Compute weights
|
|
68
|
+
if method == "entropy":
|
|
69
|
+
weights = entropy_weights(norm_matrix)
|
|
70
|
+
elif method == "pca":
|
|
71
|
+
weights = pca_weights(norm_matrix)
|
|
72
|
+
else:
|
|
73
|
+
raw_w = np.array([enabled[name].get("weight", 1.0) for name in criteria_names])
|
|
74
|
+
total_w = raw_w.sum()
|
|
75
|
+
weights = raw_w / total_w if total_w > 0 else np.ones(n_criteria) / n_criteria
|
|
76
|
+
|
|
77
|
+
computed = {name: float(weights[j]) for j, name in enumerate(criteria_names)}
|
|
78
|
+
scores = norm_matrix @ weights
|
|
79
|
+
|
|
80
|
+
return scores, computed
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def entropy_weights(norm_matrix: np.ndarray) -> np.ndarray:
|
|
84
|
+
"""Compute weights using Shannon entropy method.
|
|
85
|
+
|
|
86
|
+
Parameters
|
|
87
|
+
----------
|
|
88
|
+
norm_matrix : np.ndarray
|
|
89
|
+
Normalized decision matrix, shape (n_alternatives, n_criteria).
|
|
90
|
+
|
|
91
|
+
Returns
|
|
92
|
+
-------
|
|
93
|
+
np.ndarray
|
|
94
|
+
Weights per criterion.
|
|
95
|
+
"""
|
|
96
|
+
n, m = norm_matrix.shape
|
|
97
|
+
if n <= 1:
|
|
98
|
+
return np.ones(m) / m
|
|
99
|
+
|
|
100
|
+
shifted = norm_matrix + 1e-10
|
|
101
|
+
col_sums = shifted.sum(axis=0)
|
|
102
|
+
col_sums[col_sums == 0] = 1
|
|
103
|
+
p = shifted / col_sums
|
|
104
|
+
|
|
105
|
+
k = 1.0 / np.log(n)
|
|
106
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
107
|
+
H = -k * np.nansum(p * np.log(p + 1e-30), axis=0)
|
|
108
|
+
|
|
109
|
+
d = np.maximum(1.0 - H, 0)
|
|
110
|
+
total = d.sum()
|
|
111
|
+
if total > 0:
|
|
112
|
+
return d / total
|
|
113
|
+
return np.ones(m) / m
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def pca_weights(norm_matrix: np.ndarray) -> np.ndarray:
|
|
117
|
+
"""Compute weights from first principal component loadings.
|
|
118
|
+
|
|
119
|
+
Parameters
|
|
120
|
+
----------
|
|
121
|
+
norm_matrix : np.ndarray
|
|
122
|
+
Normalized decision matrix.
|
|
123
|
+
|
|
124
|
+
Returns
|
|
125
|
+
-------
|
|
126
|
+
np.ndarray
|
|
127
|
+
Weights per criterion.
|
|
128
|
+
"""
|
|
129
|
+
n, m = norm_matrix.shape
|
|
130
|
+
if n <= m or m <= 1:
|
|
131
|
+
return np.ones(m) / m
|
|
132
|
+
|
|
133
|
+
std = norm_matrix.std(axis=0)
|
|
134
|
+
std[std == 0] = 1
|
|
135
|
+
standardized = (norm_matrix - norm_matrix.mean(axis=0)) / std
|
|
136
|
+
|
|
137
|
+
# SVD-based PCA (no sklearn dependency)
|
|
138
|
+
_, _, Vt = np.linalg.svd(standardized, full_matrices=False)
|
|
139
|
+
loadings = np.abs(Vt[0])
|
|
140
|
+
total = loadings.sum()
|
|
141
|
+
if total > 0:
|
|
142
|
+
return loadings / total
|
|
143
|
+
return np.ones(m) / m
|
windrex/config.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Configuration dataclasses for WindreX."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class TurbineSpec:
|
|
11
|
+
"""Technical specification of a wind turbine.
|
|
12
|
+
|
|
13
|
+
Parameters
|
|
14
|
+
----------
|
|
15
|
+
key : str
|
|
16
|
+
Unique identifier (e.g. 'Vestas_V112_3MW').
|
|
17
|
+
name : str
|
|
18
|
+
Display name.
|
|
19
|
+
manufacturer : str
|
|
20
|
+
Manufacturer name.
|
|
21
|
+
rated_power_mw : float
|
|
22
|
+
Rated power in MW.
|
|
23
|
+
rotor_diameter_m : float
|
|
24
|
+
Rotor diameter in meters.
|
|
25
|
+
hub_height_m : float
|
|
26
|
+
Hub height in meters.
|
|
27
|
+
source : str
|
|
28
|
+
Database source ('atlite', 'oedb', 'custom').
|
|
29
|
+
wind_speeds : list[float]
|
|
30
|
+
Power curve wind speed bins (m/s).
|
|
31
|
+
power_curve : list[float]
|
|
32
|
+
Power curve output values (MW).
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
key: str = ""
|
|
36
|
+
name: str = ""
|
|
37
|
+
manufacturer: str = ""
|
|
38
|
+
rated_power_mw: float = 3.0
|
|
39
|
+
rotor_diameter_m: float = 112.0
|
|
40
|
+
hub_height_m: float = 80.0
|
|
41
|
+
source: str = "custom"
|
|
42
|
+
wind_speeds: list[float] = field(default_factory=list)
|
|
43
|
+
power_curve: list[float] = field(default_factory=list)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class CriterionConfig:
|
|
48
|
+
"""Configuration for a single MCDA criterion.
|
|
49
|
+
|
|
50
|
+
Parameters
|
|
51
|
+
----------
|
|
52
|
+
name : str
|
|
53
|
+
Criterion name (e.g. 'mean_speed', 'slope', 'elevation').
|
|
54
|
+
weight : float
|
|
55
|
+
Manual weight (0-1). Ignored if using entropy/PCA weights.
|
|
56
|
+
is_benefit : bool
|
|
57
|
+
True if higher is better; False if lower is better.
|
|
58
|
+
enabled : bool
|
|
59
|
+
Whether this criterion is active.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
name: str = ""
|
|
63
|
+
weight: float = 1.0
|
|
64
|
+
is_benefit: bool = True
|
|
65
|
+
enabled: bool = True
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class MCDAConfig:
|
|
70
|
+
"""Configuration for MCDA site suitability analysis.
|
|
71
|
+
|
|
72
|
+
Parameters
|
|
73
|
+
----------
|
|
74
|
+
method : str
|
|
75
|
+
Weighting method: 'entropy', 'pca', 'manual'.
|
|
76
|
+
criteria : list[CriterionConfig]
|
|
77
|
+
List of criterion configurations.
|
|
78
|
+
n_zones : int
|
|
79
|
+
Number of development zones to generate.
|
|
80
|
+
min_zone_area_km2 : float
|
|
81
|
+
Minimum zone area in km².
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
method: str = "entropy"
|
|
85
|
+
criteria: list[CriterionConfig] = field(default_factory=list)
|
|
86
|
+
n_zones: int = 5
|
|
87
|
+
min_zone_area_km2: float = 1.0
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass
|
|
91
|
+
class WindConfig:
|
|
92
|
+
"""Master configuration for wind resource analysis.
|
|
93
|
+
|
|
94
|
+
Parameters
|
|
95
|
+
----------
|
|
96
|
+
bounds : tuple[float, float, float, float]
|
|
97
|
+
Geographic bounds (lat_min, lon_min, lat_max, lon_max).
|
|
98
|
+
turbine : TurbineSpec
|
|
99
|
+
Turbine specification.
|
|
100
|
+
hub_height_m : float
|
|
101
|
+
Analysis hub height in meters.
|
|
102
|
+
grid_resolution : float
|
|
103
|
+
Grid resolution in degrees.
|
|
104
|
+
data_source : str
|
|
105
|
+
Wind data source: 'open_meteo', 'nasa_power', 'era5'.
|
|
106
|
+
year : int
|
|
107
|
+
Analysis year.
|
|
108
|
+
mcda : MCDAConfig
|
|
109
|
+
MCDA configuration.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
bounds: tuple[float, float, float, float] = (0.0, 0.0, 1.0, 1.0)
|
|
113
|
+
turbine: TurbineSpec = field(default_factory=TurbineSpec)
|
|
114
|
+
hub_height_m: float = 80.0
|
|
115
|
+
grid_resolution: float = 0.25
|
|
116
|
+
data_source: str = "open_meteo"
|
|
117
|
+
year: int = 2023
|
|
118
|
+
mcda: MCDAConfig = field(default_factory=MCDAConfig)
|
windrex/core/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Core wind resource computation modules."""
|
|
2
|
+
|
|
3
|
+
from .weibull import fit_weibull, weibull_pdf, weibull_mean_power_density
|
|
4
|
+
from .wind_rose import compute_wind_rose, WindRoseData
|
|
5
|
+
from .shear import compute_wind_shear, extrapolate_speed
|
|
6
|
+
from .temporal import compute_diurnal_pattern, compute_seasonal_pattern
|
|
7
|
+
from .wake import jensen_wake_deficit, compute_array_efficiency, compute_spacing_curve
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"fit_weibull", "weibull_pdf", "weibull_mean_power_density",
|
|
11
|
+
"compute_wind_rose", "WindRoseData",
|
|
12
|
+
"compute_wind_shear", "extrapolate_speed",
|
|
13
|
+
"compute_diurnal_pattern", "compute_seasonal_pattern",
|
|
14
|
+
"jensen_wake_deficit", "compute_array_efficiency", "compute_spacing_curve",
|
|
15
|
+
]
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Wind hourly capacity factor computation.
|
|
2
|
+
|
|
3
|
+
Computes hourly CF time series from wind speed reanalysis data
|
|
4
|
+
using turbine power curves and hub-height wind speed extrapolation.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
_HOURS_PER_YEAR = 8760
|
|
17
|
+
_FEB29_START = 24 * (31 + 28)
|
|
18
|
+
_FEB29_END = _FEB29_START + 24
|
|
19
|
+
|
|
20
|
+
# Default turbine: Vestas V112 3.0 MW
|
|
21
|
+
_DEFAULT_WIND_SPEEDS = [
|
|
22
|
+
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15,
|
|
23
|
+
16, 17, 18, 19, 20, 21, 22, 23, 24, 25,
|
|
24
|
+
]
|
|
25
|
+
_DEFAULT_POWER_CURVE_MW = [
|
|
26
|
+
0, 0, 0, 0.032, 0.16, 0.36, 0.67, 1.08, 1.58, 2.12,
|
|
27
|
+
2.58, 2.85, 2.98, 3.0, 3.0, 3.0,
|
|
28
|
+
3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 0,
|
|
29
|
+
]
|
|
30
|
+
_DEFAULT_RATED_MW = 3.0
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def compute_wind_hourly_cf(
|
|
34
|
+
lat: float,
|
|
35
|
+
lon: float,
|
|
36
|
+
year: int,
|
|
37
|
+
data_source: str = "open_meteo",
|
|
38
|
+
wind_speeds: Optional[list[float]] = None,
|
|
39
|
+
power_curve: Optional[list[float]] = None,
|
|
40
|
+
rated_power_mw: float = _DEFAULT_RATED_MW,
|
|
41
|
+
hub_height: int = 80,
|
|
42
|
+
turbine_key: Optional[str] = None,
|
|
43
|
+
) -> np.ndarray:
|
|
44
|
+
"""Compute hourly wind capacity factor for a single location.
|
|
45
|
+
|
|
46
|
+
Parameters
|
|
47
|
+
----------
|
|
48
|
+
lat, lon : float
|
|
49
|
+
Geographic coordinates (WGS84).
|
|
50
|
+
year : int
|
|
51
|
+
Calendar year for weather data.
|
|
52
|
+
data_source : str
|
|
53
|
+
One of 'open_meteo', 'nasa_power', 'era5'.
|
|
54
|
+
wind_speeds : list[float] or None
|
|
55
|
+
Power curve wind speeds (m/s).
|
|
56
|
+
power_curve : list[float] or None
|
|
57
|
+
Power curve output (MW).
|
|
58
|
+
rated_power_mw : float
|
|
59
|
+
Turbine rated power (MW).
|
|
60
|
+
hub_height : int
|
|
61
|
+
Hub height in meters.
|
|
62
|
+
turbine_key : str or None
|
|
63
|
+
Turbine key to look up power curve from database.
|
|
64
|
+
|
|
65
|
+
Returns
|
|
66
|
+
-------
|
|
67
|
+
np.ndarray
|
|
68
|
+
Hourly capacity factors, shape (8760,), values in [0, 1].
|
|
69
|
+
"""
|
|
70
|
+
pc_ws, pc_mw, rated = _resolve_power_curve(
|
|
71
|
+
wind_speeds, power_curve, rated_power_mw, turbine_key,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
if data_source == "era5":
|
|
75
|
+
from windrex.data.era5 import compute_era5_wind_cf
|
|
76
|
+
return compute_era5_wind_cf(lat, lon, year, turbine_key, hub_height)
|
|
77
|
+
|
|
78
|
+
if data_source == "nasa_power":
|
|
79
|
+
from windrex.data.nasa_power import fetch_nasa_power_wind
|
|
80
|
+
ws_hourly = fetch_nasa_power_wind(lat, lon, year, hub_height)
|
|
81
|
+
else:
|
|
82
|
+
from windrex.data.open_meteo import fetch_open_meteo_wind
|
|
83
|
+
ws_hourly = fetch_open_meteo_wind(lat, lon, year, hub_height)
|
|
84
|
+
|
|
85
|
+
if ws_hourly is None:
|
|
86
|
+
logger.warning(
|
|
87
|
+
"Failed to fetch wind data for (%.4f, %.4f) year %d, returning zeros.",
|
|
88
|
+
lat, lon, year,
|
|
89
|
+
)
|
|
90
|
+
return np.zeros(_HOURS_PER_YEAR)
|
|
91
|
+
|
|
92
|
+
cf = _wind_speed_to_hourly_cf(ws_hourly, pc_ws, pc_mw, rated)
|
|
93
|
+
return normalize_to_8760(cf)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _wind_speed_to_hourly_cf(
|
|
97
|
+
ws_hourly: np.ndarray,
|
|
98
|
+
pc_wind_speeds: list[float],
|
|
99
|
+
pc_power_mw: list[float],
|
|
100
|
+
rated_mw: float,
|
|
101
|
+
) -> np.ndarray:
|
|
102
|
+
"""Convert hourly wind speeds to capacity factors using power curve."""
|
|
103
|
+
if not pc_wind_speeds or not pc_power_mw or rated_mw <= 0:
|
|
104
|
+
return np.zeros(len(ws_hourly))
|
|
105
|
+
|
|
106
|
+
ws = np.asarray(ws_hourly, dtype=float)
|
|
107
|
+
power_out = np.interp(ws, pc_wind_speeds, pc_power_mw, left=0.0, right=0.0)
|
|
108
|
+
cf = power_out / rated_mw
|
|
109
|
+
return np.clip(cf, 0.0, 1.0)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _resolve_power_curve(
|
|
113
|
+
wind_speeds: Optional[list[float]],
|
|
114
|
+
power_curve: Optional[list[float]],
|
|
115
|
+
rated_power_mw: float,
|
|
116
|
+
turbine_key: Optional[str],
|
|
117
|
+
) -> tuple[list[float], list[float], float]:
|
|
118
|
+
"""Resolve power curve from explicit values or turbine database."""
|
|
119
|
+
if wind_speeds and power_curve:
|
|
120
|
+
return wind_speeds, power_curve, rated_power_mw
|
|
121
|
+
|
|
122
|
+
if turbine_key:
|
|
123
|
+
from windrex.data.turbine_db import load_turbine_database
|
|
124
|
+
turbines = load_turbine_database()
|
|
125
|
+
for t in turbines:
|
|
126
|
+
if t.key == turbine_key:
|
|
127
|
+
return t.wind_speeds, t.power_curve, t.rated_power_mw
|
|
128
|
+
logger.warning("Turbine '%s' not found, using default.", turbine_key)
|
|
129
|
+
|
|
130
|
+
return _DEFAULT_WIND_SPEEDS, _DEFAULT_POWER_CURVE_MW, _DEFAULT_RATED_MW
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def normalize_to_8760(cf: np.ndarray) -> np.ndarray:
|
|
134
|
+
"""Ensure output has exactly 8760 hours (remove Feb 29 for leap years)."""
|
|
135
|
+
n = len(cf)
|
|
136
|
+
if n == _HOURS_PER_YEAR:
|
|
137
|
+
return cf
|
|
138
|
+
if n == 8784:
|
|
139
|
+
return np.concatenate([cf[:_FEB29_START], cf[_FEB29_END:]])
|
|
140
|
+
if n > _HOURS_PER_YEAR:
|
|
141
|
+
return cf[:_HOURS_PER_YEAR]
|
|
142
|
+
padded = np.zeros(_HOURS_PER_YEAR)
|
|
143
|
+
padded[:n] = cf
|
|
144
|
+
return padded
|
windrex/core/shear.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Wind shear (power-law) analysis.
|
|
2
|
+
|
|
3
|
+
Computes the wind shear exponent from dual-height measurements
|
|
4
|
+
and extrapolates wind speeds to arbitrary heights.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def compute_wind_shear(
|
|
13
|
+
speeds_low: np.ndarray,
|
|
14
|
+
speeds_high: np.ndarray,
|
|
15
|
+
h_low: float,
|
|
16
|
+
h_high: float,
|
|
17
|
+
) -> float:
|
|
18
|
+
"""Compute power-law wind shear exponent alpha.
|
|
19
|
+
|
|
20
|
+
v2/v1 = (h2/h1)^alpha → alpha = ln(v2/v1) / ln(h2/h1)
|
|
21
|
+
|
|
22
|
+
Parameters
|
|
23
|
+
----------
|
|
24
|
+
speeds_low : np.ndarray
|
|
25
|
+
Wind speeds at lower height (m/s).
|
|
26
|
+
speeds_high : np.ndarray
|
|
27
|
+
Wind speeds at upper height (m/s).
|
|
28
|
+
h_low : float
|
|
29
|
+
Lower measurement height (m).
|
|
30
|
+
h_high : float
|
|
31
|
+
Upper measurement height (m).
|
|
32
|
+
|
|
33
|
+
Returns
|
|
34
|
+
-------
|
|
35
|
+
float
|
|
36
|
+
Wind shear exponent alpha. Returns IEC default (0.143) if
|
|
37
|
+
insufficient valid data.
|
|
38
|
+
"""
|
|
39
|
+
speeds_low = np.asarray(speeds_low, dtype=float)
|
|
40
|
+
speeds_high = np.asarray(speeds_high, dtype=float)
|
|
41
|
+
|
|
42
|
+
mask = (speeds_low > 0.5) & (speeds_high > 0.5)
|
|
43
|
+
if np.sum(mask) < 5:
|
|
44
|
+
return 0.143 # IEC default (open terrain)
|
|
45
|
+
|
|
46
|
+
ratio = np.mean(speeds_high[mask]) / np.mean(speeds_low[mask])
|
|
47
|
+
if ratio <= 0:
|
|
48
|
+
return 0.143
|
|
49
|
+
|
|
50
|
+
alpha = np.log(ratio) / np.log(h_high / h_low)
|
|
51
|
+
return float(np.clip(alpha, 0.0, 0.6))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def extrapolate_speed(
|
|
55
|
+
speed_ref: float,
|
|
56
|
+
h_ref: float,
|
|
57
|
+
h_target: float,
|
|
58
|
+
alpha: float,
|
|
59
|
+
) -> float:
|
|
60
|
+
"""Extrapolate wind speed to a different height using power law.
|
|
61
|
+
|
|
62
|
+
v_target = v_ref * (h_target / h_ref) ^ alpha
|
|
63
|
+
|
|
64
|
+
Parameters
|
|
65
|
+
----------
|
|
66
|
+
speed_ref : float
|
|
67
|
+
Reference wind speed (m/s).
|
|
68
|
+
h_ref : float
|
|
69
|
+
Reference height (m).
|
|
70
|
+
h_target : float
|
|
71
|
+
Target height (m).
|
|
72
|
+
alpha : float
|
|
73
|
+
Wind shear exponent.
|
|
74
|
+
|
|
75
|
+
Returns
|
|
76
|
+
-------
|
|
77
|
+
float
|
|
78
|
+
Extrapolated wind speed (m/s).
|
|
79
|
+
"""
|
|
80
|
+
return speed_ref * (h_target / h_ref) ** alpha
|