Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions test/plant/test_mixed_types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
"""Functional coverage for mixed turbine types and per-turbine wind resources.

The windIO standard supports assigning different turbine types (and therefore
different hub heights) to different positions in a wind farm, and supports
wind-resource data dimensioned per turbine. These tests assert that those
structures parse and round-trip through windIO's public API, rather than only
being validated implicitly via the example-validation sweep.
"""

from pathlib import Path

import windIO


def _plant_examples_dir():
return Path(windIO.plant_ex.__file__).parent


def test_mixed_turbine_types_wind_farm():
"""A wind farm assigns >1 turbine type by per-position index, and the
referenced types have distinct hub heights (mixed hub heights)."""
farm_yaml = _plant_examples_dir() / "plant_wind_farm" / "multiple_types.yaml"

# Mixed types are a first-class windIO feature -> must validate.
windIO.validate(input=farm_yaml, schema_type="plant/wind_farm")

farm = windIO.load_yaml(farm_yaml)
layout = farm["layouts"][0]
type_idx = layout["turbine_types"]
n_positions = len(layout["coordinates"]["x"])

# A per-position type index referencing a multi-entry turbine_types map.
assert len(farm["turbine_types"]) >= 2
assert len(type_idx) == n_positions
assert set(type_idx) == set(farm["turbine_types"].keys())
# The example genuinely uses both types.
assert len(set(type_idx)) >= 2

# The assigned types have distinct hub heights -> mixed hub heights.
hub_heights = {k: t["hub_height"] for k, t in farm["turbine_types"].items()}
assert len(set(hub_heights.values())) >= 2


def test_per_turbine_wind_resource_roundtrip():
"""A wind resource dimensioned per turbine (one hub height each, no shared
vertical profile) round-trips through dict_to_netcdf with the
``wind_turbine`` dimension and per-turbine height preserved."""
res_yaml = _plant_examples_dir() / "plant_energy_resource" / "WTResource.yaml"

windIO.validate(input=res_yaml, schema_type="plant/energy_resource")

resource = windIO.load_yaml(res_yaml)
ds = windIO.dict_to_netcdf(resource["wind_resource"])

# Per-turbine resource: wind_turbine is a real dimension.
assert "wind_turbine" in ds.dims
assert ds.sizes["wind_turbine"] >= 2

# Height is given per turbine (not a shared scalar / vertical profile).
assert "height" in ds.variables
assert ds["height"].dims == ("wind_turbine",)
assert ds["height"].sizes["wind_turbine"] == ds.sizes["wind_turbine"]

# The resource's data variables carry the per-turbine dimension (here
# wind_speed / wind_direction are binned coordinate axes, so check the
# actual per-turbine fields).
per_turbine_vars = [
v for v in ds.data_vars if "wind_turbine" in ds[v].dims
]
assert per_turbine_vars, "no data variable carries the wind_turbine dimension"
assert "sector_probability" in per_turbine_vars
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ attributes:
ws_superposition: Linear
ti_superposition: Linear
rotor_averaging:
name: GQGrid
name: gq_grid
n_x_grid_points: 5
n_y_grid_points: 5
background_averaging: center
Expand Down
17 changes: 17 additions & 0 deletions windIO/schemas/plant/energy_resource.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,23 @@ properties:
$ref: "./common.yaml#/definitions/multi_dimensional_data"
ABL_height:
title: atmoshpheric boundary layer height
description: >-
Height of the centre of the capping inversion, i.e. the top of
the well-mixed boundary layer. Together with
capping_inversion_thickness it bounds the inversion layer
(bottom = ABL_height - thickness/2, top = ABL_height +
thickness/2). Distinct from boundary_layer_height, which is a
reanalysis surface diagnostic.
$ref: "./common.yaml#/definitions/multi_dimensional_data"
boundary_layer_height:
title: Reanalysis boundary-layer height (m)
description: >-
Boundary-layer height as diagnosed by a mesoscale model or
reanalysis (e.g. ERA5 blh). Used together with
friction_velocity and LMO to build the surface-driven
turbulence closure of a height-resolved wind resource. It is
a model diagnostic, not the fitted inversion height -- see
ABL_height.
$ref: "./common.yaml#/definitions/multi_dimensional_data"
capping_inversion_thickness:
title: Capping inversion thickness in units of meters
Expand Down
136 changes: 127 additions & 9 deletions windIO/schemas/plant/wind_energy_system.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,10 @@ properties:
type: string
enum: ["Jensen", "Jensen_1983", "Bastankhah2014", "Bastankhah2016", "Niayifar2016", "Zong2020",
"Carbajofuertes2018", "SuperGaussian", "SuperGaussian2023", "TurbOPark",
"TurboNOJ", "GCL", "FUGA"]
"TurboNOJ", "GCL", "FUGA", "EddyViscosity"]
wake_expansion_coefficient:
title: wake expansion coefficient
description: Wake expansion k = k_a + k_b * TI (k_a = constant expansion, k_b = coefficient on turbulence intensity).
type: object
properties:
k_a:
Expand All @@ -66,19 +67,81 @@ properties:
type: number # (default 0)
free_stream_ti:
title: Flag deciding to use freestream or waked TI
description: TI feeding the wake-expansion coefficient (k = k_a + k_b*TI) and TI-dependent deficits. False (default) = waked/effective TI; True = freestream TI.
type: boolean # (default to False)
ceps:
title: Bastankhah c_epsilon factor
type: number
use_effective_ws:
title: flag to use freestream wind speed for deficit computation
type: boolean
use_effective_ti:
title: flag to use effective turbulence intensity
title: flag to use local (effective) wind speed for deficit computation (True=waked, False=freestream)
type: boolean
A:
title: TurboNOJ wake expansion parameter
type: number
fuga:
title: FUGA LUT generation parameters
description: >-
Options for on-the-fly Fuga look-up-table generation (pyfuga).
Fuga has no turbulence-intensity input, so ambient turbulence
enters through the roughness z0 and stability zeta0. When z0 is
not given it is derived from the site TI. By default a sweep of
LUTs across the site TI distribution is generated so the wake
honors per-flow-case TI. All omitted fields fall back to WIFA
defaults.
type: object
properties:
z0:
title: Roughness length(s) (m)
description: Single roughness, or an explicit list of roughnesses for the LUT sweep. Overrides the TI-derived z0.
oneOf:
- type: number
- type: array
items:
type: number
zi:
title: Atmospheric inversion / boundary-layer height (m)
type: number
zeta0:
title: Monin-Obukhov stability parameter z0/L (0 = neutral)
type: number
n_z0:
title: Number of z0 LUTs in the TI sweep (1 = single mean-TI LUT)
type: integer
ti_min:
title: Lower TI clamp for the z0 sweep (keeps z0 physical)
type: number
ti_max:
title: Upper TI clamp for the z0 sweep (keeps z0 physical)
type: number
ti_qlo:
title: Lower TI quantile spanned by the sweep
type: number
ti_qhi:
title: Upper TI quantile spanned by the sweep
type: number
nkz0:
title: pyfuga spectral density (wavenumbers per log decade)
type: integer
nbeta:
title: pyfuga azimuthal resolution
type: integer
nx:
title: LUT streamwise grid points
type: integer
ny:
title: LUT cross-stream grid points
type: integer
lut_vars:
title: LUT field components to compute (e.g. ["UL"])
type: array
items:
type: string
cache_dir:
title: Directory for generated/cached LUTs
type: string
n_cpu:
title: Worker processes for LUT generation (1 = in-process)
type: integer

axial_induction_model:
title: axial induction model
Expand Down Expand Up @@ -107,18 +170,29 @@ properties:
name:
title: Turbulence model name
type: string
enum: ["STF2005", "STF2017", "IEC-TI-2019", "CrespoHernandez", "GCL", "None"]
enum: ["STF2005", "STF2017", "IEC-TI-2019", "CrespoHernandez", "GCL", "QuartonAndAinslie", "None"]
# Q: IEC versus Franson
# note: We assume same shape as wind deficit for now
coefficients:
title: coefficients
type: array
c0:
title: STF/IEC model coefficient 0
type: number
c1:
title: STF model coefficient 1
type: number
c2:
title: STF model coefficient 2
type: number
c:
title: CrespoHernandez coefficients
description: >-
Calibration coefficients [c0, c1, c2, c3] for the CrespoHernandez
added-turbulence model. When given, the model is built with these
coefficients (engine-specific), reproducing a paper's calibration
(e.g. Niayifar/Zong); when omitted the engine default is used.
type: array

superposition_model:
title: Superposition model
Expand All @@ -128,7 +202,7 @@ properties:
ws_superposition:
title: Speed superposition model name
type: string
enum: ["Linear", "Squared", "Max", "Product", "Weighted", "Cumulative"]
enum: ["Linear", "Squared", "Max", "Product", "Weighted", "Cumulative", "Vector"]
ti_superposition:
title: TI superposition model name
type: string
Expand All @@ -141,8 +215,20 @@ properties:
properties:
name:
title: Rotor averaging model name
description: >-
Engine-neutral: none (no rotor-averaging model — rotor centre,
the only non-node option 'Weighted' superposition accepts),
center, grid (regular rotor grid / GridRotorAvg),
eq_grid, gq_grid, polar_grid, cgi, gaussian_overlap, area_overlap,
simplified_gaussian (LUT-based line average, the Eddy Viscosity
bundle's companion).
gaussian_overlap/area_overlap are non-node overlap models — NOT compatible
with 'Weighted' superposition, which requires a node model (use grid) or
'none'. Capitalized names are deprecated aliases of the lowercase forms.
type: string
enum: ["Center", "Avg_Deficit", "EqGrid", "GQGrid", "PolarGrid", "CGI"]
enum: ["none", "center", "grid", "eq_grid", "gq_grid", "polar_grid", "cgi",
"gaussian_overlap", "area_overlap", "simplified_gaussian",
"Center", "Avg_Deficit", "EqGrid", "GQGrid", "PolarGrid", "CGI"]
n:
title: Number of grid or integration points
type: integer
Expand Down Expand Up @@ -186,6 +272,16 @@ properties:
ss_alpha:
title: ss_alpha factor for SelfSimilarityDeficit2020
type: number
ground_mirror:
title: Mirror the blockage model in the ground plane
description: Enforce the ground (slip) boundary condition on the
blockage deficit by adding an image rotor below ground
(method of images). Applies to the blockage model only; wake
deficit models are typically calibrated including ground
effects and must not be mirrored. Ignored for FUGA, whose
look-up tables already include the ground. Default false.
type: boolean
default: false

HPC_config:
title: Information to handle parallel runs on HPC clusters
Expand Down Expand Up @@ -243,7 +339,10 @@ properties:
title: height of the layer containing the farm
type: number
number_of_fa_layers:
title: number of layers in the free atmosphere
title: >-
number of layers in the free atmosphere; 1 requests a bulk
(uniform) free atmosphere, and a value <= 0 resolves it on
the vertical grid of the wind resource profiles
type: integer

APM_additional_terms:
Expand Down Expand Up @@ -295,6 +394,25 @@ properties:
title: APM grid filter length
type: number # (default 1.e3)

abl_setup:
title: settings for the ABL setup from height-resolved wind resources
description: how the atmospheric perturbation model derives its background ABL from a height-resolved wind resource with mesoscale surface scalars (see wayve's abl_setup.mesoscale_based); ignored by scalar and turbulence-profile inputs
type: object
properties:
Gmode:
title: geostrophic wind mode
description: how the free-atmosphere velocity is derived from the profiles — at the inversion center ("h1"), at the inversion top ("h2"), averaged between the inversion and 5 km ("avg", needs profiles reaching 5 km), or averaged to the tropopause ("trop", needs profiles reaching the stratosphere)
type: string
enum: ["h1", "h2", "avg", "trop"] # (default "avg")
dh_max:
title: maximum inversion-layer depth
description: maximum depth of the inversion layer used in the inversion curve fit
type: number
serz:
title: surface-extended Rampanelli-Zardi
description: whether the surface-extended version of the Rampanelli-Zardi inversion fit is used
type: boolean # (default True)

wm_coupling:
title: settings for wake model coupling in high-fidelity atmospheric models
type: object
Expand Down
36 changes: 32 additions & 4 deletions windIO/validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,30 @@
import copy
import jsonschema
import jsonschema.validators
import numpy as np

from .yaml import load_yaml
from .schemas import schemaPath, schema_validation_error_formatter


def _structure_skeleton(obj):
"""Return a copy of ``obj`` with numpy arrays replaced by ``[]``.

Used for structure-only validation of array-backed (memory-efficient)
inputs: jsonschema requires JSON types (it rejects numpy arrays and would
iterate every element of a large list). Replacing each array with an empty
list keeps the surrounding structure (keys, ``dims``) validatable at O(1)
per variable while skipping element-wise checks of the bulk data.
"""
if isinstance(obj, np.ndarray):
return []
if isinstance(obj, dict):
return {k: _structure_skeleton(v) for k, v in obj.items()}
if isinstance(obj, (list, tuple)):
return [_structure_skeleton(v) for v in obj]
return obj


def retrieve_yaml(uri: str):
if not uri.endswith(".yaml"):
raise NoSuchResource(ref=uri)
Expand Down Expand Up @@ -51,7 +70,8 @@ def _enforce_no_additional_properties(schema):


def validate(
input: dict | str | Path, schema_type: str, restrictive: bool = True, defaults: bool = False,
input: dict | str | Path, schema_type: str, restrictive: bool = True,
defaults: bool = False, array_data: bool = False,
) -> None:
"""
Validates a given windIO input based on the selected schema type.
Expand All @@ -65,8 +85,13 @@ def validate(
'turbine/turbine_schema'.
restrictive (bool, optional): If True, the schema will be modified to enforce
that no additional properties are allowed. Defaults to True.
defaults (bool, optional): If True, default values specified in the schema will
defaults (bool, optional): If True, default values specified in the schema will
be applied to the input data during validation. Defaults to False.
array_data (bool, optional): If True, validate structure only: numpy
arrays (from an array-backed ``!include`` netCDF, or an already
array-backed dict) are replaced by ``[]`` so jsonschema checks keys
and ``dims`` without materialising/iterating the bulk data. Avoids
the dict-of-lists memory blow-up for large resources. Defaults to False.

Raises:
FileNotFoundError: If the schema file corresponding to the schema type is not found.
Expand All @@ -84,9 +109,12 @@ def validate(
raise FileNotFoundError(f"Schema file {schema_file} not found.")

if type(input) is dict:
data = copy.deepcopy(input)
data = _structure_skeleton(input) if array_data else copy.deepcopy(input)
elif type(input) in [str, Path, PosixPath, WindowsPath]:
data = load_yaml(input)
if array_data:
data = _structure_skeleton(load_yaml(input, nc_data="array"))
else:
data = load_yaml(input)
else:
raise TypeError(f"Input type {type(input)} is not supported.")

Expand Down
Loading