diff --git a/examples/cases/KUL_LES/wind_energy_system/analysis_US.yaml b/examples/cases/KUL_LES/wind_energy_system/analysis_US.yaml index dca8fcc..a6ca679 100644 --- a/examples/cases/KUL_LES/wind_energy_system/analysis_US.yaml +++ b/examples/cases/KUL_LES/wind_energy_system/analysis_US.yaml @@ -64,7 +64,7 @@ HPC_config: mesh_node_number: 2 mesh_ntasks_per_node: 48 mesh_wall_time_hours: 1 - run_partition: "" + mesh_partition: "" # wckey: "" diff --git a/pyproject.toml b/pyproject.toml index 8e34fbf..0120539 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,7 +38,7 @@ classifiers = [ ] requires-python = ">=3.10,<3.13" dependencies = [ - "windIO @ git+https://github.com/EUFlow/windIO.git", + "windIO @ git+https://github.com/bjarketol/windIO.git@flow-model-chain", "xarray", "scipy", "pyyaml", @@ -70,6 +70,7 @@ dev = [ "ncplot", "nctoolkit", "cartopy", + "pre-commit", ] docs = [ "sphinx>=7.0", diff --git a/tests/conftest.py b/tests/conftest.py index 0534492..509e133 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -149,6 +149,179 @@ def cleanup_test_outputs(): } +def make_mixed_type_timeseries_system_dict(flow_model_name): + """Build a system dict with TWO turbine types at TWO hub heights. + + 3x3 layout (9 turbines), per-turbine timeseries inflow with dims + ["time", "wind_turbine"] and NO vertical ``height`` profile (the wind + speeds are already at each turbine's own hub height, as a microscale + terrain-speedup model would produce). This exercises the mixed + hub-height + per-turbine path in the pyWake API. + + Type 0 is the DTU 10MW at 119 m; type 1 is a smaller turbine (half the + power, 120 m rotor, 90 m hub) so both the type assignment and the two + hub heights are genuinely distinct. Wind speeds are kept in 8-11 m/s, + away from cut-in/out, so the comparison is unaffected by curve edges. + """ + n_times = 5 + n_turbines = 9 + + # 3x3 grid, ~5D spacing of the larger rotor (5 * 178.3) + spacing = 5 * _TURBINE["rotor_diameter"] + axis = [0.0, spacing, 2 * spacing] + grid_x = [axis[i] for _ in range(3) for i in range(3)] + grid_y = [axis[j] for j in range(3) for _ in range(3)] + + # Alternating type assignment -> [0, 1, 0, 1, 0, 1, 0, 1, 0] + turbine_type_idx = [k % 2 for k in range(n_turbines)] + + perf = _TURBINE["performance"] + pc_ws = perf["power_curve"]["power_wind_speeds"] + pc_p = perf["power_curve"]["power_values"] + ct_ws = perf["Ct_curve"]["Ct_wind_speeds"] + ct_v = perf["Ct_curve"]["Ct_values"] + type1_power = [0.5 * p for p in pc_p] + + # Deterministic per-turbine timeseries (no RNG), wind from ~270 deg so the + # rows along x interact. + base_ws = [8.0, 9.0, 10.0, 8.5, 11.0] + base_wd = [270.0, 268.0, 272.0, 270.5, 269.0] + ws_data, wd_data, ti_data = [], [], [] + for t in range(n_times): + ws_data.append([base_ws[t] + 0.1 * i for i in range(n_turbines)]) + wd_data.append([base_wd[t] + 0.2 * (i - 4) for i in range(n_turbines)]) + ti_data.append([0.06 + 0.002 * i for i in range(n_turbines)]) + + return { + "name": "Dict test: mixed turbine types and hub heights", + "site": { + "name": "Test site", + "boundaries": { + "polygons": [ + { + "x": [-spacing, 3 * spacing, 3 * spacing, -spacing], + "y": [3 * spacing, 3 * spacing, -spacing, -spacing], + } + ] + }, + "energy_resource": { + "name": "Test resource", + "wind_resource": { + "time": list(range(n_times)), + "wind_turbine": list(range(n_turbines)), + "wind_speed": {"data": ws_data, "dims": ["time", "wind_turbine"]}, + "wind_direction": { + "data": wd_data, + "dims": ["time", "wind_turbine"], + }, + "turbulence_intensity": { + "data": ti_data, + "dims": ["time", "wind_turbine"], + }, + }, + }, + }, + "wind_farm": { + "name": "Test farm", + "layouts": [ + { + "coordinates": {"x": grid_x, "y": grid_y}, + "turbine_types": turbine_type_idx, + } + ], + "turbine_types": { + 0: { + "name": "type_0_DTU10MW", + "hub_height": 119.0, + "rotor_diameter": 178.3, + "performance": { + "power_curve": { + "power_wind_speeds": pc_ws, + "power_values": pc_p, + }, + "Ct_curve": {"Ct_wind_speeds": ct_ws, "Ct_values": ct_v}, + }, + }, + 1: { + "name": "type_1_small", + "hub_height": 90.0, + "rotor_diameter": 120.0, + "performance": { + "power_curve": { + "power_wind_speeds": pc_ws, + "power_values": type1_power, + }, + "Ct_curve": {"Ct_wind_speeds": ct_ws, "Ct_values": ct_v}, + }, + }, + }, + }, + "attributes": { + "flow_model": {"name": flow_model_name}, + "analysis": { + "wind_deficit_model": { + "name": "Bastankhah2014", + "wake_expansion_coefficient": { + "k_a": 0.0, + "k_b": 0.04, + "free_stream_ti": False, + }, + "ceps": 0.2, + "use_effective_ws": True, + }, + "axial_induction_model": "Madsen", + "deflection_model": {"name": "None"}, + "turbulence_model": {"name": "CrespoHernandez"}, + "superposition_model": { + "ws_superposition": "Linear", + "ti_superposition": "Squared", + }, + "rotor_averaging": {"name": "Center"}, + "blockage_model": {"name": "None"}, + }, + "model_outputs_specification": { + "turbine_outputs": { + "turbine_nc_filename": "turbine_data.nc", + "output_variables": ["power"], + }, + }, + }, + } + + +def make_mixed_type_profile_system_dict(flow_model_name): + """Mixed types/hub heights with a (time, height) vertical wind profile. + + Same farm as :func:`make_mixed_type_timeseries_system_dict` (two types at + 119 m and 90 m), but the inflow is given as a vertical profile over a + ``height`` dimension that brackets both hub heights, so WIFA must + interpolate the profile to each turbine's hub height. The wind direction + veers around 360 deg to exercise circular interpolation. + """ + system = make_mixed_type_timeseries_system_dict(flow_model_name) + + n_times = 5 + heights = [60.0, 90.0, 120.0, 150.0] + href, alpha = 119.0, 0.14 # power-law shear reference + exponent + ws_base = [8.0, 9.0, 10.0, 8.5, 11.0] + wd_base = [358.0, 359.0, 357.0, 0.5, 359.5] # straddles 360 deg + + ws_data, wd_data, ti_data = [], [], [] + for t in range(n_times): + ws_data.append([ws_base[t] * (h / href) ** alpha for h in heights]) + wd_data.append([(wd_base[t] + 0.05 * (h - href)) % 360 for h in heights]) + ti_data.append([max(0.08 - 0.0001 * (h - 60.0), 0.02) for h in heights]) + + system["site"]["energy_resource"]["wind_resource"] = { + "time": list(range(n_times)), + "height": heights, + "wind_speed": {"data": ws_data, "dims": ["time", "height"]}, + "wind_direction": {"data": wd_data, "dims": ["time", "height"]}, + "turbulence_intensity": {"data": ti_data, "dims": ["time", "height"]}, + } + return system + + def make_timeseries_per_turbine_system_dict(flow_model_name): """Build a complete system dict with per-turbine timeseries data including density. diff --git a/tests/mem_bench.py b/tests/mem_bench.py new file mode 100644 index 0000000..8e10da2 --- /dev/null +++ b/tests/mem_bench.py @@ -0,0 +1,266 @@ +"""Memory benchmark for WIFA site-data construction. + +Measures the peak Python allocation of turning the windIO ``wind_resource`` +dict into a pyWake site, isolating the copies this optimization targets: + * the dict-of-lists footprint (what windIO's _ds2yml currently produces) + * the extra peak allocated inside ``construct_site`` (np.array / [cases_idx] + / dict_to_netcdf copies) + * the Weibull path's duplicate ``dict_to_netcdf`` build + +Run: pixi run python tests/mem_bench.py +""" + +import gc +import tempfile +import tracemalloc +from pathlib import Path + +import numpy as np +import xarray as xr + +from wifa.pywake_api import construct_site, create_turbines + +_TURBINE = { + "name": "generic", + "hub_height": 100.0, + "rotor_diameter": 120.0, + "performance": { + "power_curve": { + "power_wind_speeds": [3.0, 8.0, 12.0, 25.0], + "power_values": [0.0, 1.0e6, 3.0e6, 3.0e6], + }, + "Ct_curve": { + "Ct_wind_speeds": [3.0, 8.0, 12.0, 25.0], + "Ct_values": [0.8, 0.8, 0.4, 0.2], + }, + }, +} + +_ANALYSIS = { + "wind_deficit_model": { + "name": "Jensen", + "wake_expansion_coefficient": {"k_a": 0.0, "k_b": 0.04}, + }, + "deflection_model": {"name": "None"}, + "turbulence_model": {"name": "STF2005", "c1": 1.0, "c2": 1.0}, + "superposition_model": { + "ws_superposition": "Linear", + "ti_superposition": "Squared", + }, + "rotor_averaging": {"name": "Center"}, + "blockage_model": {"name": "None"}, +} + + +def _attrs(): + return { + "flow_model": {"name": "pywake"}, + "analysis": _ANALYSIS, + "model_outputs_specification": { + "turbine_outputs": { + "turbine_nc_filename": "turbine_data.nc", + "output_variables": ["power"], + } + }, + } + + +def _layout(n_turbines, spacing=600.0): + return { + "coordinates": { + "x": [i * spacing for i in range(n_turbines)], + "y": [0.0] * n_turbines, + } + } + + +def _bounds(n_turbines, spacing=600.0): + hi = n_turbines * spacing + 100 + return {"polygons": [{"x": [-100, hi, hi, -100], "y": [100, 100, -100, -100]}]} + + +def timeseries_system(n_times, n_turbines, arrays=False): + """Per-turbine time-series system. + + ``arrays=False`` emits a dict-of-Python-lists (what windIO's _ds2yml + produces today). ``arrays=True`` emits a dict-of-ndarrays (what the + Phase 2 ``to_dict(data="array")`` loader would produce) to preview the win. + """ + rng = np.random.default_rng(0) + _c = (lambda a: a) if arrays else (lambda a: a.tolist()) + ws = _c(8.0 + 4.0 * rng.random((n_times, n_turbines))) + wd = _c(270.0 + 10.0 * rng.random((n_times, n_turbines))) + ti = _c(0.06 + 0.04 * rng.random((n_times, n_turbines))) + rho = _c(1.18 + 0.06 * rng.random((n_times, n_turbines))) + op = _c(np.ones((n_times, n_turbines), dtype=int)) + dims = ["time", "wind_turbine"] + return { + "name": "bench timeseries", + "site": { + "name": "s", + "boundaries": _bounds(n_turbines), + "energy_resource": { + "name": "r", + "wind_resource": { + "time": list(range(n_times)), + "wind_turbine": list(range(n_turbines)), + "wind_speed": {"data": ws, "dims": dims}, + "wind_direction": {"data": wd, "dims": dims}, + "turbulence_intensity": {"data": ti, "dims": dims}, + "density": {"data": rho, "dims": dims}, + "operating": {"data": op, "dims": dims}, + }, + }, + }, + "wind_farm": { + "name": "f", + "layouts": [_layout(n_turbines)], + "turbines": _TURBINE, + }, + "attributes": _attrs(), + } + + +def weibull_system(n_dirs, n_turbines): + """Per-turbine Weibull system as a dict-of-lists (exercises the Weibull path).""" + rng = np.random.default_rng(1) + a = (8.0 + 2.0 * rng.random((n_turbines, n_dirs))).tolist() + k = (2.0 + 0.3 * rng.random((n_turbines, n_dirs))).tolist() + p = rng.random((n_turbines, n_dirs)) + p = (p / p.sum(axis=1, keepdims=True)).tolist() + wd = np.linspace(0, 360, n_dirs, endpoint=False).tolist() + dims = ["wind_turbine", "wind_direction"] + return { + "name": "bench weibull", + "site": { + "name": "s", + "boundaries": _bounds(n_turbines), + "energy_resource": { + "name": "r", + "wind_resource": { + "wind_direction": wd, + "wind_turbine": list(range(n_turbines)), + "weibull_a": {"data": a, "dims": dims}, + "weibull_k": {"data": k, "dims": dims}, + "sector_probability": {"data": p, "dims": dims}, + "turbulence_intensity": { + "data": (0.07 * np.ones((n_turbines, n_dirs))).tolist(), + "dims": dims, + }, + }, + }, + }, + "wind_farm": { + "name": "f", + "layouts": [_layout(n_turbines)], + "turbines": _TURBINE, + }, + "attributes": _attrs(), + } + + +def _peak_of(fn): + tracemalloc.start() + tracemalloc.clear_traces() + obj = fn() + cur, peak = tracemalloc.get_traced_memory() + tracemalloc.stop() + return obj, cur, peak + + +def _construct_peak(system): + farm = system["wind_farm"] + _, _, hub_heights = create_turbines(farm) + x = farm["layouts"][0]["coordinates"]["x"] + resource = system["site"]["energy_resource"] + tracemalloc.start() + tracemalloc.reset_peak() + base, _ = tracemalloc.get_traced_memory() + construct_site(system, resource, hub_heights, x) + _, peak = tracemalloc.get_traced_memory() + tracemalloc.stop() + return peak - base + + +def nc_load_peaks(n_times, n_turbines): + """Peak memory of windIO.load_yaml on a real wind_resource.nc, list vs array. + + Requires a windIO build with the ``nc_data`` loader option; returns None if + unavailable so the benchmark still runs the in-process cases. + """ + import windIO + + d = Path(tempfile.mkdtemp()) + rng = np.random.default_rng(0) + ds = xr.Dataset( + { + v: (("time", "wind_turbine"), rng.random((n_times, n_turbines))) + for v in ("wind_speed", "wind_direction", "turbulence_intensity", "density") + }, + coords={"time": np.arange(n_times), "wind_turbine": np.arange(n_turbines)}, + ) + ds["operating"] = ( + ("time", "wind_turbine"), + np.ones((n_times, n_turbines), dtype=int), + ) + ds.to_netcdf(d / "wind_resource.nc") + sysf = d / "system.yaml" + sysf.write_text("wind_resource: !include wind_resource.nc\n") + + def peak(mode): + gc.collect() + tracemalloc.start() + tracemalloc.clear_traces() + obj = windIO.load_yaml(sysf, nc_data=mode) + _, pk = tracemalloc.get_traced_memory() + tracemalloc.stop() + del obj + gc.collect() + return pk + + try: + return peak("list"), peak("array") + except TypeError: + return None # windIO without the nc_data option + + +def main(): + MB = 1e6 + print(f"{'case':<28}{'input dict':>14}{'construct_site':>18}") + print("-" * 60) + + n_times, n_wt = 4000, 100 + nbytes = n_times * n_wt * 8 * 5 / MB # 5 float arrays + sysd, _, dpeak = _peak_of(lambda: timeseries_system(n_times, n_wt)) + cs = _construct_peak(sysd) + print( + f"{'timeseries 4000x100 (lists)':<28}{dpeak / MB:>11.1f}MB{cs / MB:>15.1f}MB" + f" (raw ndarray ~{nbytes:.1f}MB)" + ) + + sysa, _, apeak = _peak_of(lambda: timeseries_system(n_times, n_wt, arrays=True)) + csa = _construct_peak(sysa) + print( + f"{'timeseries 4000x100 (arrays)':<28}{apeak / MB:>11.1f}MB{csa / MB:>15.1f}MB" + f" <- Phase 2 preview" + ) + + n_dirs, n_wt_w = 12, 2000 + sysw, _, wpeak = _peak_of(lambda: weibull_system(n_dirs, n_wt_w)) + csw = _construct_peak(sysw) + print(f"{'weibull 2000wt x 12dir':<28}{wpeak / MB:>11.1f}MB{csw / MB:>15.1f}MB") + + print() + peaks = nc_load_peaks(n_times, n_wt) + if peaks is None: + print("load_yaml(.nc): windIO has no nc_data option (skipped)") + else: + pl, pa = peaks + print( + f"load_yaml(.nc) 4000x100 list={pl / MB:.1f}MB " + f"array={pa / MB:.1f}MB ({pl / pa:.1f}x lower peak)" + ) + + +if __name__ == "__main__": + main() diff --git a/tests/test_floris.py b/tests/test_floris.py index f95b706..f2477fc 100644 --- a/tests/test_floris.py +++ b/tests/test_floris.py @@ -9,6 +9,7 @@ import os import shutil +import sys from pathlib import Path import numpy as np diff --git a/tests/test_memory.py b/tests/test_memory.py new file mode 100644 index 0000000..e455f61 --- /dev/null +++ b/tests/test_memory.py @@ -0,0 +1,71 @@ +"""Regression guard for site-data load memory. + +Keeping an included ``wind_resource.nc`` as numpy arrays (windIO's +``nc_data="array"``) must stay dramatically cheaper than the dict-of-lists +default for a large resource. See tests/mem_bench.py for the full breakdown. +""" + +import gc +import tracemalloc + +import numpy as np +import pytest +import windIO +import xarray as xr + +pytest.importorskip("netCDF4") + + +def _write_resource(tmp_path, n_times, n_turbines): + rng = np.random.default_rng(0) + ds = xr.Dataset( + { + v: (("time", "wind_turbine"), rng.random((n_times, n_turbines))) + for v in ("wind_speed", "wind_direction", "turbulence_intensity", "density") + }, + coords={"time": np.arange(n_times), "wind_turbine": np.arange(n_turbines)}, + ) + ds["operating"] = ( + ("time", "wind_turbine"), + np.ones((n_times, n_turbines), dtype=int), + ) + ds.to_netcdf(tmp_path / "wind_resource.nc") + sysf = tmp_path / "system.yaml" + sysf.write_text("wind_resource: !include wind_resource.nc\n") + return sysf + + +def _peak_load(sysf, mode): + gc.collect() + tracemalloc.start() + tracemalloc.clear_traces() + obj = windIO.load_yaml(sysf, nc_data=mode) + _, peak = tracemalloc.get_traced_memory() + tracemalloc.stop() + del obj + gc.collect() + return peak + + +def test_array_loader_reduces_peak_memory(tmp_path): + """Array-backed load must peak well below the dict-of-lists load.""" + sysf = _write_resource(tmp_path, 2000, 60) + + list_peak = _peak_load(sysf, "list") + array_peak = _peak_load(sysf, "array") + + # Measured ~4x; require a comfortable >=2x to avoid flakiness. + assert array_peak < 0.5 * list_peak, ( + f"array load peak {array_peak / 1e6:.1f}MB not < 0.5 x list " + f"{list_peak / 1e6:.1f}MB" + ) + + +def test_array_loader_keeps_ndarrays(tmp_path): + """The array loader must actually keep numpy arrays (not lists).""" + sysf = _write_resource(tmp_path, 100, 4) + wr = windIO.load_yaml(sysf, nc_data="array")["wind_resource"] + assert isinstance(wr["wind_speed"]["data"], np.ndarray) + # default stays list-backed (backwards compatible) + wr_list = windIO.load_yaml(sysf)["wind_resource"] + assert isinstance(wr_list["wind_speed"]["data"], list) diff --git a/tests/test_pywake.py b/tests/test_pywake.py index 2605cf5..7e8f349 100644 --- a/tests/test_pywake.py +++ b/tests/test_pywake.py @@ -18,7 +18,7 @@ from py_wake.superposition_models import LinearSum from py_wake.tests import npt from py_wake.turbulence_models import CrespoHernandez -from py_wake.wind_turbines import WindTurbine +from py_wake.wind_turbines import WindTurbine, WindTurbines from py_wake.wind_turbines.power_ct_functions import PowerCtFunctionList, PowerCtTabular from scipy.special import gamma from windIO import __path__ as wiop @@ -274,13 +274,32 @@ def test_heterogeneous_wind_rose_grid(): x = [0, 1248.1, 2496.2, 3744.3] y = [0, 0, 0, 0] - # compute AEP with PyWake - res_aep = ( - wfm(x, y, ws=np.arange(2, 30, 1), wd=dat["wd"]) - .aep(normalize_probabilities=True) - .sum() + # Compute Speedup-adjusted ws range (same logic as WIFA) + A_vals = dat["Weibull_A"].values + k_vals = dat["Weibull_k"].values + ws_999 = A_vals * (-np.log(0.001)) ** (1.0 / k_vals) + min_su = np.min(speedup) + ws_max_ref = np.max(ws_999) / max(min_su, 0.1) + # ws grid starts at 0.5 (not 0): WIFA drops the degenerate ws=0 reference + # case, which breaks the WeightedSum superposition (see + # test_weibull_ws_grid_excludes_zero_for_weightedsum). + ws_range = np.arange(0.5, np.ceil(ws_max_ref) + 0.5, 0.5) + + # Compute sub-sector wd (same logic as WIFA) + wd_sectors = dat["wd"].values + if len(wd_sectors) > 1 and np.isclose(wd_sectors[-1], 360.0): + wd_sectors = wd_sectors[:-1] + n_sub = 5 + sw = 360.0 / len(wd_sectors) + ssw = sw / n_sub + offsets = np.linspace(-sw / 2 + ssw / 2, sw / 2 - ssw / 2, n_sub) + wd_fine = np.sort( + (wd_sectors[:, np.newaxis] + offsets[np.newaxis, :]).ravel() % 360 ) + # compute AEP with PyWake + res_aep = wfm(x, y, ws=ws_range, wd=wd_fine).aep(normalize_probabilities=True).sum() + # compute AEP with API wifa_res = run_pywake( test_path @@ -447,6 +466,225 @@ def test_turbine_specific_speeds_timeseries(): npt.assert_allclose(wifa_res, manual_aep, rtol=1e-6) +def test_pywake_mixed_turbine_types_hub_heights(tmp_path): + """Two turbine types at two hub heights with per-turbine timeseries inflow. + + WIFA must reproduce a hand-built pyWake simulation. This is the golden + equivalence check for the mixed-type / mixed-hub-height path: the API + builds the per-turbine XRSite (via dict_to_site) inside the + ``len(hub_heights) > 1`` branch and assigns turbine types, and the + resulting per-turbine power time series must match raw pyWake. + """ + from conftest import make_mixed_type_timeseries_system_dict + + system_dict = make_mixed_type_timeseries_system_dict("pywake") + output_dir = tmp_path / "output_pywake_mixed" + aep_wifa = run_pywake(system_dict, output_dir=str(output_dir)) + + # --- Build the reference pyWake simulation directly --- + wr = system_dict["site"]["energy_resource"]["wind_resource"] + farm = system_dict["wind_farm"] + coords = farm["layouts"][0]["coordinates"] + x, y = coords["x"], coords["y"] + type_list = farm["layouts"][0]["turbine_types"] + + ws = np.array(wr["wind_speed"]["data"]) # (time, turbine) + wd = np.array(wr["wind_direction"]["data"]) + ti = np.array(wr["turbulence_intensity"]["data"]) + n_time, n_wt = ws.shape + + # Per-turbine site (mirrors dict_to_site: wind_turbine -> i, i leading dim, + # uniform P, integer time). + ds = xr.Dataset( + { + "WS": (("time", "i"), ws), + "WD": (("time", "i"), wd), + "TI": (("time", "i"), ti), + }, + coords={"time": np.arange(n_time), "i": np.arange(n_wt)}, + ).transpose("i", "time") + ds["P"] = (("time",), np.ones(n_time) / n_time) + site = XRSite(ds, interp_method="linear") + + # Two turbine types matching the windIO definitions (WIFA interpolates the + # curves onto integer wind speeds; the nodes here are already integer). + tdefs = farm["turbine_types"] + turbines = [] + for k in sorted(tdefs): + td = tdefs[k] + pc = td["performance"]["power_curve"] + ctc = td["performance"]["Ct_curve"] + speeds = np.arange( + min(pc["power_wind_speeds"][0], ctc["Ct_wind_speeds"][0]), + max(pc["power_wind_speeds"][-1], ctc["Ct_wind_speeds"][-1]) + 1, + 1, + ) + powers = np.interp(speeds, pc["power_wind_speeds"], pc["power_values"]) + cts = np.interp(speeds, ctc["Ct_wind_speeds"], ctc["Ct_values"]) + turbines.append( + WindTurbine( + name=td["name"], + diameter=td["rotor_diameter"], + hub_height=td["hub_height"], + powerCtFunction=PowerCtTabular(speeds, powers, "W", ct=cts), + ) + ) + turbine = WindTurbines.from_WindTurbine_lst(turbines) + + wfm = BastankhahGaussian( + site, + turbine, + k=0.04, + ceps=0.2, + superpositionModel=LinearSum(), + use_effective_ws=True, + turbulenceModel=CrespoHernandez(), + ) + + # Reference inflow arrays, exactly as the API reduces them (mean over + # turbines, vector mean for direction). + ws_ref = ds.WS.mean(dim="i").values + rads = np.deg2rad(ds.WD) + wd_ref = ( + np.rad2deg(np.arctan2(np.sin(rads).mean("i"), np.cos(rads).mean("i"))) % 360 + ).values + + res = wfm(x, y, type=type_list, time=True, ws=ws_ref, wd=wd_ref) + ref_power = res.Power.transpose("wt", "time").values + ref_aep = res.aep(normalize_probabilities=False).sum() + + # --- Compare WIFA output (per-turbine power) to the reference --- + with xr.open_dataset(output_dir / "turbine_data.nc") as out: + wifa_power = out["power"].transpose("turbine", "time").values + + npt.assert_allclose(wifa_power, ref_power, rtol=1e-6, atol=1.0) + npt.assert_allclose(aep_wifa, ref_aep, rtol=1e-6) + + # The case really does exercise two distinct hub heights. + assert {td["hub_height"] for td in tdefs.values()} == {119.0, 90.0} + + +def test_pywake_mixed_types_vertical_profile(tmp_path): + """Two turbine types at two hub heights driven by a (time, height) vertical + profile. WIFA interpolates the profile to each turbine's hub height and must + match a hand-built per-turbine pyWake reference (per-turbine power).""" + from conftest import make_mixed_type_profile_system_dict + + system_dict = make_mixed_type_profile_system_dict("pywake") + output_dir = tmp_path / "output_pywake_profile" + aep_wifa = run_pywake(system_dict, output_dir=str(output_dir)) + + wr = system_dict["site"]["energy_resource"]["wind_resource"] + farm = system_dict["wind_farm"] + coords = farm["layouts"][0]["coordinates"] + x, y = coords["x"], coords["y"] + type_list = farm["layouts"][0]["turbine_types"] + + heights = np.array(wr["height"], dtype=float) + ws_prof = np.array(wr["wind_speed"]["data"]) # (time, height) + wd_prof = np.array(wr["wind_direction"]["data"]) + ti_prof = np.array(wr["turbulence_intensity"]["data"]) + n_time = ws_prof.shape[0] + + tdefs = farm["turbine_types"] + ordered_keys = sorted(tdefs) + ordered_hh = [tdefs[k]["hub_height"] for k in ordered_keys] + + # Interpolate the profile to each turbine's hub height: linear WS/TI, + # vector (sin/cos) WD — mirroring the WIFA helpers. + def _lin(prof, hub): + return np.array([np.interp(hub, heights, prof[t]) for t in range(n_time)]) + + def _dir(prof, hub): + rad = np.deg2rad(prof) + s = np.array([np.interp(hub, heights, np.sin(rad)[t]) for t in range(n_time)]) + c = np.array([np.interp(hub, heights, np.cos(rad)[t]) for t in range(n_time)]) + return np.mod(np.rad2deg(np.arctan2(s, c)), 360.0) + + n_wt = len(type_list) + hub_of = [ordered_hh[type_list[i]] for i in range(n_wt)] + ws_i = np.array([_lin(ws_prof, hub_of[i]) for i in range(n_wt)]) + wd_i = np.array([_dir(wd_prof, hub_of[i]) for i in range(n_wt)]) + ti_i = np.maximum(np.array([_lin(ti_prof, hub_of[i]) for i in range(n_wt)]), 0.02) + + ds = xr.Dataset( + { + "WS": (("i", "time"), ws_i), + "WD": (("i", "time"), wd_i), + "TI": (("i", "time"), ti_i), + }, + coords={"i": np.arange(n_wt), "time": np.arange(n_time)}, + ) + ds["P"] = (("time",), np.ones(n_time) / n_time) + site = XRSite(ds, interp_method="linear") + + turbines = [] + for k in ordered_keys: + td = tdefs[k] + pc = td["performance"]["power_curve"] + ctc = td["performance"]["Ct_curve"] + speeds = np.arange( + min(pc["power_wind_speeds"][0], ctc["Ct_wind_speeds"][0]), + max(pc["power_wind_speeds"][-1], ctc["Ct_wind_speeds"][-1]) + 1, + 1, + ) + powers = np.interp(speeds, pc["power_wind_speeds"], pc["power_values"]) + cts = np.interp(speeds, ctc["Ct_wind_speeds"], ctc["Ct_values"]) + turbines.append( + WindTurbine( + name=td["name"], + diameter=td["rotor_diameter"], + hub_height=td["hub_height"], + powerCtFunction=PowerCtTabular(speeds, powers, "W", ct=cts), + ) + ) + turbine = WindTurbines.from_WindTurbine_lst(turbines) + + wfm = BastankhahGaussian( + site, + turbine, + k=0.04, + ceps=0.2, + superpositionModel=LinearSum(), + use_effective_ws=True, + turbulenceModel=CrespoHernandez(), + ) + ws_ref = ds.WS.mean("i").values + rads = np.deg2rad(ds.WD) + wd_ref = ( + np.rad2deg(np.arctan2(np.sin(rads).mean("i"), np.cos(rads).mean("i"))) % 360 + ).values + + res = wfm(x, y, type=type_list, time=True, ws=ws_ref, wd=wd_ref) + ref_power = res.Power.transpose("wt", "time").values + ref_aep = res.aep(normalize_probabilities=False).sum() + + with xr.open_dataset(output_dir / "turbine_data.nc") as out: + wifa_power = out["power"].transpose("turbine", "time").values + + npt.assert_allclose(wifa_power, ref_power, rtol=1e-6, atol=1.0) + npt.assert_allclose(aep_wifa, ref_aep, rtol=1e-6) + + # Two distinct hub heights, at least one strictly between profile levels + # (so the interpolation is genuinely exercised, not just node selection). + assert len(set(ordered_hh)) == 2 + assert any(h not in set(heights.tolist()) for h in ordered_hh) + + +def test_pywake_mixed_height_and_per_turbine_raises(tmp_path): + """A resource carrying BOTH a height profile and a wind_turbine dimension is + ambiguous and must fail loudly rather than guess.""" + from conftest import make_mixed_type_timeseries_system_dict + + system_dict = make_mixed_type_timeseries_system_dict("pywake") + # The per-turbine resource already has wind_turbine-dimensioned ws/wd; + # add a height axis alongside it to create the ambiguous combination. + system_dict["site"]["energy_resource"]["wind_resource"]["height"] = [80.0, 120.0] + + with pytest.raises(NotImplementedError, match="both a 'height' profile"): + run_pywake(system_dict, output_dir=str(tmp_path / "output_pywake_ambig")) + + def test_pywake_dict_timeseries_per_turbine_with_density(tmp_path): from conftest import make_timeseries_per_turbine_system_dict @@ -466,6 +704,231 @@ def test_pywake_dict_timeseries_per_turbine_with_density(tmp_path): assert aep_with != aep_without +def test_weibull_speedup_dim_ordering(tmp_path): + """Regression test: per-turbine Weibull Speedup with both dim orderings. + + flow_model_chain (via windkit) writes wind_resource.nc with dims + (wind_direction, wind_turbine), while WIFA's own test fixtures use + (wind_turbine, wind_direction). A bug in _construct_weibull_site() + previously hardcoded axis=0 for the Speedup normalisation, which only + worked for the turbine-first ordering. With direction-first data the + Speedup dims were silently swapped and PyWake ignored the variable, + removing all terrain-induced wind speed inhomogeneity from the wake + simulation and inflating wake losses from ~10 % to ~39 %. + + This test runs the same per-turbine Weibull case with BOTH dim + orderings and asserts identical AEP. + """ + from conftest import _ANALYSIS, _TURBINE + + n_wd = 4 + n_wt = 4 + wd_vals = [0.0, 90.0, 180.0, 270.0] + ws_vals = list(np.arange(4.0, 26.0, 1.0).tolist()) + + # Per-turbine, per-sector Weibull A — turbine 3 is windiest + # Shape: (wind_direction, wind_turbine) = (4, 4) + A_data = [ + [7.0, 8.0, 9.0, 10.0], # sector 0° + [6.5, 7.5, 8.5, 9.5], # sector 90° + [8.0, 9.0, 10.0, 11.0], # sector 180° + [6.0, 7.0, 8.0, 9.0], # sector 270° + ] + k_data = [[2.0] * n_wt] * n_wd + freq_data = [[1.0 / n_wd] * n_wt] * n_wd + ti_data = [[0.06] * n_wt] * n_wd + + common_site = { + "name": "Test site", + "boundaries": { + "polygons": [{"x": [-90, 5000, 5000, -90], "y": [90, 90, -90, -90]}] + }, + } + common_farm = { + "name": "Test farm", + "layouts": [ + {"coordinates": {"x": [0, 1248.1, 2496.2, 3744.3], "y": [0, 0, 0, 0]}} + ], + "turbines": _TURBINE, + } + common_attrs = { + "flow_model": {"name": "pywake"}, + "analysis": _ANALYSIS, + "model_outputs_specification": { + "turbine_outputs": { + "turbine_nc_filename": "PowerTable.nc", + "output_variables": ["power"], + }, + }, + } + + def _make_system(data, dims, name): + return { + "name": name, + "site": { + **common_site, + "energy_resource": { + "name": "Test resource", + "wind_resource": { + "wind_direction": wd_vals, + "wind_speed": ws_vals, + "wind_turbine": list(range(n_wt)), + "reference_height": 119.0, + "weibull_a": {"data": data["A"], "dims": dims}, + "weibull_k": {"data": data["k"], "dims": dims}, + "sector_probability": {"data": data["f"], "dims": dims}, + "turbulence_intensity": {"data": data["ti"], "dims": dims}, + }, + }, + }, + "wind_farm": common_farm, + "attributes": common_attrs, + } + + # --- 1. Direction-first ordering (flow_model_chain convention) -------- + wd_first = _make_system( + {"A": A_data, "k": k_data, "f": freq_data, "ti": ti_data}, + ["wind_direction", "wind_turbine"], + "Direction-first", + ) + aep_wd_first = run_pywake(wd_first, output_dir=str(tmp_path / "wd_first")) + assert np.isfinite(aep_wd_first) and aep_wd_first > 0 + + # --- 2. Turbine-first ordering (WIFA test-fixture convention) --------- + A_T = np.array(A_data).T.tolist() + k_T = np.array(k_data).T.tolist() + freq_T = np.array(freq_data).T.tolist() + ti_T = np.array(ti_data).T.tolist() + + wt_first = _make_system( + {"A": A_T, "k": k_T, "f": freq_T, "ti": ti_T}, + ["wind_turbine", "wind_direction"], + "Turbine-first", + ) + aep_wt_first = run_pywake(wt_first, output_dir=str(tmp_path / "wt_first")) + + # Both orderings must produce identical AEP + npt.assert_allclose(aep_wd_first, aep_wt_first, rtol=1e-6) + + +def test_weibull_ws_grid_excludes_zero_for_weightedsum(tmp_path): + """Regression: the auto-generated Weibull ws grid must exclude ws=0. + + When no explicit ``wind_speed`` is given, ``_construct_weibull_site`` + builds a reference ws grid. It used to start at 0 m/s. A ws=0 flow case + carries zero energy for every model, but it is degenerate for the + ``WeightedSum`` superposition (Zong 2020), whose convection-velocity + iteration divides by the convection speed and is undefined at zero wind + speed. Including ws=0 silently corrupted the WeightedSum AEP — collapsing + the apparent wake loss (e.g. Zong fell to ~3.5 % while the near-identical + LinearSum Niayifar stayed at ~8 %). LinearSum and the other superpositions + were unaffected, so the bug only surfaced on the distributions path with + Weighted superposition. + + Guards both the grid (ws[0] > 0) and the behaviour (Weighted must not + diverge from Linear for an otherwise-identical Zong farm). + """ + from conftest import _TURBINE + from wifa.pywake_api import ( + construct_site, + create_turbines, + ) + + n_wd, n_wt = 4, 5 + wd_vals = [0.0, 90.0, 180.0, 270.0] + A = [[9.0] * n_wt for _ in range(n_wd)] + k = [[2.0] * n_wt for _ in range(n_wd)] + freq = [[1.0 / n_wd] * n_wt for _ in range(n_wd)] + ti = [[0.06] * n_wt for _ in range(n_wd)] + + def make_system(superposition, rotor): + return { + "name": "ws0-regression", + "site": { + "name": "Test site", + "boundaries": { + "polygons": [ + {"x": [-90, 5000, 5000, -90], "y": [90, 90, -90, -90]} + ] + }, + "energy_resource": { + "name": "Test resource", + "wind_resource": { + # NOTE: deliberately NO "wind_speed" -> auto ws grid + "wind_direction": wd_vals, + "wind_turbine": list(range(n_wt)), + "reference_height": 119.0, + "weibull_a": { + "data": A, + "dims": ["wind_direction", "wind_turbine"], + }, + "weibull_k": { + "data": k, + "dims": ["wind_direction", "wind_turbine"], + }, + "sector_probability": { + "data": freq, + "dims": ["wind_direction", "wind_turbine"], + }, + "turbulence_intensity": { + "data": ti, + "dims": ["wind_direction", "wind_turbine"], + }, + }, + }, + }, + "wind_farm": { + "name": "Test farm", + "layouts": [ + { + "coordinates": { + # 5-turbine row at 5D spacing -> strong aligned wakes + "x": [i * 5 * _TURBINE["rotor_diameter"] for i in range(5)], + "y": [0.0] * 5, + } + } + ], + "turbines": _TURBINE, + }, + "attributes": { + "flow_model": {"name": "pywake"}, + "analysis": { + "wind_deficit_model": { + "name": "Zong2020", + "wake_expansion_coefficient": {"k_a": 0.38, "k_b": 0.004}, + }, + "deflection_model": {"name": "None"}, + "turbulence_model": {"name": "CrespoHernandez"}, + "superposition_model": { + "ws_superposition": superposition, + "ti_superposition": "Squared", + }, + "rotor_averaging": {"name": rotor}, + "blockage_model": {"name": "None"}, + "axial_induction_model": "Madsen", + }, + }, + } + + # 1. The constructed ws grid must not contain a 0 m/s reference case. + weighted = make_system("Weighted", "none") + turbine, _types, hub_heights = create_turbines(weighted["wind_farm"]) + x = weighted["wind_farm"]["layouts"][0]["coordinates"]["x"] + site_data = construct_site( + weighted, weighted["site"]["energy_resource"], hub_heights, x + ) + ws_grid = np.asarray(site_data["ws"]) + assert ws_grid[0] > 0.0, f"ws grid must exclude 0; starts at {ws_grid[0]}" + + # 2. Behaviour: WeightedSum must not collapse relative to LinearSum on the + # same Zong farm (pre-fix the Weighted AEP was inflated by the ws=0 bug). + aep_weighted = run_pywake(weighted, output_dir=str(tmp_path / "weighted")) + aep_linear = run_pywake( + make_system("Linear", "Center"), output_dir=str(tmp_path / "linear") + ) + npt.assert_allclose(aep_weighted, aep_linear, rtol=0.03) + + # if __name__ == "__main__": # test_heterogeneous_wind_rose() # simple_yaml_to_pywake('../examples/cases/windio_4turbines_multipleTurbines/plant_energy_turbine/IEA_10MW_turbine.yaml') diff --git a/tests/test_pywake_submodels.py b/tests/test_pywake_submodels.py new file mode 100644 index 0000000..0b09e2d --- /dev/null +++ b/tests/test_pywake_submodels.py @@ -0,0 +1,1025 @@ +"""Parametrized unit tests for PyWake submodel configuration functions. + +Tests each _configure_*() function in wifa/pywake_api.py to verify: +- Correct PyWake class is returned for each model name +- Parameters are passed through correctly +- NotImplementedError raised for unsupported names +- Case-insensitive matching works +""" + +import pytest +from py_wake.deficit_models import ( + HybridInduction, + RankineHalfBody, + SelfSimilarityDeficit, + SelfSimilarityDeficit2020, + VortexCylinder, + VortexDipole, +) +from py_wake.deficit_models.gaussian import ( + BastankhahGaussianDeficit, + BlondelSuperGaussianDeficit2020, + BlondelSuperGaussianDeficit2023, + CarbajofuertesGaussianDeficit, + NiayifarGaussianDeficit, + TurboGaussianDeficit, + ZongGaussianDeficit, +) +from py_wake.deficit_models.gcl import GCLDeficit +from py_wake.deficit_models.noj import NOJDeficit, NOJLocalDeficit, TurboNOJDeficit +from py_wake.deficit_models.rathmann import Rathmann +from py_wake.deficit_models.utils import ct2a_madsen, ct2a_mom1d +from py_wake.deflection_models import JimenezWakeDeflection +from py_wake.deflection_models.gcl_hill_vortex import GCLHillDeflection +from py_wake.ground_models.ground_models import Mirror +from py_wake.rotor_avg_models import ( + AreaOverlapAvgModel, + CGIRotorAvg, + EqGridRotorAvg, + GaussianOverlapAvgModel, + GQGridRotorAvg, + GridRotorAvg, + PolarGridRotorAvg, + RotorCenter, +) +from py_wake.superposition_models import ( + CumulativeWakeSum, + LinearSum, + MaxSum, + SqrMaxSum, + SquaredSum, + WeightedSum, +) +from py_wake.turbulence_models import ( + CrespoHernandez, + STF2005TurbulenceModel, + STF2017TurbulenceModel, +) +from py_wake.turbulence_models.gcl_turb import GCLTurbulence + +from wifa.pywake_api import ( + DEFAULTS, + _configure_blockage_model, + _configure_deficit_model, + _configure_deflection_model, + _configure_rotor_averaging, + _configure_superposition_model, + _configure_turbulence_model, + _fuga_atmosphere, + _fuga_z0_sweep, + configure_wake_model, + get_with_default, +) + +# Default rotor diameter and hub height for deficit model tests +_RD = 126.0 +_HH = 90.0 + + +def _call_deficit(name, analysis_extra=None, analysis_top=None): + """Helper to call _configure_deficit_model with minimal boilerplate. + + Returns ``(wake_model_class, deficit_args)``; the third element + (``deficit_post_attrs``) is dropped for the common case. ``analysis_top`` + merges keys at the ``analysis`` level (e.g. ``axial_induction_model``). + """ + wind_deficit_model = {"name": name, **(analysis_extra or {})} + analysis = {"wind_deficit_model": wind_deficit_model, **(analysis_top or {})} + cls, args, _post = _configure_deficit_model({"name": name}, analysis, _RD, _HH) + return cls, args + + +def _call_deficit_full(name, analysis_extra=None, analysis_top=None): + """Like ``_call_deficit`` but returns the full 3-tuple including post-attrs.""" + wind_deficit_model = {"name": name, **(analysis_extra or {})} + analysis = {"wind_deficit_model": wind_deficit_model, **(analysis_top or {})} + return _configure_deficit_model({"name": name}, analysis, _RD, _HH) + + +# --------------------------------------------------------------------------- +# Deficit model tests +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "name,expected_class", + [ + ("Jensen", NOJLocalDeficit), + ("jensen", NOJLocalDeficit), + ("JENSEN", NOJLocalDeficit), + ("Bastankhah2014", BastankhahGaussianDeficit), + ("bastankhah2014", BastankhahGaussianDeficit), + ("BASTANKHAH2014", BastankhahGaussianDeficit), + ("SuperGaussian", BlondelSuperGaussianDeficit2020), + ("supergaussian", BlondelSuperGaussianDeficit2020), + ("SuperGaussian2023", BlondelSuperGaussianDeficit2023), + ("TurboPark", TurboGaussianDeficit), + ("TurbOPark", TurboGaussianDeficit), + ("turbopark", TurboGaussianDeficit), + ("Niayifar2016", NiayifarGaussianDeficit), + ("niayifar2016", NiayifarGaussianDeficit), + ("Zong2020", ZongGaussianDeficit), + ("zong2020", ZongGaussianDeficit), + ("Carbajofuertes2018", CarbajofuertesGaussianDeficit), + ("carbajofuertes2018", CarbajofuertesGaussianDeficit), + ("TurboNOJ", TurboNOJDeficit), + ("turbonoj", TurboNOJDeficit), + ("GCL", GCLDeficit), + ("gcl", GCLDeficit), + ("NOJLocalDeficit", NOJLocalDeficit), + ("nojlocaldeficit", NOJLocalDeficit), + ("NOJLOCALDEFICIT", NOJLocalDeficit), + ("Jensen_1983", NOJDeficit), + ("jensen_1983", NOJDeficit), + ("JENSEN_1983", NOJDeficit), + ("NOJDeficit", NOJDeficit), + ("nojdeficit", NOJDeficit), + ("NOJDEFICIT", NOJDeficit), + ], +) +def test_configure_deficit_model(name, expected_class): + cls, args = _call_deficit(name) + assert cls is expected_class + + +@pytest.mark.parametrize( + "name,expected_class", + [ + ("Jensen", NOJLocalDeficit), + ("Bastankhah2014", BastankhahGaussianDeficit), + ("SuperGaussian", BlondelSuperGaussianDeficit2020), + ("SuperGaussian2023", BlondelSuperGaussianDeficit2023), + ("TurboPark", TurboGaussianDeficit), + ("Niayifar2016", NiayifarGaussianDeficit), + ("Zong2020", ZongGaussianDeficit), + ("Carbajofuertes2018", CarbajofuertesGaussianDeficit), + ("TurboNOJ", TurboNOJDeficit), + ("GCL", GCLDeficit), + ("NOJLocalDeficit", NOJLocalDeficit), + ("Jensen_1983", NOJDeficit), + ("NOJDeficit", NOJDeficit), + ], +) +def test_configure_deficit_model_instantiation(name, expected_class): + """Verify returned kwargs can actually instantiate the model without TypeError.""" + cls, args = _call_deficit(name) + instance = cls(**args) + assert isinstance(instance, expected_class) + + +def test_configure_deficit_model_bastankhah2014_params(): + """Verify wake expansion and ceps params are passed for Bastankhah2014.""" + cls, args = _call_deficit( + "Bastankhah2014", + {"wake_expansion_coefficient": {"k": 0.04}, "ceps": 0.2}, + ) + assert cls is BastankhahGaussianDeficit + assert args["k"] == 0.04 + assert args["ceps"] == 0.2 + + +def test_configure_deficit_model_bastankhah2014_k_b(): + """Verify k_b wake expansion is used when present.""" + _, args = _call_deficit( + "Bastankhah2014", + {"wake_expansion_coefficient": {"k_a": 0.38, "k_b": 0.004}}, + ) + assert args["k"] == 0.004 + + +def test_configure_deficit_model_jensen_k_b(): + """Verify Jensen k_a/k_b expansion params.""" + _, args = _call_deficit( + "Jensen", + {"wake_expansion_coefficient": {"k_a": 0.38, "k_b": 0.004}}, + ) + assert args["a"] == [0.38, 0.004] + + +def test_configure_deficit_model_nojlocaldeficit_k_b(): + """Verify NOJLocalDeficit k_a/k_b expansion params.""" + _, args = _call_deficit( + "NOJLocalDeficit", + {"wake_expansion_coefficient": {"k_a": 0.38, "k_b": 0.004}}, + ) + assert args["a"] == [0.38, 0.004] + + +def test_configure_deficit_model_jensen_1983_k(): + """Verify Jensen_1983 passes scalar k and does not pass use_effective_ws.""" + cls, args = _call_deficit( + "Jensen_1983", + {"wake_expansion_coefficient": {"k": 0.04}}, + ) + assert cls is NOJDeficit + assert args["k"] == 0.04 + assert "use_effective_ws" not in args + + +def test_configure_deficit_model_jensen_1983_k_b(): + """Jensen_1983 (NOJDeficit) accepts k via k_b, since windIO's + wake_expansion_coefficient has no scalar k field.""" + cls, args = _call_deficit( + "Jensen_1983", + {"wake_expansion_coefficient": {"k_a": 0.0, "k_b": 0.1}}, + ) + assert cls is NOJDeficit + assert args["k"] == 0.1 + + +def test_configure_deficit_model_gaussian_params_niayifar(): + """Verify Gaussian params pass through for Niayifar2016.""" + cls, args = _call_deficit( + "Niayifar2016", + { + "wake_expansion_coefficient": { + "k_a": 0.38, + "k_b": 0.004, + "free_stream_ti": False, + }, + "ceps": 0.3, + }, + ) + assert cls is NiayifarGaussianDeficit + assert args["a"] == [0.38, 0.004] + assert args["ceps"] == 0.3 + assert args["use_effective_ti"] is True + + +def test_configure_deficit_model_zong_no_ceps(): + """Verify Zong2020 does not pass ceps (unsupported).""" + _, args = _call_deficit( + "Zong2020", + {"ceps": 0.3, "wake_expansion_coefficient": {"free_stream_ti": True}}, + ) + assert "ceps" not in args + assert args["use_effective_ti"] is False + + +def test_configure_deficit_model_bastankhah2014_no_effective_ti(): + """Verify Bastankhah2014 does not pass use_effective_ti (unsupported).""" + _, args = _call_deficit( + "Bastankhah2014", + {"wake_expansion_coefficient": {"k": 0.04, "free_stream_ti": False}}, + ) + assert "use_effective_ti" not in args + assert args["k"] == 0.04 + + +def test_configure_deficit_model_a_param_warns_on_scalar_k(): + """Verify warning when scalar k is provided for a=[k_a, k_b] models.""" + with pytest.warns(UserWarning, match="uses a="): + _, args = _call_deficit( + "Niayifar2016", {"wake_expansion_coefficient": {"k": 0.05}} + ) + assert "k" not in args + assert "a" not in args + + +def test_configure_deficit_model_a_param_warns_on_missing_k_a(): + """Verify warning when k_b is provided without k_a.""" + with pytest.warns(UserWarning, match="k_a not specified"): + _, args = _call_deficit( + "Zong2020", {"wake_expansion_coefficient": {"k_b": 0.004}} + ) + assert args["a"] == [0, 0.004] + + +@pytest.mark.parametrize( + "name,extra,expected_class", + [ + ( + "Bastankhah2014", + {"wake_expansion_coefficient": {"k": 0.04}, "ceps": 0.2}, + BastankhahGaussianDeficit, + ), + ( + "Niayifar2016", + { + "wake_expansion_coefficient": { + "k_a": 0.38, + "k_b": 0.004, + "free_stream_ti": False, + }, + "ceps": 0.3, + }, + NiayifarGaussianDeficit, + ), + ( + "Zong2020", + { + "wake_expansion_coefficient": { + "k_a": 0.38, + "k_b": 0.004, + "free_stream_ti": True, + } + }, + ZongGaussianDeficit, + ), + ( + "Jensen", + {"wake_expansion_coefficient": {"k_a": 0.38, "k_b": 0.004}}, + NOJLocalDeficit, + ), + ( + "NOJLocalDeficit", + {"wake_expansion_coefficient": {"k_a": 0.38, "k_b": 0.004}}, + NOJLocalDeficit, + ), + ( + "Jensen_1983", + {"wake_expansion_coefficient": {"k": 0.04}}, + NOJDeficit, + ), + ], +) +def test_configure_deficit_model_instantiation_with_params(name, extra, expected_class): + """Verify models with user-specified params can be instantiated.""" + cls, args = _call_deficit(name, extra) + assert isinstance(cls(**args), expected_class) + + +def test_configure_deficit_model_turbonoj_A_param(): + """Verify TurboNOJ passes through the A parameter and can be instantiated.""" + cls, args = _call_deficit("TurboNOJ", {"A": 0.6}) + assert cls is TurboNOJDeficit + assert args["A"] == 0.6 + instance = cls(**args) + assert isinstance(instance, TurboNOJDeficit) + + +@pytest.mark.parametrize("name", ["Bastankhah2016", "bastankhah2016"]) +def test_configure_deficit_model_bastankhah2016_not_implemented(name): + with pytest.raises(NotImplementedError, match="Bastankhah2016"): + _call_deficit(name) + + +def test_configure_deficit_model_unknown(): + with pytest.raises(NotImplementedError, match="NonexistentModel"): + _call_deficit("NonexistentModel") + + +# --------------------------------------------------------------------------- +# TI reference flag (windIO free_stream_ti -> PyWake use_effective_ti) +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "name", + [ + "Jensen", + "Niayifar2016", + "Carbajofuertes2018", + "Zong2020", + "TurbOPark", + "SuperGaussian", + "SuperGaussian2023", + ], +) +@pytest.mark.parametrize("free_stream_ti,expected", [(False, True), (True, False)]) +def test_free_stream_ti_inverts_to_use_effective_ti(name, free_stream_ti, expected): + """free_stream_ti maps to use_effective_ti with inverted polarity, for every + TI-capable deficit (including SuperGaussian and TurbOPark, which the previous + narrow handling missed).""" + _, args = _call_deficit( + name, {"wake_expansion_coefficient": {"free_stream_ti": free_stream_ti}} + ) + assert args["use_effective_ti"] is expected + # kwargs must actually instantiate the model + cls, _ = _call_deficit(name) + cls(**args) + + +@pytest.mark.parametrize("name", ["Bastankhah2014"]) +def test_free_stream_ti_ignored_for_non_ti_capable(name): + """Deficits without a use_effective_ti param must not receive it, even if + free_stream_ti is present (would raise TypeError on instantiation). + + (GCL was moved to TI_CAPABLE — GCLDeficit accepts use_effective_ti, as + GCLLocal demonstrates.)""" + _, args = _call_deficit( + name, {"wake_expansion_coefficient": {"k_b": 0.04, "free_stream_ti": True}} + ) + assert "use_effective_ti" not in args + + +def test_use_effective_ti_key_is_ignored(): + """The deprecated top-level use_effective_ti key is no longer read.""" + _, args = _call_deficit( + "Niayifar2016", + { + "wake_expansion_coefficient": {"k_a": 0.38, "k_b": 0.004}, + "use_effective_ti": False, + }, + ) + assert "use_effective_ti" not in args + + +# --------------------------------------------------------------------------- +# Deflection model tests +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "name,expected_class", + [ + ("Jimenez", JimenezWakeDeflection), + ("jimenez", JimenezWakeDeflection), + ("JIMENEZ", JimenezWakeDeflection), + ("GCLHill", GCLHillDeflection), + ("gclhill", GCLHillDeflection), + ("GCLhill", GCLHillDeflection), + ], +) +def test_configure_deflection_model(name, expected_class): + model = _configure_deflection_model({"name": name, "beta": 0.1}) + assert isinstance(model, expected_class) + + +@pytest.mark.parametrize("name", [None, "None", "none", "NONE"]) +def test_configure_deflection_model_none(name): + assert _configure_deflection_model({"name": name, "beta": 0.1}) is None + + +def test_configure_deflection_model_bastankhah2016(): + with pytest.raises(NotImplementedError, match="Bastankhah2016"): + _configure_deflection_model({"name": "Bastankhah2016", "beta": 0.1}) + + +def test_configure_deflection_model_unknown(): + with pytest.raises(NotImplementedError, match="UnknownDeflection"): + _configure_deflection_model({"name": "UnknownDeflection", "beta": 0.1}) + + +# --------------------------------------------------------------------------- +# Turbulence model tests +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "name,expected_class", + [ + ("STF2005", STF2005TurbulenceModel), + ("stf2005", STF2005TurbulenceModel), + ("STF2017", STF2017TurbulenceModel), + ("stf2017", STF2017TurbulenceModel), + ("CrespoHernandez", CrespoHernandez), + ("crespohernandez", CrespoHernandez), + ("CRESPOHERNANDEZ", CrespoHernandez), + ("IEC-TI-2019", STF2017TurbulenceModel), + ("iec-ti-2019", STF2017TurbulenceModel), + ("GCL", GCLTurbulence), + ("gcl", GCLTurbulence), + ], +) +def test_configure_turbulence_model(name, expected_class): + data = {"name": name, "c1": 1.0, "c2": 1.0} + assert isinstance(_configure_turbulence_model(data), expected_class) + + +@pytest.mark.parametrize("name", [None, "None", "none", "NONE"]) +def test_configure_turbulence_model_none(name): + assert _configure_turbulence_model({"name": name, "c1": 1.0, "c2": 1.0}) is None + + +def test_configure_turbulence_model_unknown(): + with pytest.raises(NotImplementedError, match="UnknownTurb"): + _configure_turbulence_model({"name": "UnknownTurb", "c1": 1.0, "c2": 1.0}) + + +# --------------------------------------------------------------------------- +# Superposition model tests +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "name,expected_class", + [ + ("Linear", LinearSum), + ("linear", LinearSum), + ("LINEAR", LinearSum), + ("Squared", SquaredSum), + ("squared", SquaredSum), + ("Max", MaxSum), + ("max", MaxSum), + ("Weighted", WeightedSum), + ("weighted", WeightedSum), + ("Cumulative", CumulativeWakeSum), + ("cumulative", CumulativeWakeSum), + ], +) +def test_configure_superposition_model(name, expected_class): + assert isinstance( + _configure_superposition_model({"ws_superposition": name}), expected_class + ) + + +def test_configure_superposition_model_product_not_implemented(): + with pytest.raises(NotImplementedError, match="Product"): + _configure_superposition_model({"ws_superposition": "Product"}) + + +def test_configure_superposition_model_vector_not_implemented(): + """Vector superposition is foxes-only; the pyWake path rejects it.""" + with pytest.raises(NotImplementedError, match="Vector"): + _configure_superposition_model({"ws_superposition": "Vector"}) + + +def test_configure_superposition_model_unknown(): + with pytest.raises(NotImplementedError, match="UnknownSuper"): + _configure_superposition_model({"ws_superposition": "UnknownSuper"}) + + +# --------------------------------------------------------------------------- +# Rotor averaging tests +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "name,expected_class", + [ + ("Center", RotorCenter), + ("center", RotorCenter), + ("CENTER", RotorCenter), + ("grid", GridRotorAvg), + ("avg_deficit", GridRotorAvg), + ("Avg_Deficit", GridRotorAvg), + ("gaussian_overlap", GaussianOverlapAvgModel), + ("gaussianoverlap", GaussianOverlapAvgModel), + ("area_overlap", AreaOverlapAvgModel), + ("areaoverlap", AreaOverlapAvgModel), + ("EqGrid", EqGridRotorAvg), + ("eqgrid", EqGridRotorAvg), + ("GQGrid", GQGridRotorAvg), + ("gqgrid", GQGridRotorAvg), + ("PolarGrid", PolarGridRotorAvg), + ("polargrid", PolarGridRotorAvg), + ("CGI", CGIRotorAvg), + ("cgi", CGIRotorAvg), + ], +) +def test_configure_rotor_averaging(name, expected_class): + assert isinstance(_configure_rotor_averaging({"name": name}), expected_class) + + +def test_configure_rotor_averaging_eqgrid_n(): + assert isinstance( + _configure_rotor_averaging({"name": "EqGrid", "n": 9}), EqGridRotorAvg + ) + + +def test_configure_rotor_averaging_gqgrid_params(): + data = {"name": "GQGrid", "n_x_grid_points": 3, "n_y_grid_points": 5} + model = _configure_rotor_averaging(data) + assert isinstance(model, GQGridRotorAvg) + # Verify custom params produced different nodes than defaults + default = GQGridRotorAvg() + assert len(model.nodes_x) != len(default.nodes_x) + + +def test_configure_rotor_averaging_cgi_n(): + assert isinstance(_configure_rotor_averaging({"name": "CGI", "n": 7}), CGIRotorAvg) + + +def test_configure_rotor_averaging_unknown(): + with pytest.raises(NotImplementedError, match="UnknownRotor"): + _configure_rotor_averaging({"name": "UnknownRotor"}) + + +# --------------------------------------------------------------------------- +# Blockage model tests +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "name,expected_class", + [ + ("SelfSimilarityDeficit2020", SelfSimilarityDeficit2020), + ("selfsimilaritydeficit2020", SelfSimilarityDeficit2020), + ("SelfSimilarityDeficit", SelfSimilarityDeficit), + ("selfsimilaritydeficit", SelfSimilarityDeficit), + ("RankineHalfBody", RankineHalfBody), + ("rankinehalfbody", RankineHalfBody), + ("Rathmann", Rathmann), + ("rathmann", Rathmann), + ("VortexCylinder", VortexCylinder), + ("vortexcylinder", VortexCylinder), + ("VortexDipole", VortexDipole), + ("vortexdipole", VortexDipole), + ("HybridInduction", HybridInduction), + ("hybridinduction", HybridInduction), + ], +) +def test_configure_blockage_model(name, expected_class): + model = _configure_blockage_model({"name": name, "ss_alpha": 0.888}, {}) + assert isinstance(model, expected_class) + + +@pytest.mark.parametrize("name", [None, "None", "none", "NONE"]) +def test_configure_blockage_model_none(name): + assert _configure_blockage_model({"name": name}, {}) is None + + +def test_configure_blockage_model_unknown(): + with pytest.raises(NotImplementedError, match="UnknownBlockage"): + _configure_blockage_model({"name": "UnknownBlockage"}, {}) + + +# --------------------------------------------------------------------------- +# get_with_default preserves extra user keys +# --------------------------------------------------------------------------- + + +def test_get_with_default_preserves_extra_keys(): + """Verify that get_with_default merges defaults without dropping user keys.""" + analysis = { + "rotor_averaging": { + "name": "GQGrid", + "n_x_grid_points": 3, + "n_y_grid_points": 5, + }, + } + result = get_with_default(analysis, "rotor_averaging", DEFAULTS) + assert result["name"] == "GQGrid" + assert result["n_x_grid_points"] == 3 + assert result["n_y_grid_points"] == 5 + + +def test_get_with_default_rotor_avg_eqgrid_n(): + """Verify EqGrid 'n' param survives through get_with_default.""" + analysis = {"rotor_averaging": {"name": "EqGrid", "n": 9}} + result = get_with_default(analysis, "rotor_averaging", DEFAULTS) + model = _configure_rotor_averaging(result) + assert isinstance(model, EqGridRotorAvg) + assert result["n"] == 9 + + +def test_get_with_default_fills_missing_keys(): + """Verify that missing keys are filled from defaults.""" + # deflection_model defaults have beta=0.1; user only provides name + analysis = {"deflection_model": {"name": "Jimenez"}} + result = get_with_default(analysis, "deflection_model", DEFAULTS) + assert result["name"] == "Jimenez" + assert result["beta"] == 0.1 + + +def test_get_with_default_recursive_nested_dicts(): + """Verify recursive merge fills deep missing keys while preserving user extras.""" + nested_defaults = { + "model": { + "params": {"a": 1, "b": 2}, + "name": "default", + } + } + # User provides partial nested dict (missing key "b") plus an extra key "c" + data = { + "model": { + "params": {"a": 10, "c": 99}, + "name": "custom", + } + } + result = get_with_default(data, "model", nested_defaults) + assert result["name"] == "custom" + assert result["params"]["a"] == 10 # user value preserved + assert result["params"]["b"] == 2 # missing key filled from defaults + assert result["params"]["c"] == 99 # extra user key preserved + + +# --------------------------------------------------------------------------- +# configure_wake_model return contract +# --------------------------------------------------------------------------- + + +def test_configure_wake_model_returns_wake_deficit_key(): + """Verify configure_wake_model returns wake_deficit_key for API compat.""" + system_dat = { + "attributes": { + "analysis": { + "wind_deficit_model": {"name": "Jensen"}, + } + } + } + config = configure_wake_model(system_dat, rotor_diameter=126.0, hub_height=90.0) + assert "wake_deficit_key" in config + assert config["wake_deficit_key"] is None + + +def _weighted_system(rotor_name, ws_superposition="Weighted"): + return { + "attributes": { + "analysis": { + "wind_deficit_model": { + "name": "Zong2020", + "wake_expansion_coefficient": {"k_a": 0.38, "k_b": 0.004}, + }, + "superposition_model": {"ws_superposition": ws_superposition}, + "rotor_averaging": {"name": rotor_name}, + "deflection_model": {"name": "None"}, + "turbulence_model": {"name": "None"}, + "blockage_model": {"name": None}, + } + } + } + + +@pytest.mark.parametrize("rotor_name", ["gaussian_overlap", "area_overlap", "center"]) +@pytest.mark.parametrize("ws_superposition", ["Weighted", "Cumulative"]) +def test_weighted_superposition_requires_node_rotor_avg(rotor_name, ws_superposition): + """WeightedSum/CumulativeWakeSum with a non-node rotor-averaging model raises + a clear ValueError instead of PyWake's deep AssertionError.""" + with pytest.raises(ValueError, match="node"): + configure_wake_model( + _weighted_system(rotor_name, ws_superposition), + rotor_diameter=126.0, + hub_height=90.0, + ) + + +@pytest.mark.parametrize("rotor_name", ["grid", "eq_grid", "gq_grid", "cgi"]) +def test_weighted_superposition_allows_node_rotor_avg(rotor_name): + """A node rotor-averaging model is accepted with Weighted superposition.""" + config = configure_wake_model( + _weighted_system(rotor_name), rotor_diameter=126.0, hub_height=90.0 + ) + assert isinstance(config["superposition_model"], WeightedSum) + + +def _weighted_deficit_system(deficit_name): + """Weighted superposition + node rotor-avg, varying only the deficit.""" + return { + "attributes": { + "analysis": { + "wind_deficit_model": { + "name": deficit_name, + "wake_expansion_coefficient": {"free_stream_ti": True}, + }, + "superposition_model": {"ws_superposition": "Weighted"}, + "rotor_averaging": {"name": "grid"}, + "deflection_model": {"name": "None"}, + "turbulence_model": {"name": "None"}, + "blockage_model": {"name": None}, + } + } + } + + +@pytest.mark.parametrize("deficit_name", ["SuperGaussian", "SuperGaussian2023", "GCL"]) +def test_weighted_superposition_requires_convection_deficit(deficit_name): + """WeightedSum/CumulativeWakeSum with a non-ConvectionDeficitModel deficit + (super-Gaussian, GCL) raises a clear ValueError instead of PyWake's deep + AssertionError, even when the rotor-averaging model is a node model.""" + with pytest.raises(ValueError, match="ConvectionDeficitModel"): + configure_wake_model( + _weighted_deficit_system(deficit_name), + rotor_diameter=126.0, + hub_height=90.0, + ) + + +@pytest.mark.parametrize("deficit_name", ["Zong2020", "Niayifar2016", "Bastankhah2014"]) +def test_weighted_superposition_allows_convection_deficit(deficit_name): + """Convection-based deficits are accepted with Weighted superposition.""" + config = configure_wake_model( + _weighted_deficit_system(deficit_name), rotor_diameter=126.0, hub_height=90.0 + ) + assert isinstance(config["superposition_model"], WeightedSum) + + +# --------------------------------------------------------------------------- +# CrespoHernandez calibration coefficients (Phase 2 / Fix C) +# --------------------------------------------------------------------------- + + +def test_crespo_default_without_c(): + """No c -> the PyWake-default CrespoHernandez (ct2a_madsen).""" + tm = _configure_turbulence_model({"name": "CrespoHernandez"}) + assert isinstance(tm, CrespoHernandez) + assert tm.ct2a is ct2a_madsen + + +def test_crespo_with_c_uses_literature_recipe(): + """c -> CrespoHernandez with those coefficients, 1D induction and SqrMaxSum.""" + c = [0.73, 0.83, 0.03, -0.32] + tm = _configure_turbulence_model({"name": "CrespoHernandez", "c": c}) + assert isinstance(tm, CrespoHernandez) + assert list(tm.c) == c + assert tm.ct2a is ct2a_mom1d + assert isinstance(tm.addedTurbulenceSuperpositionModel, SqrMaxSum) + + +# --------------------------------------------------------------------------- +# 'none' rotor averaging + WeightedSum (Phase 2 / Fix D) +# --------------------------------------------------------------------------- + + +def test_rotor_averaging_none(): + assert _configure_rotor_averaging({"name": "none"}) is None + + +def test_weighted_superposition_allows_none_rotor(): + """WeightedSum accepts rotorAvgModel=None (rotor centre), as Zong (2020) uses.""" + sd = _weighted_deficit_system("Zong2020") + sd["attributes"]["analysis"]["rotor_averaging"] = {"name": "none"} + config = configure_wake_model(sd, rotor_diameter=126.0, hub_height=90.0) + assert config["rotor_averaging"] is None + assert isinstance(config["superposition_model"], WeightedSum) + + +def test_weighted_superposition_rejects_center_rotor(): + """A non-node, non-None rotor model is still rejected for WeightedSum.""" + sd = _weighted_deficit_system("Zong2020") + sd["attributes"]["analysis"]["rotor_averaging"] = {"name": "center"} + with pytest.raises(ValueError, match="node"): + configure_wake_model(sd, rotor_diameter=126.0, hub_height=90.0) + + +# --------------------------------------------------------------------------- +# Zong ceps -> eps_coeff (Phase 2) +# --------------------------------------------------------------------------- + + +def test_zong_ceps_maps_to_eps_coeff(): + """Zong's near-wake epsilon is named eps_coeff (not ceps) in PyWake.""" + cls, args = _call_deficit( + "Zong2020", + {"wake_expansion_coefficient": {"k_a": 0.38, "k_b": 0.004}, "ceps": 0.35}, + ) + assert cls is ZongGaussianDeficit + assert args["eps_coeff"] == 0.35 + assert "ceps" not in args + + +# --------------------------------------------------------------------------- +# Axial induction -> ct2a (honoring axial_induction_model) +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "axial, expected", + [("1D", ct2a_mom1d), ("Madsen", ct2a_madsen), ("madsen", ct2a_madsen)], +) +def test_axial_induction_sets_ct2a(axial, expected): + """axial_induction_model maps to the deficit's ct2a on a ct2a-capable model.""" + _, args = _call_deficit( + "Bastankhah2014", analysis_top={"axial_induction_model": axial} + ) + assert args["ct2a"] is expected + + +def test_axial_induction_default_absent_keeps_deficit_default(): + """No axial_induction_model -> no ct2a override (deficit keeps its default).""" + _, args = _call_deficit("Bastankhah2014") + assert "ct2a" not in args + + +def test_axial_induction_skipped_when_deficit_has_no_ct2a(): + """Blondel2020 has no ct2a parameter, so it is left untouched.""" + _, args = _call_deficit( + "SuperGaussian", analysis_top={"axial_induction_model": "1D"} + ) + assert "ct2a" not in args + + +def test_axial_induction_ct2a_instantiates(): + """The injected ct2a is a valid constructor argument.""" + cls, args = _call_deficit( + "Niayifar2016", analysis_top={"axial_induction_model": "1D"} + ) + assert isinstance(cls(**args), NiayifarGaussianDeficit) + + +# --------------------------------------------------------------------------- +# TurbOPark canonical recipe (Nygaard 2022) +# --------------------------------------------------------------------------- + + +def test_turbopark_recipe_ground_and_ctlim(): + """TurbOPark gets a Mirror ground model and ctlim=0.96 as constructor args.""" + cls, args, post = _call_deficit_full("TurbOPark") + assert cls is TurboGaussianDeficit + assert isinstance(args["groundModel"], Mirror) + assert args["ctlim"] == 0.96 + + +def test_turbopark_recipe_ws_key_post_attr(): + """TurbOPark scales by the downstream ambient WS via WS_key='WS_jlk'.""" + _, _, post = _call_deficit_full("TurbOPark") + assert post == {"WS_key": "WS_jlk"} + + +def test_turbopark_recipe_instantiates_and_applies_ws_key(): + """The recipe builds a valid deficit and WS_key is set post-construction.""" + cls, args, post = _call_deficit_full("TurbOPark") + deficit = cls(**args) + for attr, value in post.items(): + setattr(deficit, attr, value) + assert deficit.WS_key == "WS_jlk" + + +def test_non_turbopark_has_no_post_attrs(): + """Only TurbOPark carries post-construction attributes.""" + _, _, post = _call_deficit_full("Bastankhah2014") + assert post == {} + + +# --------------------------------------------------------------------------- +# use_effective_ws / use_effective_ti for GCL (Phase 3) +# --------------------------------------------------------------------------- + + +def test_use_effective_ws_honored(): + """The windIO use_effective_ws flag is passed through (not hardcoded).""" + _, args = _call_deficit("Bastankhah2014", {"use_effective_ws": False}) + assert args["use_effective_ws"] is False + + +def test_use_effective_ws_defaults_true(): + _, args = _call_deficit("Bastankhah2014") + assert args["use_effective_ws"] is True + + +def test_gcl_honors_free_stream_ti(): + """GCLDeficit accepts use_effective_ti (GCLLocal); free_stream_ti is honored.""" + _, args = _call_deficit( + "GCL", {"wake_expansion_coefficient": {"free_stream_ti": False}} + ) + assert args["use_effective_ti"] is True + + +# --------------------------------------------------------------------------- +# Fuga LUT-atmosphere helpers (pure functions; no LUT generation) +# --------------------------------------------------------------------------- +import numpy as np # noqa: E402 + + +def _resource(ti=None, abl=None, z0=None): + wr = {} + if ti is not None: + wr["turbulence_intensity"] = {"data": np.asarray(ti, dtype=float)} + if abl is not None: + wr["ABL_height"] = {"data": np.asarray(abl, dtype=float)} + if z0 is not None: + wr["z0"] = {"data": np.asarray(z0, dtype=float)} + return {"wind_resource": wr} + + +def test_fuga_atmosphere_derives_z0_from_ti(): + # Neutral inversion: z0 = zhub * exp(-1/TI). Mean TI 0.10 at hub 78. + z0, zi, zeta0, ti = _fuga_atmosphere( + _resource(ti=np.full(50, 0.10), abl=np.full(50, 600.0)), {}, 78.0 + ) + assert zeta0 == 0.0 + assert zi == 600.0 # from ABL_height + assert ti == pytest.approx(0.10) + assert z0 == pytest.approx(78.0 * np.exp(-1 / 0.10), rel=1e-3) + + +def test_fuga_atmosphere_defaults_without_resource(): + z0, zi, zeta0, ti = _fuga_atmosphere(None, {}, 78.0) + assert z0 == 0.03 and zi == 500.0 and ti is None + + +def test_fuga_atmosphere_config_overrides_site(): + z0, zi, _, _ = _fuga_atmosphere( + _resource(ti=np.full(10, 0.10)), {"z0": 0.001, "zi": 800.0}, 78.0 + ) + assert z0 == 0.001 and zi == 800.0 + + +def test_fuga_z0_sweep_spans_distribution_and_is_sorted(): + rng = np.random.default_rng(0) + ti = np.clip(rng.normal(0.10, 0.03, 5000), 0.03, 0.25) + z0s = _fuga_z0_sweep(_resource(ti=ti), {"n_z0": 5}, 78.0, 0.0, 0.03) + assert len(z0s) >= 2 + assert z0s == sorted(z0s) + # all physical: clamp keeps z0 within ~[1e-5, 0.3] m + assert z0s[0] >= 1e-5 and z0s[-1] <= 0.31 + + +def test_fuga_z0_sweep_clamps_high_ti_to_physical_z0(): + # All-high TI must not produce absurd roughness (TI 0.30 -> z0 ~2.8 m). + z0s = _fuga_z0_sweep(_resource(ti=np.full(200, 0.30)), {"n_z0": 5}, 78.0, 0.0, 0.5) + assert max(z0s) <= 0.31 + + +def test_fuga_z0_sweep_single_when_n1_or_no_ti(): + assert _fuga_z0_sweep( + _resource(ti=np.full(10, 0.1)), {"n_z0": 1}, 78.0, 0.0, 0.04 + ) == [0.04] + assert _fuga_z0_sweep(None, {"n_z0": 5}, 78.0, 0.0, 0.04) == [0.04] + + +def test_fuga_z0_sweep_explicit_z0_overrides(): + z0s = _fuga_z0_sweep( + _resource(ti=np.full(10, 0.1)), {"z0": [0.01, 0.05]}, 78.0, 0.0, 0.04 + ) + assert z0s == [0.01, 0.05] + + +def test_fuga_atmosphere_ignores_list_z0(): + # An explicit z0 list is a sweep (handled by _fuga_z0_sweep); _fuga_atmosphere + # must not choke on it when computing the scalar fallback. + z0, zi, _, _ = _fuga_atmosphere( + _resource(ti=np.full(10, 0.10)), {"z0": [1e-4, 1e-2]}, 78.0 + ) + assert isinstance(z0, float) # derived from TI, not the list diff --git a/wifa/foxes_api.py b/wifa/foxes_api.py index 911f6fb..af160f3 100644 --- a/wifa/foxes_api.py +++ b/wifa/foxes_api.py @@ -66,7 +66,9 @@ def run_foxes( idir = input_dir else: input_yaml = Path(input_yaml) - wio = load_yaml(input_yaml) + # Keep an included wind_resource.nc as numpy arrays (foxes' reader uses + # ndarrays directly), avoiding the dict-of-lists memory blow-up. + wio = load_yaml(input_yaml, nc_data="array") idir = input_yaml.parent idict, algo, odir = read_windio_dict(wio, verbosity=verbosity) diff --git a/wifa/pywake_api.py b/wifa/pywake_api.py index 91cf99a..248ee3b 100644 --- a/wifa/pywake_api.py +++ b/wifa/pywake_api.py @@ -5,15 +5,19 @@ import numpy as np import xarray as xr -import yaml from scipy.interpolate import interp1d from scipy.special import gamma -from windIO import dict_to_netcdf, load_yaml -from windIO import validate as validate_yaml from wifa._optional import require # Define default values for wind_deficit_model parameters + + +def _normalize_name(name): + """Normalize model name for case-insensitive matching.""" + return name.strip().lower().replace("-", "").replace("_", "") + + DEFAULTS = { "wind_deficit_model": { "name": "Jensen", @@ -43,16 +47,25 @@ def get_with_default(data, key, defaults): If the value is a dictionary, apply the same process recursively. """ if key not in data: - print("WARNING: Using default value for ", key) + warnings.warn(f"Using default value for {key}") return defaults[key] - elif isinstance(data[key], dict): - # For nested dictionaries, ensure all subkeys are checked for defaults - return { - sub_key: get_with_default(data[key], sub_key, defaults[key]) - for sub_key in defaults[key] - } - else: - return data[key] + + if isinstance(data[key], dict): + # Merge defaults into user dict: fill missing keys from defaults, + # but preserve all extra user-provided keys (e.g. n, n_x_grid_points). + # Recurse when both user and default values are dicts. + merged = dict(data[key]) + for sub_key in defaults[key]: + if sub_key not in merged: + warnings.warn(f"Using default value for {sub_key}") + merged[sub_key] = defaults[key][sub_key] + elif isinstance(merged[sub_key], dict) and isinstance( + defaults[key][sub_key], dict + ): + merged[sub_key] = get_with_default(data[key], sub_key, defaults[key]) + return merged + + return data[key] def load_and_validate_config(yaml_input, default_output_dir="output"): @@ -69,8 +82,11 @@ def load_and_validate_config(yaml_input, default_output_dir="output"): from windIO import validate as validate_yaml if not isinstance(yaml_input, dict): - validate_yaml(yaml_input, "plant/wind_energy_system") - system_dat = load_yaml(Path(yaml_input)) + # Keep an included wind_resource.nc as numpy arrays instead of exploding + # it into Python lists; validate structure-only so jsonschema does not + # require/iterate the bulk data. + validate_yaml(yaml_input, "plant/wind_energy_system", array_data=True) + system_dat = load_yaml(Path(yaml_input), nc_data="array") else: system_dat = yaml_input @@ -100,8 +116,10 @@ def create_turbines(farm_dat): """ from py_wake.wind_turbines import WindTurbine, WindTurbines from py_wake.wind_turbines.power_ct_functions import ( + DensityCompensation, PowerCtFunctionList, PowerCtTabular, + SimpleYawModel, ) # Handle single vs multiple turbine types @@ -109,9 +127,7 @@ def create_turbines(farm_dat): turbine_dats = [farm_dat["turbines"]] type_names = "0" else: - turbine_dats = [ - farm_dat["turbine_types"][key] for key in farm_dat["turbine_types"] - ] + turbine_dats = list(farm_dat["turbine_types"].values()) type_names = list(farm_dat["turbine_types"].keys()) turbines = [] @@ -150,11 +166,25 @@ def create_turbines(farm_dat): cutin = turbine_dat["performance"].get("cutin_wind_speed", 0) cutout = turbine_dat["performance"].get("cutout_wind_speed") + # Use DensityCompensation (wind speed correction before lookup) to + # match foxes' air density handling: ws *= (rho/rho_ref)^(1/3) + try: + yaw_model = SimpleYawModel(exp=2) # PyWake < 2.6 + except TypeError: + yaw_model = SimpleYawModel() # PyWake >= 2.6 + density_models = [yaw_model, DensityCompensation(1.225)] + this_turbine = WindTurbine( name=turbine_dat["name"], diameter=rd, hub_height=hh, - powerCtFunction=PowerCtTabular(speeds, powers, power_unit="W", ct=cts_int), + powerCtFunction=PowerCtTabular( + speeds, + powers, + power_unit="W", + ct=cts_int, + additional_models=density_models, + ), ws_cutin=cutin, ws_cutout=cutout, ) @@ -231,7 +261,6 @@ def dict_to_site(resource_dict): # This is required for XRSite's linear interpolation, which expects the turbine index # as the leading dimension. resource_ds = resource_ds.transpose("i", *other_dims) - print("making site with ", resource_ds) return XRSite(resource_ds) @@ -267,68 +296,49 @@ def construct_site(system_dat, resource_dat, hub_heights, x_positions): dict with keys: site, ws, wd, TI, timeseries, operating, additional_heights, cases_idx, flow_bounds """ - from py_wake.examples.data.hornsrev1 import Hornsrev1Site - from py_wake.site import XRSite - from windIO import dict_to_netcdf - - # Get flow field bounds from config or site boundaries - boundaries = system_dat["site"]["boundaries"]["polygons"][0] - WFXLB = np.min(boundaries["x"]) - WFXUB = np.max(boundaries["x"]) - WFYLB = np.min(boundaries["y"]) - WFYUB = np.max(boundaries["y"]) - - # Override with explicit flow field bounds if specified - WFXLB = get_flow_field_param(system_dat, "xlb", WFXLB) - WFXUB = get_flow_field_param(system_dat, "xub", WFXUB) - WFYLB = get_flow_field_param(system_dat, "ylb", WFYLB) - WFYUB = get_flow_field_param(system_dat, "yub", WFYUB) - WFDX = get_flow_field_param(system_dat, "dx", (WFXUB - WFXLB) / 100) - WFDY = get_flow_field_param(system_dat, "dy", (WFYUB - WFYLB) / 100) - - flow_bounds = { - "xlb": WFXLB, - "xub": WFXUB, - "ylb": WFYLB, - "yub": WFYUB, - "dx": WFDX, - "dy": WFDY, - } + # Compute flow field bounds from site boundaries, with optional overrides + flow_bounds = _compute_flow_bounds(system_dat) # Determine site type and construct accordingly - if "time" in resource_dat["wind_resource"]: - # Timeseries site + wind_resource = resource_dat["wind_resource"] + if "time" in wind_resource: result = _construct_timeseries_site( system_dat, resource_dat, hub_heights, x_positions ) - result["flow_bounds"] = flow_bounds - return result - - elif "weibull_k" in resource_dat["wind_resource"]: - # Weibull distribution site + elif "weibull_k" in wind_resource: result = _construct_weibull_site(resource_dat, hub_heights, x_positions) - result["flow_bounds"] = flow_bounds - return result - else: - # Simple probability-based site - ws = resource_dat["wind_resource"]["wind_speed"] - wd = resource_dat["wind_resource"]["wind_direction"] - site = dict_to_site(resource_dat["wind_resource"]) - TI = resource_dat["wind_resource"]["turbulence_intensity"]["data"] - - return { - "site": site, - "ws": ws, - "wd": wd, - "TI": TI, + result = { + "site": dict_to_site(wind_resource), + "ws": wind_resource["wind_speed"], + "wd": wind_resource["wind_direction"], + "TI": wind_resource["turbulence_intensity"]["data"], "timeseries": False, "operating": np.ones((len(x_positions), 1)), "additional_heights": [], "cases_idx": np.ones(1).astype(bool), - "flow_bounds": flow_bounds, } + result["flow_bounds"] = flow_bounds + return result + + +def _compute_flow_bounds(system_dat): + """Compute flow field bounds from site boundaries with optional overrides.""" + boundaries = system_dat["site"]["boundaries"]["polygons"][0] + xlb = get_flow_field_param(system_dat, "xlb", np.min(boundaries["x"])) + xub = get_flow_field_param(system_dat, "xub", np.max(boundaries["x"])) + ylb = get_flow_field_param(system_dat, "ylb", np.min(boundaries["y"])) + yub = get_flow_field_param(system_dat, "yub", np.max(boundaries["y"])) + return { + "xlb": xlb, + "xub": xub, + "ylb": ylb, + "yub": yub, + "dx": get_flow_field_param(system_dat, "dx", (xub - xlb) / 100), + "dy": get_flow_field_param(system_dat, "dy", (yub - ylb) / 100), + } + def _construct_timeseries_site(system_dat, resource_dat, hub_heights, x_positions): """Construct site from timeseries data. @@ -343,21 +353,32 @@ def _construct_timeseries_site(system_dat, resource_dat, hub_heights, x_position cases_idx = np.ones(len(times)).astype(bool) # Check for subset configuration - output_spec = system_dat["attributes"].get("model_outputs_specification", {}) - if "run_configuration" in output_spec: - run_config = output_spec["run_configuration"] - if "times_run" in run_config and not run_config["times_run"].get( - "all_occurences", True - ): - if "subset" in run_config["times_run"]: - cases_idx = run_config["times_run"]["subset"] + times_run = ( + system_dat["attributes"] + .get("model_outputs_specification", {}) + .get("run_configuration", {}) + .get("times_run", {}) + ) + do_subset = False + if not times_run.get("all_occurences", True) and "subset" in times_run: + cases_idx = times_run["subset"] + do_subset = True heights = wind_resource.get("height") + # ``heights`` may be a list or a numpy array (array-backed resource), so use + # an explicit boolean rather than the ambiguous truth value of an array. + has_heights = heights is not None and len(heights) > 0 + + def _subset(arr): + # Only copy when a real time subset is requested. An all-True boolean + # index still copies the whole array, so skip it in the common case. + return arr[cases_idx] if do_subset else arr - # Helper to get data and dimensions safely + # Helper to get data and dimensions safely. np.asarray avoids copying when + # the data is already an ndarray (e.g. an array-backed windIO resource). def get_resource_data(var_name): data_obj = wind_resource[var_name] - vals = np.array(data_obj["data"]) + vals = np.asarray(data_obj["data"]) dims = data_obj.get("dims", ["time"]) return vals, dims @@ -366,8 +387,8 @@ def get_resource_data(var_name): wd_vals, wd_dims = get_resource_data("wind_direction") # Apply subsetting - ws_vals = ws_vals[cases_idx] - wd_vals = wd_vals[cases_idx] + ws_vals = _subset(ws_vals) + wd_vals = _subset(wd_vals) # Prepare reference arrays - average across turbines if turbine-specific if "wind_turbine" in ws_dims: @@ -386,7 +407,7 @@ def get_resource_data(var_name): # Handle operating status if "operating" in wind_resource: - operating = np.array(wind_resource["operating"]["data"])[cases_idx].T + operating = _subset(np.asarray(wind_resource["operating"]["data"])).T assert operating.shape[0] == len(x_positions) else: operating = np.ones((len(x_positions), len(cases_idx))) @@ -397,69 +418,118 @@ def get_resource_data(var_name): site = None if len(hub_heights) > 1: - # Multiple turbine types - need height interpolation - flow_field_spec = ( - system_dat["attributes"] - .get("model_outputs_specification", {}) - .get("flow_field", {}) - ) - if ( - "z_planes" in flow_field_spec - and flow_field_spec["z_planes"] != "hub_heights" - ): - additional_heights = flow_field_spec.get("z_planes", {}).get("z_list", []) + # Multiple turbine types with differing hub heights. + if ("wind_turbine" in ws_dims or "wind_turbine" in wd_dims) and not has_heights: + # Per-turbine ws/wd are given without a vertical profile (e.g. terrain + # speedups produced by a microscale model, already at each turbine's + # hub height). Preserve the per-turbine values via dict_to_site rather + # than averaging across turbines or interpolating a vertical profile. + site = dict_to_site(wind_resource) + if "turbulence_intensity" not in wind_resource: + TI = 0.02 + else: + TI = _subset(np.asarray(wind_resource["turbulence_intensity"]["data"])) + elif "wind_turbine" in ws_dims or "wind_turbine" in wd_dims: + # Both a vertical profile and per-turbine data is ambiguous — we + # cannot tell whether height or turbine indexes the inflow. + raise NotImplementedError( + "A wind resource with both a 'height' profile and a " + "'wind_turbine' dimension is not supported. Provide one or the " + "other for mixed hub heights." + ) + else: + # Vertical wind profile (a `height` dimension) with mixed hub + # heights: interpolate the profile to each distinct hub height (plus + # any requested flow-field z-planes) and build a height-indexed + # XRSite. pyWake then assigns every turbine its inflow at its own + # hub height. + flow_field_spec = ( + system_dat["attributes"] + .get("model_outputs_specification", {}) + .get("flow_field", {}) + ) + if ( + "z_planes" in flow_field_spec + and flow_field_spec["z_planes"] != "hub_heights" + ): + additional_heights = flow_field_spec.get("z_planes", {}).get( + "z_list", [] + ) - speeds, dirs, TIs, seen = [], [], [], [] - for hh in sorted(np.append(list(hub_heights.values()), additional_heights)): - if hh in seen: - continue - seen.append(hh) - ws_int, wd_int = _interpolate_wind_data(heights, ws, wd, hh) - speeds.append(ws_int) - dirs.append(wd_int) + h_levels = sorted(set(hub_heights.values()) | set(additional_heights)) - ws, wd = ws_int, wd_int + # Interpolate WS (linear) and WD (vector / circular) to each level. + speeds = [_interpolate_wind_data(heights, ws, wd, h)[0] for h in h_levels] + dirs = [_interpolate_wind_dir(heights, wd, h) for h in h_levels] + n_time = np.asarray(speeds[0]).shape[0] - # Handle TI interpolation - if "turbulence_intensity" not in wind_resource: - TI = 0.02 - else: - TI_data = np.array(wind_resource["turbulence_intensity"]["data"])[cases_idx] - for hh in sorted(np.append(list(hub_heights.values()), additional_heights)): - if hh in seen[len(speeds) :]: - continue - if heights: - ti_int = _interpolate_with_min(heights, TI_data, hh, min_val=0.02) - else: - ti_int = TI_data - TIs.append(ti_int) - TI = ti_int + # Interpolate TI to each level (floored), else a constant default. + if "turbulence_intensity" not in wind_resource: + ti_levels = [np.full(n_time, 0.02) for _ in h_levels] + else: + ti_data = _subset( + np.asarray(wind_resource["turbulence_intensity"]["data"]) + ) + ti_levels = [ + _interpolate_with_min(heights, ti_data, h, min_val=0.02) + for h in h_levels + ] data_vars = { "WS": (["h", "time"], np.array(speeds)), "WD": (["h", "time"], np.array(dirs)), - "TI": (["h", "time"], np.array(TIs)), - "P": 1, + "TI": (["h", "time"], np.array(ti_levels)), + "P": (["time"], np.ones(n_time) / n_time), } if "density" in wind_resource: - density_vals = np.array(wind_resource["density"]["data"])[cases_idx] + density_vals = _subset(np.asarray(wind_resource["density"]["data"])) density_dims = wind_resource["density"].get("dims", ["time"]) - if "wind_turbine" in density_dims: - density_vals = np.mean(density_vals, axis=1) - data_vars["Air_density"] = (["time"], density_vals) + if "height" in density_dims: + data_vars["Air_density"] = ( + ["h", "time"], + np.array( + [ + _interpolate_with_min(heights, density_vals, h, 0.0) + for h in h_levels + ] + ), + ) + elif "wind_turbine" in density_dims: + data_vars["Air_density"] = (["time"], np.mean(density_vals, axis=1)) + else: + data_vars["Air_density"] = (["time"], density_vals) + site = XRSite( xr.Dataset( data_vars=data_vars, - coords={"h": seen, "time": np.arange(len(times))}, + coords={"h": h_levels, "time": np.arange(n_time)}, ) ) + + # Reference inflow arrays (1-D over time). The height-indexed site + # is authoritative per turbine, so these only define the flow cases; + # use the turbine-averaged inflow for a representative reference. + turbine_types = system_dat["wind_farm"]["layouts"][0]["turbine_types"] + ordered_hh = list(hub_heights.values()) + level_of = {h: i for i, h in enumerate(h_levels)} + idx_per_turbine = [level_of[ordered_hh[t]] for t in turbine_types] + ws = np.mean([speeds[i] for i in idx_per_turbine], axis=0) + rads = np.deg2rad([dirs[i] for i in idx_per_turbine]) + wd = np.mod( + np.rad2deg( + np.arctan2( + np.mean(np.sin(rads), axis=0), np.mean(np.cos(rads), axis=0) + ) + ), + 360.0, + ) + TI = np.mean([ti_levels[i] for i in idx_per_turbine], axis=0) else: # Single turbine type - print(np.array(ws).shape, np.array(heights).shape) - if heights: + if has_heights: ws, wd = _interpolate_wind_data(heights, ws, wd, hh) - assert len(np.array(times)[cases_idx]) == len(ws) + assert len(_subset(np.asarray(times))) == len(ws) assert len(wd) == len(ws) if "wind_turbine" in ws_dims or "wind_turbine" in wd_dims: @@ -467,15 +537,15 @@ def get_resource_data(var_name): else: site = Hornsrev1Site() if "density" in wind_resource: - density_vals = np.array(wind_resource["density"]["data"])[cases_idx] + density_vals = _subset(np.asarray(wind_resource["density"]["data"])) site.ds["Air_density"] = (("time",), density_vals) # Handle TI if "turbulence_intensity" not in wind_resource: TI = 0.02 else: - TI = np.array(wind_resource["turbulence_intensity"]["data"])[cases_idx] - if heights: + TI = _subset(np.asarray(wind_resource["turbulence_intensity"]["data"])) + if has_heights: TI = interp1d(heights, TI, axis=1)(hh) return { @@ -490,27 +560,38 @@ def get_resource_data(var_name): } -def _construct_weibull_site(resource_dat, hub_heights, x_positions): +def _construct_weibull_site(resource_dat, hub_heights, x_positions, n_subsector=5): """Construct site from Weibull distribution data. Internal helper for construct_site(). - """ - from windIO import dict_to_netcdf + Parameters + ---------- + resource_dat : dict + Energy resource dictionary from windIO. + hub_heights : dict + Mapping of turbine type names to hub heights. + x_positions : list + Turbine x positions (for operating array sizing). + n_subsector : int + Number of sub-directions per wind direction sector. Higher values + smooth directional wake effects. Default 5 (matching pywasp). + """ wind_resource = resource_dat["wind_resource"] A = wind_resource["weibull_a"] k = wind_resource["weibull_k"] - wd = wind_resource["wind_direction"] - ws = wind_resource.get("wind_speed", np.arange(2, 30, 1)) + wd_raw = wind_resource["wind_direction"] + # --- Speedup computation ------------------------------------------------ # Handle turbine-specific Weibull if "wind_turbine" in wind_resource["sector_probability"]["dims"]: mean_ws = np.array(A["data"]) * gamma(1 + 1.0 / np.array(k["data"])) - max_mean = np.max(mean_ws, axis=0) + wt_axis = list(A["dims"]).index("wind_turbine") + max_mean = np.max(mean_ws, axis=wt_axis, keepdims=True) Speedup = mean_ws / max_mean wind_resource["Speedup"] = { - "dims": ["wind_turbine", "wd"], - "data": Speedup, + "dims": list(A["dims"]), + "data": Speedup.tolist(), } # Handle spatial Weibull @@ -523,21 +604,76 @@ def _construct_weibull_site(resource_dat, hub_heights, x_positions): "data": Speedup, } + # --- Flow case computation ----------------------------------------------- + # When wind_speed is absent from the windIO dict, WIFA computes optimal + # flow cases: a Speedup-adjusted ws range and sub-sector wd values. + # When wind_speed IS present, the user has chosen explicit flow cases + # and both ws and wd are used as-is. + ws = wind_resource.get("wind_speed", None) + if ws is None: + # -- Wind speed range from Weibull + Speedup -------------------------- + A_arr = np.asarray(A["data"], dtype=float) + k_arr = np.asarray(k["data"], dtype=float) + # Weibull inverse CDF at 99.9 %: ws = A * (-ln(0.001))^(1/k) + ws_999 = A_arr * (-np.log(0.001)) ** (1.0 / k_arr) + ws_max_local = float(np.max(ws_999)) + # Extend for speed-downs so the reference WS grid covers every + # turbine's distribution after Speedup scaling + if "Speedup" in wind_resource: + min_speedup = float(np.min(wind_resource["Speedup"]["data"])) + ws_max_ref = ws_max_local / max(min_speedup, 0.1) + else: + ws_max_ref = ws_max_local + # Start at the first nonzero bin: a ws=0 reference case carries zero + # energy for every model, but it is a degenerate flow case for the + # WeightedSum superposition (Zong), whose convection-velocity iteration + # divides by the convection speed and is undefined at zero wind speed. + # Including ws=0 silently corrupts the WeightedSum AEP (collapsing the + # apparent wake loss); dropping it is harmless for all other models. + ws = np.arange(0.5, np.ceil(ws_max_ref) + 0.5, 0.5) + + # -- Wind direction sub-sectors --------------------------------------- + # Strip 360° wrap-around before computing sub-sectors + wd_sectors = np.asarray(wd_raw, dtype=float) + if len(wd_sectors) > 1 and np.isclose(wd_sectors[-1], 360.0): + wd_sectors = wd_sectors[:-1] + if n_subsector > 1 and len(wd_sectors) >= 4: + n_sectors = len(wd_sectors) + sector_width = 360.0 / n_sectors + subsector_width = sector_width / n_subsector + offsets = np.linspace( + -sector_width / 2 + subsector_width / 2, + sector_width / 2 - subsector_width / 2, + n_subsector, + ) + wd = np.sort( + (wd_sectors[:, np.newaxis] + offsets[np.newaxis, :]).ravel() % 360 + ) + else: + wd = wd_sectors + else: + # Explicit wind_speed provided: use original wd as-is + wd = wd_raw + + # --- Site and TI -------------------------------------------------------- site = dict_to_site(wind_resource) - # Handle TI - site_ds = dict_to_netcdf(wind_resource) - if "x" in site_ds.turbulence_intensity.dims: - interpolated_ti = site_ds.turbulence_intensity.interp( - x=x_positions, y=x_positions - ) - if "height" in interpolated_ti.dims: - interpolated_ti = interpolated_ti.interp(height=hub_heights["0"]) - TI = np.array( - [interpolated_ti.isel(x=i, y=i).values for i in range(len(x_positions))] - ) + if "turbulence_intensity" in wind_resource: + # Reuse the dataset already materialized inside ``site`` instead of + # rebuilding it with a second dict_to_netcdf. dict_to_site renames + # turbulence_intensity -> TI and height -> h. + ti_da = site.ds["TI"] + if "x" in ti_da.dims: + interpolated_ti = ti_da.interp(x=x_positions, y=x_positions) + if "h" in interpolated_ti.dims: + interpolated_ti = interpolated_ti.interp(h=hub_heights["0"]) + TI = np.array( + [interpolated_ti.isel(x=i, y=i).values for i in range(len(x_positions))] + ) + else: + TI = wind_resource["turbulence_intensity"]["data"] else: - TI = wind_resource["turbulence_intensity"]["data"] + TI = 0.06 # default when TI is absent from wind resource return { "site": site, @@ -573,6 +709,33 @@ def _interpolate_wind_data(heights, ws, wd, target_height): return ws_int, wd_int +def _interpolate_wind_dir(heights, wd, target_height): + """Interpolate wind direction (degrees) to ``target_height``. + + Uses vector (sin/cos) interpolation so the 0/360 wrap-around is handled + correctly — plain linear interpolation of the raw degrees would average, + e.g., 350° and 10° to 180° instead of 0°. + """ + if heights is None: + return wd + rad = np.deg2rad(np.asarray(wd, dtype=float)) + try: + sin_i = interp1d(heights, np.sin(rad), axis=1, fill_value="extrapolate")( + target_height + ) + cos_i = interp1d(heights, np.cos(rad), axis=1, fill_value="extrapolate")( + target_height + ) + except ValueError: + sin_i = interp1d(heights, np.sin(rad).T, axis=1, fill_value="extrapolate")( + target_height + ) + cos_i = interp1d(heights, np.cos(rad).T, axis=1, fill_value="extrapolate")( + target_height + ) + return np.mod(np.rad2deg(np.arctan2(sin_i, cos_i)), 360.0) + + def _interpolate_with_min(heights, values, target_height, min_val=0.02): """Interpolate values to target height with minimum value clipping.""" try: @@ -589,40 +752,37 @@ def _interpolate_with_min(heights, values, target_height, min_val=0.02): ) -def configure_wake_model(system_dat, rotor_diameter, hub_height): +def configure_wake_model( + system_dat, + rotor_diameter, + hub_height, + resource_dat=None, + turbine_geometries=None, +): """Configure the wake model components based on system configuration. Args: system_dat: System data dictionary rotor_diameter: Rotor diameter for FUGA LUT generation hub_height: Hub height for FUGA LUT generation + resource_dat: Optional energy_resource dict; lets FUGA derive its LUT + roughness/inversion height from the site (z0 from TI, zi from + ABL_height) instead of using defaults. + turbine_geometries: Optional list of (rotor_diameter, hub_height) for + every turbine type; FUGA builds one LUT set per geometry so mixed + farms interpolate over d_h. Defaults to the single + (rotor_diameter, hub_height). Returns: dict with keys: wake_model_class, deficit_args, deflection_model, turbulence_model, superposition_model, rotor_averaging, blockage_model, solver_class, solver_args """ - from py_wake.deficit_models import SelfSimilarityDeficit2020 - from py_wake.deficit_models.fuga import FugaDeficit - from py_wake.deficit_models.gaussian import ( - BastankhahGaussianDeficit, - BlondelSuperGaussianDeficit2020, - TurboGaussianDeficit, - ) - from py_wake.deficit_models.noj import NOJLocalDeficit - from py_wake.deflection_models import JimenezWakeDeflection - from py_wake.rotor_avg_models import GridRotorAvg, RotorCenter - from py_wake.superposition_models import LinearSum, SquaredSum - from py_wake.turbulence_models import ( - CrespoHernandez, - STF2005TurbulenceModel, - STF2017TurbulenceModel, - ) from py_wake.wind_farm_models import All2AllIterative, PropagateDownwind analysis = system_dat["attributes"]["analysis"] - # Get model configurations with defaults + # Resolve each submodel config, filling missing keys from DEFAULTS wind_deficit_data = get_with_default(analysis, "wind_deficit_model", DEFAULTS) deflection_data = get_with_default(analysis, "deflection_model", DEFAULTS) turbulence_data = get_with_default(analysis, "turbulence_model", DEFAULTS) @@ -630,35 +790,49 @@ def configure_wake_model(system_dat, rotor_diameter, hub_height): rotor_avg_data = get_with_default(analysis, "rotor_averaging", DEFAULTS) blockage_data = get_with_default(analysis, "blockage_model", DEFAULTS) - # Configure wind deficit model - deficit_args = {"use_effective_ws": True} - wake_deficit_key = None - - print("Running deficit ", wind_deficit_data) - - wake_model_class, deficit_args, wake_deficit_key = _configure_deficit_model( - wind_deficit_data, analysis, rotor_diameter, hub_height, deficit_args + wake_model_class, deficit_args, deficit_post_attrs = _configure_deficit_model( + wind_deficit_data, + analysis, + rotor_diameter, + hub_height, + resource_dat, + turbine_geometries, ) - - print("deficit args ", deficit_args) - - # Configure deflection model deflection_model = _configure_deflection_model(deflection_data) - - # Configure turbulence model turbulence_model = _configure_turbulence_model(turbulence_data) - - # Configure superposition model superposition_model = _configure_superposition_model(superposition_data) - print("using superposition ", superposition_data) - - # Configure rotor averaging rotor_averaging = _configure_rotor_averaging(rotor_avg_data) - - # Configure blockage model blockage_model = _configure_blockage_model(blockage_data, deficit_args) - # Determine solver based on blockage + # WeightedSum/CumulativeWakeSum impose two constraints that PyWake otherwise + # enforces via bare AssertionErrors deep in a run. Fail fast with actionable + # messages; windIO cannot express these cross-field constraints. + from py_wake.deficit_models.deficit_model import ConvectionDeficitModel + from py_wake.rotor_avg_models.rotor_avg_model import NodeRotorAvgModel + from py_wake.superposition_models import CumulativeWakeSum, WeightedSum + + if isinstance(superposition_model, (WeightedSum, CumulativeWakeSum)): + # 1. Requires a node-based rotor-averaging model, or None (rotor centre, + # which PyWake accepts; an explicit RotorCenter is rejected). + if rotor_averaging is not None and not isinstance( + rotor_averaging, NodeRotorAvgModel + ): + raise ValueError( + "WeightedSum/CumulativeWakeSum superposition requires a node " + "rotor-averaging model (grid/eq_grid/gq_grid/cgi) or 'none'; " + "center, gaussian_overlap and area_overlap are not node models." + ) + # 2. Requires a convection-based deficit (carries a convective velocity). + if not issubclass(wake_model_class, ConvectionDeficitModel): + raise ValueError( + f"WeightedSum/CumulativeWakeSum superposition requires a " + f"ConvectionDeficitModel-based deficit (e.g. Zong2020); " + f"'{wind_deficit_data['name']}' " + f"({wake_model_class.__name__}) is not one. SuperGaussian, " + f"SuperGaussian2023 and GCL do not support it — use Linear." + ) + + # Blockage requires All2AllIterative solver solver_args = {} if blockage_model is not None: solver_class = All2AllIterative @@ -669,7 +843,8 @@ def configure_wake_model(system_dat, rotor_diameter, hub_height): return { "wake_model_class": wake_model_class, "deficit_args": deficit_args, - "wake_deficit_key": wake_deficit_key, + "deficit_post_attrs": deficit_post_attrs, + "wake_deficit_key": None, # Deprecated: kept for API compatibility "deflection_model": deflection_model, "turbulence_model": turbulence_model, "superposition_model": superposition_model, @@ -680,103 +855,448 @@ def configure_wake_model(system_dat, rotor_diameter, hub_height): } +# --- Fuga LUT generation ----------------------------------------------------- +# Fuga is a linearised-RANS wake model that reads a precomputed look-up table +# (LUT). Historically those came from a Windows GUI; pyfuga (conda-forge, pure +# Python) now generates them, so WIFA can build a LUT on the fly for any farm. +# +# Fuga has NO turbulence-intensity input: ambient turbulence enters implicitly +# through the roughness z0 (which sets the neutral shear/mixing) and the +# stability zeta0. We therefore derive z0 from the site's representative TI via +# the same inversion PyWake uses at runtime (z0 = zref * exp(-1/TI), neutral), +# unless the windIO config or the resource supplies z0 directly. + + +def _fuga_default_lut_dir(): + """Persistent, shared LUT cache dir (override with $WIFA_FUGA_LUT_DIR). + + LUTs are content-addressed by filename, so a single shared dir is safe and + lets the expensive preLUT stage be reused across runs and farms. + """ + return Path( + os.environ.get( + "WIFA_FUGA_LUT_DIR", Path.home() / ".cache" / "wifa" / "fuga_luts" + ) + ) + + +def _resource_field_array(resource_dat, key): + """Finite values of a windIO wind_resource field (dict-with-'data' or array). + + Returns a 1-D numpy array, or None if the field is absent/empty. + """ + if resource_dat is None: + return None + field = resource_dat.get("wind_resource", {}).get(key) + if field is None: + return None + data = np.asarray(field["data"] if isinstance(field, dict) else field, dtype=float) + data = data[np.isfinite(data)].ravel() + return data if data.size else None + + +def _mean_resource_field(resource_dat, key): + """Finite mean of a windIO wind_resource field, or None.""" + data = _resource_field_array(resource_dat, key) + return float(np.mean(data)) if data is not None else None + + +def _fuga_atmosphere(resource_dat, fuga_cfg, hub_height): + """Resolve (z0, zi, zeta0, ti) for a Fuga LUT from config + site resource. + + Precedence for z0/zi: explicit fuga config > site resource field > default. + z0 is derived from the site TI (Fuga has no TI knob) when not given. + """ + from py_wake.utils import fuga_utils + + zeta0 = float(fuga_cfg.get("zeta0", 0.0)) + + zi = fuga_cfg.get("zi") + if zi is None: + zi = _mean_resource_field(resource_dat, "ABL_height") + if zi is None: + zi = 500.0 + + ti = _mean_resource_field(resource_dat, "turbulence_intensity") + z0 = fuga_cfg.get("z0") + if isinstance(z0, (list, tuple)): + # An explicit z0 list is a sweep, handled by _fuga_z0_sweep; the single + # scalar here is only a fallback, so don't treat the list as scalar. + z0 = None + if z0 is None: + z0 = _mean_resource_field(resource_dat, "z0") + if z0 is None and ti is not None and ti > 0: + z0 = float(np.ravel(fuga_utils.z0(ti, hub_height, zeta0))[0]) + if z0 is None: + z0 = 0.03 # open-farmland fallback if neither z0 nor TI is available + return float(z0), float(zi), zeta0, ti + + +def _fuga_z0_sweep(resource_dat, fuga_cfg, hub_height, zeta0, z0_single): + """z0 values for a TI-faithful multi-LUT, spanning the site TI distribution. + + Fuga reads TI off the LUT roughness, so a single mean-TI LUT evaluates the + wake at loss(mean TI) and misses the low-TI tail that drives the deepest + wakes. A sweep of LUTs across z0 lets FugaDeficit interpolate z0 = z0(TI) + per flow case at run time, so the farm loss is integrated over the TI + distribution instead of taken at its mean (cf. the GCL free-stream-TI gap). + + Returns a sorted list of distinct z0. Falls back to ``[z0_single]`` when TI + data is unavailable, z0 is pinned in config, or n_z0 <= 1. Out-of-range TI + is handled by FugaDeficit's bounds='limit' (clamped to the nearest LUT). + """ + from py_wake.utils import fuga_utils + + if fuga_cfg.get("z0") is not None: + z0s = fuga_cfg["z0"] + z0s = z0s if isinstance(z0s, (list, tuple)) else [z0s] + return sorted({float(z) for z in z0s}) + + n = int(fuga_cfg.get("n_z0", 5)) + ti = _resource_field_array(resource_dat, "turbulence_intensity") + if n <= 1 or ti is None: + return [z0_single] + ti = ti[ti > 0] + if ti.size == 0: + return [z0_single] + lo, hi = np.quantile( + ti, [fuga_cfg.get("ti_qlo", 0.05), fuga_cfg.get("ti_qhi", 0.95)] + ) + # Clamp to a TI band that keeps the neutral-inversion z0 physical: the + # mapping z0 = zhub*exp(-1/TI) sends high TI to absurd roughness (TI 0.30 -> + # z0 ~2.8 m, well outside Fuga's linearisation). The low-TI tail drives the + # deepest, most TI-sensitive wakes, so cover it; high-TI cases saturate to + # shallow wakes and clamp to the roughest LUT via bounds='limit'. Band + # [0.03, 0.18] keeps z0 in ~[1e-5, 0.3] m. + ti_lo = float(fuga_cfg.get("ti_min", 0.03)) + ti_hi = float(fuga_cfg.get("ti_max", 0.18)) + lo, hi = float(np.clip(lo, ti_lo, ti_hi)), float(np.clip(hi, ti_lo, ti_hi)) + + def _z0(ti_val): + return round(float(np.ravel(fuga_utils.z0(ti_val, hub_height, zeta0))[0]), 8) + + if hi <= lo: + # Whole TI distribution sits at/over a clamp bound -> a single LUT at + # the clamped TI (still physical), not the unclamped mean-TI z0. + return [_z0(hi)] + return sorted({_z0(t) for t in np.linspace(lo, hi, n)}) + + +def _ensure_fuga_luts( + *, folder, zeta0, nkz0, nbeta, geometries, z0_list, zi, lut_vars, nx, ny +): + """Generate/reuse a LUT for every (geometry, z0) pair; return the path list. + + All LUTs share the costly preLUT (which depends only on zeta0/nkz0/nbeta), + so extra z0 values and turbine geometries add only the cheap per-LUT stage. + FugaDeficit/FugaMultiLUTDeficit interpolate the resulting set over d_h + (turbine geometry) and z0 (per-flow-case TI). + """ + paths = [] + for diameter, zhub in geometries: + for z0 in z0_list: + paths.append( + _ensure_fuga_lut( + folder=folder, + zeta0=zeta0, + nkz0=nkz0, + nbeta=nbeta, + diameter=diameter, + zhub=zhub, + z0=z0, + zi=zi, + lut_vars=lut_vars, + nx=nx, + ny=ny, + ) + ) + return paths + + +def _ensure_fuga_lut( + *, folder, zeta0, nkz0, nbeta, diameter, zhub, z0, zi, lut_vars, nx, ny +): + """Generate (or reuse a cached) hub-height Fuga LUT; return its path. + + The LUT filename encodes every physical/grid parameter, so an existing file + with the right name is a valid cache hit. pyfuga reuses the costly preLUT + stage (which depends only on zeta0/nkz0/nbeta) across geometries. + """ + from pyfuga import get_luts + from pyfuga.paths import get_luts_path + + folder = Path(folder) + folder.mkdir(parents=True, exist_ok=True) + # pyfuga's own defaults; pass explicitly so the cache-probe path built by + # get_luts_path matches the filename get_luts actually writes. + dx, dy = diameter / 4, diameter / 16 + lut_vars = list(lut_vars) + # zlow == zhigh == zhub -> single hub-height level (the cheap path). + lut_path = get_luts_path( + folder, + zeta0, + nkz0, + nbeta, + diameter, + zhub, + z0, + zi, + zhub, + zhub, + lut_vars, + nx, + ny, + dx, + dy, + ) + if not lut_path.exists(): + get_luts( + folder=folder, + zeta0=zeta0, + nkz0=nkz0, + nbeta=nbeta, + diameter=diameter, + zhub=zhub, + z0=z0, + zi=zi, + zlow=zhub, + zhigh=zhub, + lut_vars=lut_vars, + nx=nx, + ny=ny, + dx=dx, + dy=dy, + ) + return str(lut_path) + + def _configure_deficit_model( - wind_deficit_data, analysis, rotor_diameter, hub_height, deficit_args + wind_deficit_data, + analysis, + rotor_diameter, + hub_height, + resource_dat=None, + turbine_geometries=None, ): """Configure the wind deficit model. Returns: - tuple: (wake_model_class, deficit_args, wake_deficit_key) + tuple: (wake_model_class, deficit_args, deficit_post_attrs) where + deficit_post_attrs is a dict of attributes to set on the built + deficit after construction (e.g. TurbOPark's WS_key). """ from py_wake.deficit_models.fuga import FugaDeficit from py_wake.deficit_models.gaussian import ( BastankhahGaussianDeficit, BlondelSuperGaussianDeficit2020, + BlondelSuperGaussianDeficit2023, + CarbajofuertesGaussianDeficit, + NiayifarGaussianDeficit, TurboGaussianDeficit, + ZongGaussianDeficit, ) - from py_wake.deficit_models.noj import NOJLocalDeficit + from py_wake.deficit_models.gcl import GCLDeficit + from py_wake.deficit_models.noj import NOJDeficit, NOJLocalDeficit, TurboNOJDeficit - wake_deficit_key = None model_name = wind_deficit_data["name"] + normalized = _normalize_name(model_name) + + wind_deficit_cfg = analysis.get("wind_deficit_model", {}) + # Honor the windIO use_effective_ws flag (local vs free-stream inflow at the + # waking turbine); deficits that don't accept it pop it below (NOJDeficit). + deficit_args = {"use_effective_ws": wind_deficit_cfg.get("use_effective_ws", True)} + wake_expansion = wind_deficit_cfg.get("wake_expansion_coefficient", {}) + + GAUSSIAN_MODELS = { + "bastankhah2014": BastankhahGaussianDeficit, + "niayifar2016": NiayifarGaussianDeficit, + "zong2020": ZongGaussianDeficit, + "carbajofuertes2018": CarbajofuertesGaussianDeficit, + } + # Models that accept a=[k_a, k_b] instead of k (scalar) + A_PARAM_MODELS = {"niayifar2016", "zong2020", "carbajofuertes2018"} + # Deficits that expose a use_effective_ti param (TI-dependent expansion/width). + # NOJLocalDeficit (Jensen) accepts it too: with a=[k_a, k_b] it references + # effective TI, so honoring free_stream_ti lets a no-turbulence config use + # ambient TI. GCLDeficit also accepts it (GCLLocal sets use_effective_ti=True). + # Bastankhah2014, free-stream NOJDeficit and FUGA do not. + TI_CAPABLE = { + "jensen", + "nojlocaldeficit", + "niayifar2016", + "carbajofuertes2018", + "zong2020", + "turbopark", + "gcl", + "supergaussian", + "supergaussian2023", + } - if model_name == "Jensen": + if normalized in ("jensen", "nojlocaldeficit"): wake_model_class = NOJLocalDeficit - wake_expansion = analysis.get("wind_deficit_model", {}).get( - "wake_expansion_coefficient", {} - ) - if "k_b" in wake_expansion: - k_a = wake_expansion.get("k_a", 0) - k_b = wake_expansion["k_b"] - deficit_args["a"] = [k_a, k_b] - - elif model_name.lower() == "bastankhah2014": - wake_model_class = BastankhahGaussianDeficit - wake_expansion = analysis.get("wind_deficit_model", {}).get( - "wake_expansion_coefficient", {} - ) if "k_b" in wake_expansion: - deficit_args["k"] = wake_expansion["k_b"] - elif "k" in wake_expansion: + deficit_args["a"] = [wake_expansion.get("k_a", 0), wake_expansion["k_b"]] + + elif normalized in ("jensen1983", "nojdeficit"): + wake_model_class = NOJDeficit + deficit_args.pop("use_effective_ws", None) + # NOJDeficit takes a scalar k. windIO's wake_expansion_coefficient has + # no scalar `k` field, so accept k_b (schema-valid) as well as `k`. + if "k" in wake_expansion: deficit_args["k"] = wake_expansion["k"] - if "ceps" in analysis.get("wind_deficit_model", {}): - deficit_args["ceps"] = analysis["wind_deficit_model"]["ceps"] + elif "k_b" in wake_expansion: + deficit_args["k"] = wake_expansion["k_b"] - elif model_name == "SuperGaussian": + elif normalized in GAUSSIAN_MODELS: + wake_model_class = GAUSSIAN_MODELS[normalized] + if normalized in A_PARAM_MODELS: + # Niayifar, Zong, Carbajofuertes use a=[k_a, k_b] + if "k" in wake_expansion: + warnings.warn( + f"{model_name} uses a=[k_a, k_b] for wake expansion, not scalar k. " + f"Scalar 'k' is ignored; specify k_a/k_b instead." + ) + if "k_b" in wake_expansion: + if "k_a" not in wake_expansion: + warnings.warn( + f"k_a not specified for {model_name}, defaulting to 0" + ) + deficit_args["a"] = [ + wake_expansion.get("k_a", 0), + wake_expansion["k_b"], + ] + else: + # Bastankhah2014 uses k (scalar) + if "k_b" in wake_expansion: + deficit_args["k"] = wake_expansion["k_b"] + elif "k" in wake_expansion: + deficit_args["k"] = wake_expansion["k"] + # ceps maps to the deficit's near-wake epsilon coefficient. Bastankhah, + # Niayifar and Carbajofuertes name it `ceps`; Zong names it `eps_coeff`. + if "ceps" in wind_deficit_cfg: + if normalized == "zong2020": + deficit_args["eps_coeff"] = wind_deficit_cfg["ceps"] + else: + deficit_args["ceps"] = wind_deficit_cfg["ceps"] + + elif normalized == "supergaussian": wake_model_class = BlondelSuperGaussianDeficit2020 - elif model_name == "TurbOPark": + elif normalized == "supergaussian2023": + wake_model_class = BlondelSuperGaussianDeficit2023 + + elif normalized == "turbopark": wake_model_class = TurboGaussianDeficit + # Canonical Nygaard (2022) recipe (py_wake.literature.turbopark): a Mirror + # ground model and ctlim=0.96 as constructor args; the WS_key='WS_jlk' + # attribute (scale the deficit by the downstream turbine's ambient WS) is + # applied post-construction via deficit_post below. + from py_wake.ground_models.ground_models import Mirror + from py_wake.superposition_models import SquaredSum - elif model_name.upper() == "FUGA": - wake_model_class = FugaDeficit - from pyfuga import get_luts - - lut = get_luts( - folder="luts", - zeta0=0, - nkz0=8, - nbeta=32, - diameter=rotor_diameter, - zhub=hub_height, - z0=0.00001, - zi=500, - zlow=70, - zhigh=70, - lut_vars=["UL"], - nx=2048, - ny=512, - n_cpu=1, + deficit_args["groundModel"] = Mirror(superpositionModel=SquaredSum()) + deficit_args["ctlim"] = 0.96 + + elif normalized == "turbonoj": + wake_model_class = TurboNOJDeficit + if "A" in wind_deficit_cfg: + deficit_args["A"] = wind_deficit_cfg["A"] + + elif normalized == "gcl": + wake_model_class = GCLDeficit + + elif normalized == "bastankhah2016": + raise NotImplementedError( + "Bastankhah2016 is not available in PyWake. Use flow_model 'foxes', " + "or choose Bastankhah2014/Zong2020 for PyWake." ) - deficit_args["LUT_path"] = ( - f"luts/LUTs_Zeta0=0.00e+00_8_32_D{rotor_diameter:.1f}_zhub{hub_height:.1f}" - f"_zi500_z0=0.00001000_z69.2-72.8_UL_nx2048_ny512_dx44.575_dy11.14375.nc" + + elif normalized == "fuga": + wake_model_class = FugaDeficit + # FugaDeficit reads a LUT instead of an analytic expansion; it takes no + # use_effective_ws (it always uses the free-stream-referenced deficit). + deficit_args.pop("use_effective_ws", None) + fuga_cfg = wind_deficit_cfg.get("fuga", {}) or {} + z0_single, zi, zeta0, _ti = _fuga_atmosphere(resource_dat, fuga_cfg, hub_height) + # A z0 sweep across the site TI distribution + a LUT per turbine + # geometry; FugaDeficit interpolates z0 (per-flow-case TI) and d_h at + # run time. Degenerates to a single LUT for one geometry + n_z0<=1. + z0_list = _fuga_z0_sweep(resource_dat, fuga_cfg, hub_height, zeta0, z0_single) + geometries = turbine_geometries or [(rotor_diameter, hub_height)] + lut_paths = _ensure_fuga_luts( + folder=fuga_cfg.get("cache_dir", _fuga_default_lut_dir()), + zeta0=zeta0, + nkz0=int(fuga_cfg.get("nkz0", 8)), + nbeta=int(fuga_cfg.get("nbeta", 32)), + geometries=geometries, + z0_list=z0_list, + zi=zi, + lut_vars=fuga_cfg.get("lut_vars", ["UL"]), + nx=int(fuga_cfg.get("nx", 2048)), + ny=int(fuga_cfg.get("ny", 512)), ) + # Single LUT -> plain path; multiple -> list (FugaDeficit globs/lists). + deficit_args["LUT_path"] = lut_paths[0] if len(lut_paths) == 1 else lut_paths else: raise NotImplementedError(f"Wake model '{model_name}' is not supported") - # Handle k/k2 format conversion - if "k2" in deficit_args: - k = deficit_args.pop("k") - k2 = deficit_args.pop("k2") - deficit_args["a"] = [k2, k] + # TI reference: windIO carries this as the nested wake_expansion_coefficient + # .free_stream_ti flag (foxes-compatible). PyWake's deficits expose the + # inverse use_effective_ti param (use_effective_ti = not free_stream_ti), + # but only the TI-dependent deficits accept it. + if normalized in TI_CAPABLE and "free_stream_ti" in wake_expansion: + deficit_args["use_effective_ti"] = not wake_expansion["free_stream_ti"] + + # Axial induction: windIO's axial_induction_model maps to PyWake's ct2a + # (1D -> ct2a_mom1d, Madsen -> ct2a_madsen). Honor it on every deficit that + # accepts a ct2a parameter; without this the deficit silently keeps its + # ct2a_madsen default, so a "1D" request was previously ignored on the + # pyWake path. + import inspect + + from py_wake.deficit_models.utils import ct2a_madsen, ct2a_mom1d + + axial = analysis.get("axial_induction_model") + if axial is not None: + ct2a_fn = {"1d": ct2a_mom1d, "madsen": ct2a_madsen}.get(_normalize_name(axial)) + if ( + ct2a_fn is not None + and "ct2a" in inspect.signature(wake_model_class.__init__).parameters + ): + deficit_args["ct2a"] = ct2a_fn + + # Attributes set on the deficit *after* construction (not constructor + # kwargs), applied by run_simulation. + deficit_post = {} + if normalized == "turbopark": + deficit_post["WS_key"] = "WS_jlk" - return wake_model_class, deficit_args, wake_deficit_key + return wake_model_class, deficit_args, deficit_post def _configure_deflection_model(deflection_data): """Configure the wake deflection model.""" from py_wake.deflection_models import JimenezWakeDeflection + from py_wake.deflection_models.gcl_hill_vortex import GCLHillDeflection + + name = deflection_data.get("name") + if name is None: + return None - name = deflection_data["name"].lower() - if name == "none": + normalized = _normalize_name(name) + if normalized == "none": return None - elif name == "jimenez": + if normalized == "jimenez": return JimenezWakeDeflection(beta=deflection_data["beta"]) - else: + if normalized == "gclhill": + return GCLHillDeflection() + if normalized == "bastankhah2016": raise NotImplementedError( - f"Deflection model '{deflection_data['name']}' is not supported" + "Bastankhah2016 deflection is not available in PyWake. Use flow_model " + "'foxes', or choose Jimenez/GCLHill for PyWake." ) + raise NotImplementedError(f"Deflection model '{name}' is not supported") def _configure_turbulence_model(turbulence_data): @@ -786,67 +1306,161 @@ def _configure_turbulence_model(turbulence_data): STF2005TurbulenceModel, STF2017TurbulenceModel, ) + from py_wake.turbulence_models.gcl_turb import GCLTurbulence + + name = turbulence_data.get("name") + if name is None: + return None - name = turbulence_data["name"].upper() - if turbulence_data["name"].lower() == "none": + normalized = _normalize_name(name) + if normalized == "none": return None - elif name == "STF2005": - return STF2005TurbulenceModel(c=[turbulence_data["c1"], turbulence_data["c2"]]) - elif name == "STF2017": - return STF2017TurbulenceModel(c=[turbulence_data["c1"], turbulence_data["c2"]]) - elif name == "CRESPOHERNANDEZ": + + STF_MODELS = { + "stf2005": STF2005TurbulenceModel, + "stf2017": STF2017TurbulenceModel, + "iecti2019": STF2017TurbulenceModel, + } + + if normalized in STF_MODELS: + c = [turbulence_data.get("c1", 1.0), turbulence_data.get("c2", 1.0)] + return STF_MODELS[normalized](c=c) + if normalized == "crespohernandez": + c = turbulence_data.get("c") + if c is not None: + # A paper's calibration (e.g. Niayifar 2016, Zong 2020): the + # literature CrespoHernandez uses 1D induction and SqrMaxSum + # added-turbulence summation alongside the calibrated coefficients. + from py_wake.deficit_models.utils import ct2a_mom1d + from py_wake.superposition_models import SqrMaxSum + + return CrespoHernandez( + c=list(c), + ct2a=ct2a_mom1d, + addedTurbulenceSuperpositionModel=SqrMaxSum(), + ) return CrespoHernandez() - else: - raise NotImplementedError( - f"Turbulence model '{turbulence_data['name']}' is not supported" - ) + if normalized == "gcl": + return GCLTurbulence() + raise NotImplementedError(f"Turbulence model '{name}' is not supported") def _configure_superposition_model(superposition_data): """Configure the superposition model.""" - from py_wake.superposition_models import LinearSum, SquaredSum + from py_wake.superposition_models import ( + CumulativeWakeSum, + LinearSum, + MaxSum, + SquaredSum, + WeightedSum, + ) - name = superposition_data["ws_superposition"].lower() - if name == "linear": - return LinearSum() - elif name == "squared": - return SquaredSum() - else: + name = superposition_data["ws_superposition"] + normalized = _normalize_name(name) + + SUPERPOSITION_MODELS = { + "linear": LinearSum, + "squared": SquaredSum, + "max": MaxSum, + "weighted": WeightedSum, + "cumulative": CumulativeWakeSum, + } + + if normalized in SUPERPOSITION_MODELS: + return SUPERPOSITION_MODELS[normalized]() + if normalized == "product": + raise NotImplementedError("Product superposition is not available in PyWake.") + if normalized == "vector": raise NotImplementedError( - f"Superposition model '{superposition_data['ws_superposition']}' is not supported" + "Vector superposition is foxes-only; not available in PyWake." ) + raise NotImplementedError(f"Superposition model '{name}' is not supported") def _configure_rotor_averaging(rotor_avg_data): """Configure the rotor averaging model.""" - from py_wake.rotor_avg_models import GridRotorAvg, RotorCenter + from py_wake.rotor_avg_models import ( + AreaOverlapAvgModel, + CGIRotorAvg, + EqGridRotorAvg, + GaussianOverlapAvgModel, + GQGridRotorAvg, + GridRotorAvg, + PolarGridRotorAvg, + RotorCenter, + ) - name = rotor_avg_data["name"].lower() - if name == "center": - print("Using Center Average") + name = rotor_avg_data["name"] + normalized = _normalize_name(name) + + if normalized == "none": + # No rotor-averaging model. PyWake's Weighted superposition accepts this + # (rotor centre) but rejects an explicit RotorCenter; the Zong (2020) + # literature model uses None. + return None + if normalized == "center": return RotorCenter() - elif name == "avg_deficit": + # "grid" is the canonical windIO name; "avgdeficit" is a deprecated alias + if normalized in ("grid", "avgdeficit"): return GridRotorAvg() - else: - raise NotImplementedError( - f"Rotor averaging model '{rotor_avg_data['name']}' is not supported" + if normalized == "gaussianoverlap": + return GaussianOverlapAvgModel() + if normalized == "areaoverlap": + return AreaOverlapAvgModel() + if normalized == "eqgrid": + return EqGridRotorAvg(n=rotor_avg_data.get("n", 4)) + if normalized == "gqgrid": + return GQGridRotorAvg( + n_x=rotor_avg_data.get("n_x_grid_points", 4), + n_y=rotor_avg_data.get("n_y_grid_points", 4), ) + if normalized == "polargrid": + return PolarGridRotorAvg() + if normalized == "cgi": + return CGIRotorAvg(n=rotor_avg_data.get("n", 4)) + raise NotImplementedError(f"Rotor averaging model '{name}' is not supported") def _configure_blockage_model(blockage_data, deficit_args): """Configure the blockage model.""" - from py_wake.deficit_models import SelfSimilarityDeficit2020 + from py_wake.deficit_models import ( + HybridInduction, + RankineHalfBody, + SelfSimilarityDeficit, + SelfSimilarityDeficit2020, + VortexCylinder, + VortexDipole, + ) from py_wake.deficit_models.fuga import FugaDeficit + from py_wake.deficit_models.rathmann import Rathmann name = blockage_data["name"] - if name == "None" or name is None: + if name is None: + return None + + normalized = _normalize_name(name) + if normalized == "none": return None - elif name == "SelfSimilarityDeficit2020": - return SelfSimilarityDeficit2020(ss_alpha=blockage_data["ss_alpha"]) - elif name.upper() == "FUGA": + + # Models that take no constructor arguments + SIMPLE_BLOCKAGE_MODELS = { + "selfsimilaritydeficit": SelfSimilarityDeficit, + "rankinehalfbody": RankineHalfBody, + "rathmann": Rathmann, + "vortexcylinder": VortexCylinder, + "vortexdipole": VortexDipole, + "hybridinduction": HybridInduction, + } + + if normalized == "selfsimilaritydeficit2020": + return SelfSimilarityDeficit2020( + ss_alpha=blockage_data.get("ss_alpha", 0.8888888888888888) + ) + if normalized in SIMPLE_BLOCKAGE_MODELS: + return SIMPLE_BLOCKAGE_MODELS[normalized]() + if normalized == "fuga": return FugaDeficit(deficit_args["LUT_path"]) - else: - raise ValueError(f"Unknown blockage model: {name}") + raise NotImplementedError(f"Blockage model '{name}' is not supported") def run_simulation(site, turbine, wake_config, site_data, x, y, turbine_types): @@ -864,16 +1478,17 @@ def run_simulation(site, turbine, wake_config, site_data, x, y, turbine_types): Returns: dict with keys: sim_res, aep, aep_per_turbine """ - # Build deficit model - print("Running ", wake_config["wake_model_class"], wake_config["deficit_args"]) + # Build deficit model. groundModel comes from deficit_args when a model needs + # a specific one (e.g. TurbOPark's Mirror); otherwise the deficit's own + # default (None) applies. + deficit_args = dict(wake_config["deficit_args"]) + deficit_args.setdefault("groundModel", None) deficit_model = wake_config["wake_model_class"]( rotorAvgModel=wake_config["rotor_averaging"], - groundModel=None, - **wake_config["deficit_args"], + **deficit_args, ) - - if wake_config["wake_deficit_key"]: - deficit_model.WS_key = wake_config["wake_deficit_key"] + for attr, value in wake_config.get("deficit_post_attrs", {}).items(): + setattr(deficit_model, attr, value) # Build wind farm model wind_farm_model = wake_config["solver_class"]( @@ -905,20 +1520,10 @@ def run_simulation(site, turbine, wake_config, site_data, x, y, turbine_types): # Run simulation sim_res = wind_farm_model(**sim_kwargs) - aep = sim_res.aep(normalize_probabilities=not site_data["timeseries"]).sum() - print("aep is ", aep, "GWh") - - # Calculate per-turbine AEP - if site_data["timeseries"]: - aep_per_turbine = ( - sim_res.aep(normalize_probabilities=True).sum(["time"]).to_numpy() - ) - else: - aep_per_turbine = ( - sim_res.aep(normalize_probabilities=True).sum(["ws", "wd"]).to_numpy() - ) - - print(sim_res) + is_timeseries = site_data["timeseries"] + aep = sim_res.aep(normalize_probabilities=not is_timeseries).sum() + sum_dims = ["time"] if is_timeseries else ["ws", "wd"] + aep_per_turbine = sim_res.aep(normalize_probabilities=True).sum(sum_dims).to_numpy() return {"sim_res": sim_res, "aep": aep, "aep_per_turbine": aep_per_turbine} @@ -938,9 +1543,8 @@ def generate_outputs(sim_results, system_dat, site_data, hub_heights, output_dir """ sim_res = sim_results["sim_res"] flow_bounds = site_data["flow_bounds"] - - # Ensure output directory exists - os.makedirs(output_dir, exist_ok=True) + output_path = Path(output_dir) + output_path.mkdir(parents=True, exist_ok=True) # Write turbine outputs if requested output_spec = system_dat["attributes"].get("model_outputs_specification", {}) @@ -948,20 +1552,17 @@ def generate_outputs(sim_results, system_dat, site_data, hub_heights, output_dir sim_res_formatted = sim_res[["Power", "WS_eff"]].rename( {"Power": "power", "WS_eff": "effective_wind_speed", "wt": "turbine"} ) - turbine_nc_filename = str( - output_spec.get("turbine_outputs", {}).get( - "turbine_nc_filename", "PowerTable.nc" - ) + turbine_nc_filename = output_spec["turbine_outputs"].get( + "turbine_nc_filename", "PowerTable.nc" ) - turbine_nc_filepath = Path(output_dir) / turbine_nc_filename - sim_res_formatted.to_netcdf(turbine_nc_filepath) + sim_res_formatted.to_netcdf(output_path / turbine_nc_filename) # Flow field handling flow_map = _generate_flow_field( sim_res, system_dat, site_data, hub_heights, flow_bounds ) - if flow_map: + if flow_map is not None: flow_map = flow_map[["WS_eff", "TI_eff"]].rename( { "h": "z", @@ -969,7 +1570,7 @@ def generate_outputs(sim_results, system_dat, site_data, hub_heights, output_dir "TI_eff": "turbulence_intensity", } ) - flow_map.to_netcdf(Path(output_dir) / "FarmFlow.nc") + flow_map.to_netcdf(output_path / "FarmFlow.nc") # Write YAML output _write_yaml_output(output_dir) @@ -984,70 +1585,52 @@ def _generate_flow_field(sim_res, system_dat, site_data, hub_heights, flow_bound Flow map xarray or None """ output_spec = system_dat["attributes"].get("model_outputs_specification", {}) - timeseries = site_data["timeseries"] - - WFXLB, WFXUB = flow_bounds["xlb"], flow_bounds["xub"] - WFYLB, WFYUB = flow_bounds["ylb"], flow_bounds["yub"] - WFDX, WFDY = flow_bounds["dx"], flow_bounds["dy"] + if "flow_field" not in output_spec: + return None - flow_map = None + x_range = np.arange( + flow_bounds["xlb"], flow_bounds["xub"] + flow_bounds["dx"], flow_bounds["dx"] + ) + y_range = np.arange( + flow_bounds["ylb"], flow_bounds["yub"] + flow_bounds["dy"], flow_bounds["dy"] + ) - if "flow_field" in output_spec and not timeseries: + if not site_data["timeseries"]: flow_map = sim_res.flow_box( - x=np.arange(WFXLB, WFXUB + WFDX, WFDX), - y=np.arange(WFYLB, WFYUB + WFDY, WFDY), + x=x_range, + y=y_range, h=list(hub_heights.values()), ) - # Warn if user requests unsupported outputs requested_vars = output_spec["flow_field"].get("output_variables", []) - if any( - var not in ["velocity_u", "turbulence_intensity"] for var in requested_vars - ): + unsupported = {"velocity_u", "turbulence_intensity"} + if any(var not in unsupported for var in requested_vars): warnings.warn("PyWake can only output velocity_u and turbulence_intensity") + return flow_map - elif "flow_field" in output_spec and timeseries: - flow_field_spec = output_spec["flow_field"] - if flow_field_spec.get("report") is not False: - z_list = flow_field_spec.get("z_list", sorted(list(hub_heights.values()))) - flow_map = sim_res.flow_box( - x=np.arange(WFXLB, WFXUB + WFDX, WFDX), - y=np.arange(WFYLB, WFYUB + WFDY, WFDY), - h=z_list, - time=sim_res.time.values, - ) + # Timeseries flow field + flow_field_spec = output_spec["flow_field"] + if flow_field_spec.get("report") is False: + return None - return flow_map + z_list = flow_field_spec.get("z_list", sorted(hub_heights.values())) + return sim_res.flow_box( + x=x_range, + y=y_range, + h=z_list, + time=sim_res.time.values, + ) def _write_yaml_output(output_dir): """Write the output YAML file with include directives.""" - data = { - "wind_energy_system": "INCLUDE_YAML_PLACEHOLDER", - "power_table": "INCLUDE_POWER_TABLE_PLACEHOLDER", - "flow_field": "INCLUDE_FLOW_FIELD_PLACEHOLDER", - } - - output_yaml_name = Path(output_dir) / "output.yaml" - with open(output_yaml_name, "w") as file: - yaml.dump(data, file, default_flow_style=False, allow_unicode=True) - - # Replace placeholders with include directives - with open(output_yaml_name, "r") as file: - yaml_content = file.read() - - yaml_content = yaml_content.replace( - "INCLUDE_YAML_PLACEHOLDER", "!include recorded_inputs.yaml" - ) - yaml_content = yaml_content.replace( - "INCLUDE_POWER_TABLE_PLACEHOLDER", "!include PowerTable.nc" + # Write directly with !include tags (avoids round-trip through yaml.dump) + content = ( + "flow_field: !include FarmFlow.nc\n" + "power_table: !include PowerTable.nc\n" + "wind_energy_system: !include recorded_inputs.yaml\n" ) - yaml_content = yaml_content.replace( - "INCLUDE_FLOW_FIELD_PLACEHOLDER", "!include FarmFlow.nc" - ) - - with open(output_yaml_name, "w") as file: - file.write(yaml_content) + (Path(output_dir) / "output.yaml").write_text(content) def run_pywake(yaml_input, output_dir="output"): @@ -1091,16 +1674,25 @@ def run_pywake(yaml_input, output_dir="output"): site = site_data["site"] # Step 4: Configure wake model - # Use first turbine's dimensions for FUGA LUT if needed - first_hh = list(hub_heights.values())[0] - # Get rotor diameter from farm data + # Collect every turbine geometry so FUGA can build a LUT set per type + # (mixed farms interpolate over d_h); the first one drives single-type paths. if "turbines" in farm_dat: - rd = farm_dat["turbines"]["rotor_diameter"] + geometries = [ + ( + farm_dat["turbines"]["rotor_diameter"], + farm_dat["turbines"]["hub_height"], + ) + ] else: - first_key = list(farm_dat["turbine_types"].keys())[0] - rd = farm_dat["turbine_types"][first_key]["rotor_diameter"] + geometries = [ + (t["rotor_diameter"], t["hub_height"]) + for t in farm_dat["turbine_types"].values() + ] + rd, first_hh = geometries[0] - wake_config = configure_wake_model(system_dat, rd, first_hh) + wake_config = configure_wake_model( + system_dat, rd, first_hh, resource_dat, geometries + ) # Step 5: Run simulation sim_results = run_simulation( diff --git a/wifa/wayve_api.py b/wifa/wayve_api.py index f731d19..83920bb 100644 --- a/wifa/wayve_api.py +++ b/wifa/wayve_api.py @@ -177,7 +177,7 @@ def run_wayve(yamlFile, output_dir="output", debug_mode=False): for time_index, time in enumerate(times): if debug_mode: # Print timestep - print(f"time {time_index+1}/{len(times)}") + print(f"time {time_index + 1}/{len(times)}") try: # Set up ABL abl = flow_io_abl(resource_dat["wind_resource"], time_index, hh, h1)