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 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}'")
@@ -0,0 +1,9 @@
1
+ """Analysis modules for wind resource assessment."""
2
+
3
+ from .mcda import compute_mcda_scores, entropy_weights, pca_weights
4
+
5
+ __all__ = [
6
+ "compute_mcda_scores",
7
+ "entropy_weights",
8
+ "pca_weights",
9
+ ]
@@ -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)
@@ -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)
@@ -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