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
../_images/6b40cbd2e2e6078859c36e5de6c656f97014e2cbbf75f5d49401ad76905a12b8.png

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]
../_images/e3f02697857412d49fe48a2446205c532ab810a0320ced8b5452fecde232425a.png

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()
../_images/292e12b79be790ee934e390f474e4c7ec8465222e56fe13eb888b83ccc4fc8fd.png