windIO interchange#
windIO is the IEA Wind Task 37 YAML
schema for wind energy systems. Farm.save, Turbine.save and
WindResource.save write the subset OOWL supports without the windIO package,
and oowl.load reads a document back as the object it describes.
Saved documents validate part by part against windIO 2.1.1. The exception is
exclusion zones, which that schema cannot express because it allows no keys
in an exclusion polygon, so OOWL writes them like boundary polygons. load
also reads documents split with !include, sites bounded by a circle and the
grid form of bathymetry from windIO’s development schema.
A surface is written into the document in windIO point form, one coordinate
pair and value per grid node. A large one is slow as text, so
save(..., surface_format="netcdf") writes it beside the document as a NetCDF
grid and includes it with !include. A surface of 10.9 million nodes then
saves and loads in under a second each, at 87 MB, where YAML takes tens of
seconds each, at 420 MB. load and
windIO both read it, but windIO’s schema does not accept an included grid for
bathymetry, so that site no longer validates. Other NetCDF includes are
refused, so convert those files with windIO first.
A document carries curves, not models. A reloaded turbine is always
Tabulated, and the model class, analytical parameters, cp and controller
diagnostics such as rotor speed are not saved. The persistence functions are
documented in the core API.
Documents are written to a temporary directory.
import tempfile
from dataclasses import replace
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
import yaml
import oowl
from oowl import boundaries as B
from oowl import layouts as L
from oowl import resources as R
from oowl import turbines as T
A farm to write#
The three 15 MW machines of Attaching turbines to a farm cycle over 72 positions.
idealized = T.Idealized(
rated_power=15e6,
rotor_diameter=240.0,
hub_height=150.0,
cp_max=0.46184,
ct_rated=0.7783,
generator_efficiency=0.95756,
cutin_wind_speed=3.0,
ct_idle=0.06,
ct_parked=0.18,
name="15 MW idealized",
)
controlled = T.Controlled(
rated_power=15e6,
rotor_diameter=240.0,
hub_height=150.0,
cp_max=0.46184,
optimal_tip_speed_ratio=9.0,
minimum_rotor_speed=5.0,
maximum_tip_speed=95.0,
ct_optimal=0.7783,
induction_exponent=0.05,
generator_efficiency=0.95756,
cutin_wind_speed=3.0,
name="15 MW controlled",
)
speeds = np.linspace(3.0, 25.0, 45)
tabulated = T.Tabulated(
rotor_diameter=240.0,
hub_height=150.0,
wind_speeds=speeds,
power_values=controlled.power(speeds),
ct_values=controlled.ct(speeds),
name="15 MW tabulated",
)
boundary = B.SmoothBlob(area=np.pi * 3000**2, roughness=0.2, seed=7)
layout = L.Grid(n_turbines=72, lattice_angle=60, seed=4)
resource = R.WindResource(direction=R.VonMises(n_modes=2, seed=8), n_sectors=12)
fleet = (idealized, controlled, tabulated)
assignment = tuple(fleet[index % len(fleet)] for index in range(layout.n_turbines))
scenario = oowl.Sampler(boundary, layout, resource, turbines=assignment, seed=11)
farm = scenario.sample()[0]
Saving a farm#
The document holds the boundary, coordinates, wind resource, a per-position type index and one definition per distinct turbine object, with hub height, rotor diameter and sampled power and \(C_T\) curves.
workspace = tempfile.TemporaryDirectory()
folder = Path(workspace.name)
path = farm.save(folder / "farm.yaml", name="OOWL example", crs="EPSG:32632")
document = yaml.safe_load(path.read_text())
definitions = document["wind_farm"]["turbine_types"]
per_position = document["wind_farm"]["layouts"]["turbine_types"]
print(f"{len(per_position)} position indices into {len(definitions)} definitions")
print(f"definition names: {[entry['name'] for entry in definitions.values()]}")
print(f"fields of a definition: {sorted(definitions['0'])}")
print(f"first twelve indices: {per_position[:12]}")
72 position indices into 3 definitions
definition names: ['15 MW idealized', '15 MW controlled', '15 MW tabulated']
fields of a definition: ['hub_height', 'name', 'performance', 'rotor_diameter']
first twelve indices: [0, 1, 2, 0, 1, 2, 0, 1, 2, 0, 1, 2]
Equal configuration is not enough. A copy of idealized per position gives
one definition per position.
copies = tuple(replace(idealized) for _ in range(layout.n_turbines))
scenario = oowl.Sampler(boundary, layout, resource, turbines=copies, seed=11)
copies_path = scenario.sample()[0].save(folder / "copies.yaml")
saved = yaml.safe_load(copies_path.read_text())
print(
f"{len(saved['wind_farm']['turbine_types'])} definitions for {layout.n_turbines} equal turbines"
)
72 definitions for 72 equal turbines
One document per object#
A farm with a boundary saves as a wind_energy_system by default and one
without as a wind_farm. document="wind_farm" saves the layout alone and
document="site" the boundary, resource and surface. A turbine saves as a turbine document and a resource as an
energy_resource, the file a site can !include. windIO requires a site to
carry an energy_resource, so a site or system without a resource gets a
labeled placeholder. A layout-only document loads as a farm without a
boundary.
bare = oowl.Sampler(boundary, L.Grid(n_turbines=12, seed=3), seed=5).sample()[0]
written = {
"wind_energy_system": farm.save(folder / "system.yaml"),
"wind_farm": bare.save(folder / "layout.yaml", document="wind_farm"),
"site": farm.save(folder / "site.yaml", document="site"),
"turbine": tabulated.save(folder / "turbine.yaml"),
"energy_resource": resource.save(folder / "resource.yaml"),
"resourceless system": bare.save(folder / "bare-system.yaml"),
}
for kind, document_path in written.items():
text = document_path.read_text()
keys = sorted(yaml.safe_load(text))
print(f"{kind:20} keys={keys} placeholder={'PLACEHOLDER' in text}")
layout_only = oowl.load(written["wind_farm"])
site_only = oowl.load(written["site"])
print(f"\nlayout only: {layout_only.n_turbines} turbines, {layout_only.boundary}")
print(f"site only: {site_only.n_turbines} turbines, {site_only.resource}")
print(f"turbine: {type(oowl.load(written['turbine'])).__name__}")
print(f"resource: {oowl.load(written['energy_resource'])}")
wind_energy_system keys=['name', 'site', 'wind_farm'] placeholder=False
wind_farm keys=['layouts', 'name'] placeholder=False
site keys=['boundaries', 'energy_resource', 'name'] placeholder=False
turbine keys=['hub_height', 'name', 'performance', 'rotor_diameter'] placeholder=False
energy_resource keys=['name', 'wind_resource'] placeholder=False
resourceless system keys=['name', 'site', 'wind_farm'] placeholder=True
layout only: 12 turbines, None
site only: 0 turbines, windIO resource(n_sectors=12, mean_wind_speed=9)
turbine: Tabulated
resource: windIO resource(n_sectors=12, mean_wind_speed=9)
Probability tables#
Other tools often give the resource as a probability table over direction and
speed. Each speed carries the cell up to the midpoints of its neighbors,
uniform within it, so the wind rose over those cells is the table. The
coverage design resources.Dimitrov has no Weibull and is written this way.
speeds = np.arange(3.0, 26.0)
table = np.outer([0.4, 0.1, 0.3, 0.2], np.exp(-(((speeds - 9.0) / 5.0) ** 2)))
table /= table.sum()
table_path = folder / "table.yaml"
table_path.write_text(
yaml.safe_dump(
{
"name": "tabulated site",
"wind_resource": {
"wind_direction": [0.0, 90.0, 180.0, 270.0],
"wind_speed": speeds.tolist(),
"probability": {
"data": table.tolist(),
"dims": ["wind_direction", "wind_speed"],
},
},
}
)
)
table_resource = oowl.load(table_path)
edges = np.concatenate([speeds[:1], 0.5 * (speeds[1:] + speeds[:-1]), speeds[-1:]])
rose = table_resource.wind_rose(edges, u_min=3.0, u_max=25.0)
print(f"rose equals the table: {np.allclose(rose, table)}")
table_resource.sample(512, seed=1, u_min=3.0, u_max=25.0).plot(
title="cases from a table"
);
rose equals the table: True
One speed for every direction#
The IEA Wind Task 37 case studies give each direction its share and one wind
speed for all, wind_speed: [9.8] with a probability over wind_direction
alone. The rose is that table and every case blows at that speed, so the cases
cover all time when the sampled range holds it and a range that leaves it out
cannot be sampled. The file saves back with its one-speed table.
rose_path = folder / "rose.yaml"
rose_path.write_text(
yaml.safe_dump(
{
"name": "one speed",
"wind_resource": {
"wind_direction": [0.0, 90.0, 180.0, 270.0],
"wind_speed": [9.8],
"probability": {
"data": [0.1, 0.2, 0.3, 0.4],
"dims": ["wind_direction"],
},
},
}
)
)
one_speed = oowl.load(rose_path)
cases = one_speed.sample(256, seed=1)
print(f"speeds drawn: {sorted(set(cases.wind_speed.tolist()))}")
print(f"share of time in 4 to 25 m/s: {cases.metadata['in_range_fraction']}")
try:
one_speed.sample(8, seed=1, u_min=10.0, u_max=25.0)
except ValueError as error:
print(f"10 to 25 m/s: {error}")
speeds drawn: [9.8]
share of time in 4 to 25 m/s: 1.0
10 to 25 m/s: wind-speed truncation range has zero probability
Time series#
A resource given as a time series of time, wind_direction and wind_speed
loads as a table of 12 sectors of 30° and 1 m/s speed bins centered on whole
m/s, from the one at or below the slowest step to the one at or above the
fastest, and saves back as that table. The lowest bin is centered on
1 m/s and also takes the calm steps below it, so a rose still shows them below
1.5 m/s. The saved table records no lower edge, so after reloading that bin
runs from 1 to 1.5 m/s.
Any other form, a resource mapped over x and y, given per wind_turbine or
varying with height, raises ValueError naming those fields. Only OOWL’s own
placeholder loads as no resource.
rng = np.random.default_rng(3)
hours = 24 * 7
series_path = folder / "series.yaml"
series_path.write_text(
yaml.safe_dump(
{
"name": "a week of hours",
"wind_resource": {
"time": [
f"2024-01-{1 + h // 24:02d}T{h % 24:02d}:00:00Z"
for h in range(hours)
],
"wind_direction": (
np.rad2deg(rng.vonmises(np.deg2rad(240.0), 2.0, hours)) % 360.0
).tolist(),
"wind_speed": (10.0 * rng.weibull(2.0, hours)).round(2).tolist(),
},
}
)
)
series = oowl.load(series_path)
saved = yaml.safe_load(series.save(folder / "series-table.yaml").read_text())
print(f"{series}, table speeds {saved['wind_resource']['wind_speed']}")
series.plot(title="a week of hours as a rose");
windIO resource(n_sectors=12, mean_wind_speed=8.11351), table speeds [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0, 13.0, 14.0, 15.0, 16.0, 17.0, 18.0, 19.0, 20.0, 21.0, 22.0, 23.0]
Reloading#
oowl.load returns an ordinary Farm with the same positions, boundary and
sector probabilities. Every turbine comes back as Tabulated, with power and
thrust kept and cp gone. Power and \(C_T\) curves given on different speeds are
merged onto the speeds of both, each reading zero outside its own range.
windIO is a common language for tools such as PyWake, not a record of how a
farm was made, so OOWL writes only what windIO has a place for. The
metadata, modifier history and string guide of a farm are left out, and a
Dimitrov design reloads as the WindResource of its table. OOWL’s own
files keep the rest, Cluster.save with a manifest of every farm’s metadata,
SiteConditions.save for sampled cases and Surface.save for a grid.
loaded = oowl.load(path)
same_boundary = loaded.boundary.geometry.equals_exact(farm.boundary.geometry, 1e-9)
same_rose = np.allclose(loaded.resource.probability, resource.probability)
print(f"positions preserved: {np.allclose(loaded.positions, farm.positions)}")
print(f"boundary preserved: {same_boundary}")
print(f"sector probabilities preserved: {same_rose}")
by_name = {turbine.name: turbine for turbine in loaded.turbines}
for name, turbine in sorted(by_name.items()):
knots, has_cp = len(turbine.wind_speeds), hasattr(turbine, "cp")
print(f"{name}: {type(turbine).__name__} with {knots} knots, cp {has_cp}")
positions preserved: True
boundary preserved: True
sector probabilities preserved: True
15 MW controlled: Tabulated with 55 knots, cp False
15 MW idealized: Tabulated with 55 knots, cp False
15 MW tabulated: Tabulated with 45 knots, cp False
Thrust survives in every state#
A tabulated curve reads zero outside its speeds, which would lose idle and
parked thrust. The exported curves therefore start at 0 m/s and add knots just
below cut-in, just above cut-out and at twice cut-out. The idealized turbine
keeps its ct_idle of 0.06 and ct_parked of 0.18 and the controlled turbine
its defaults. The tabulated turbine covers 3 to 25 m/s only, so it reads zero
outside that range before and after.
speeds = np.linspace(0.0, 35.0, 701)
fig, axes = plt.subplots(1, 3, figsize=(12.5, 3.6), sharey=True)
for ax, original in zip(axes, fleet):
reloaded = by_name[original.name]
ax.axvspan(0.0, 3.0, alpha=0.1)
ax.axvspan(25.0, speeds[-1], alpha=0.1)
ax.plot(speeds, original.ct(speeds), lw=4, alpha=0.35, label="saved")
ax.plot(speeds, reloaded.ct(speeds), lw=1.4, ls="--", label="reloaded")
ax.set(title=original.name, xlabel="wind speed [m/s]", xlim=(0.0, speeds[-1]))
ax.grid(True)
ax.spines[["top", "right"]].set_visible(False)
axes[0].set_ylabel("$C_T$")
axes[0].legend(frameon=False, fontsize=8, loc="center right")
fig.tight_layout()
workspace.cleanup()