Describing a population of farms#

A Sampler describes a population and is validated as it is built. Any component may be fixed or a specification, meaning a number, a (low, high) range, a frozen scipy.stats distribution, a callable taking one Generator, a Choice, a class, a (constructor, kwargs) recipe, or Literal(value) for data passed through unchanged. Drawing, streaming and replaying a population covers drawing, Sampling with a density sets a density such as the distance between turbines and Concepts explains what a scenario is.

import matplotlib.pyplot as plt
import numpy as np

import oowl
from oowl import boundaries as B
from oowl import layouts as L
from oowl import resources as R
from oowl import sampling as SP
from oowl import strings as S
from oowl import turbines as T

A simple specification#

boundary, layout and resource may be positional, and everything else is keyword-only. A class in a Choice is constructed fresh for each scenario with the drawn count and area. A draw returns a tuple of independent scenarios, and each title names the layout family.

simple = oowl.Sampler(
    SP.Choice([B.OffshorePolygon, B.SmoothBlob]),
    SP.Choice([L.Grid, L.PoissonDisc, L.Delaunay]),
    n_turbines=(40, 90),
    area=(30e6, 90e6),
    seed=7,
)

fig, axes = plt.subplots(3, 4, figsize=(14, 10))
for ax, farm in zip(axes.flat, simple.sample(12)):
    family = farm.metadata["sample"]["layout"]["algorithm"]
    farm.plot(ax=ax, s=14, title=f"{family}, N={farm.n_turbines}")
fig.tight_layout()
../_images/c17077df1103a26ec177283298a51c7eda85fb2fa830e6d32697f6afb469dec1.png

Nested specifications#

A Choice may be nested in another specification, so one boundary choice can serve several branches. Integer pairs are inclusive ranges, pairs containing floats are continuous, and a callable receives its own NumPy generator.

boundary = SP.Choice(
    [
        (B.OffshorePolygon, {"rotation": (0.0, 360.0)}),
        (
            B.SmoothBlob,
            {
                "roughness": (0.05, 0.45),
                "n_harmonics": lambda rng: rng.integers(2, 8),
            },
        ),
        (B.RandomRadialPolygon, {"n_vertices": lambda rng: rng.integers(5, 10)}),
    ],
    probabilities=[0.5, 0.3, 0.2],
)
lattice = SP.Choice(
    [
        (L.Grid, {"rotation": (0.0, 180.0)}),
        (L.Grid, {"lattice_angle": 60, "rotation": (0.0, 180.0)}),
    ]
)
string_layout = (
    L.String,
    {
        "strings": (
            S.Parallel,
            {
                "string": S.Straight,
                "n_strings": lambda rng: rng.integers(3, 8),
                "relative_spacing": (0.1, 0.3),
            },
        ),
    },
)

A recipe head may itself be a Choice. The constructor is chosen first and then given the shared arguments, which every option must accept. A center pair is a point rather than a range, as are the corners, sites and vertices given to exterior, holes, sites and coordinates.

shared_layout = (
    SP.Choice([L.Grid, L.PoissonDisc, L.Sunflower], selection="balanced"),
    {"n_turbines": (25, 45)},
)
chosen = oowl.Sampler(
    boundary=(
        B.Circle,
        {"area": 20e6, "center": (1000.0, 2000.0)},
    ),
    layout=shared_layout,
    seed=12,
).sample(3)
print([farm.metadata["sample"]["layout"]["algorithm"] for farm in chosen])
['Sunflower', 'Grid', 'PoissonDisc']

Values passed through with Literal#

A recipe’s constructor may be a function of one’s own, whose arguments the sampler cannot know. A pair given to one of them is a range to draw, so Literal passes it through as it is. A distribution an argument takes itself, rather than one value drawn from it, is wrapped the same way.

def lease(corner, width=4000.0, height=2500.0):
    """A rectangular lease from its south-west corner."""
    x, y = corner
    return B.Polygon([(x, y), (x + width, y), (x + width, y + height), (x, y + height)])


kept = oowl.Sampler(
    (lease, {"corner": SP.Literal((5e5, 6.2e6))}), L.Grid(12), seed=4
).sample()[0]
print("Literal corner:", kept.boundary.geometry.bounds[:2])
try:
    oowl.Sampler((lease, {"corner": (5e5, 6.2e6)}), L.Grid(12), seed=4).sample()
except (TypeError, ValueError) as error:
    print("a plain pair is a range:", error)
Literal corner: (500000.0, 6200000.0)
a plain pair is a range: could not construct lease: cannot unpack non-iterable float object

Correlating components with configuration#

configuration overrides boundary, layout, count, area, density, resource, turbines and surface together, a density replacing the sampler’s whole, so a large boundary carries many turbines and a compact one few. A Choice of mappings with selection="balanced" keeps every prefix of the population close to the requested proportions.

configurations = [
    {
        "boundary": boundary,
        "layout": lattice,
        "n_turbines": (70, 120),
        "area": (80e6, 160e6),
    },
    {
        "boundary": boundary,
        "layout": SP.Choice([L.PoissonDisc, L.Delaunay]),
        "n_turbines": (25, 45),
        "area": (18e6, 40e6),
    },
    {
        "boundary": boundary,
        "layout": string_layout,
        "n_turbines": (30, 60),
        "area": (25e6, 60e6),
    },
]
varied = oowl.Sampler(
    configuration=SP.Choice(
        configurations, probabilities=[0.4, 0.4, 0.2], selection="balanced"
    ),
    seed=101,
)
scenarios = varied.sample(8)

fig, axes = plt.subplots(2, 4, figsize=(15, 7.5))
for ax, farm in zip(axes.flat, scenarios):
    family = farm.metadata["sample"]["layout"]["algorithm"]
    farm.plot(ax=ax, s=14, title=f"{family}, N={farm.n_turbines}")
fig.tight_layout()
../_images/33126783f739d2529acad23dc62b91e930a8d7f53549a7375b132df8c9ba6c2c.png

metadata["sample"] holds each farm’s index and resolved components, and summary adds metrics. Count and area tend to rise together.

[
    {
        "index": farm.metadata["sample"]["index"],
        "layout": farm.metadata["sample"]["layout"]["algorithm"],
        **farm.summary(["n_turbines", "region_area"]),
    }
    for farm in scenarios
]
[{'index': 0,
  'layout': 'Delaunay',
  'n_turbines': 33,
  'region_area': 35200151.71515746},
 {'index': 1,
  'layout': 'Grid',
  'n_turbines': 79,
  'region_area': 119592122.97268836},
 {'index': 2,
  'layout': 'String',
  'n_turbines': 44,
  'region_area': 35198420.41582571},
 {'index': 3,
  'layout': 'PoissonDisc',
  'n_turbines': 42,
  'region_area': 19650888.86170616},
 {'index': 4,
  'layout': 'Grid',
  'n_turbines': 89,
  'region_area': 99495734.53156087},
 {'index': 5,
  'layout': 'Grid',
  'n_turbines': 119,
  'region_area': 112665491.76661433},
 {'index': 6,
  'layout': 'Delaunay',
  'n_turbines': 34,
  'region_area': 31314040.181794077},
 {'index': 7,
  'layout': 'String',
  'n_turbines': 60,
  'region_area': 49149061.43379717}]

Writing your own surroundings#

Any value may be a function of the random generator. Here each farm gets a coastline, with offshore sectors over a random arc of 90 to 180 degrees of wind direction and onshore sectors of random roughness elsewhere. Each sector has its own surface, so turbulence differs between the offshore and onshore sectors.

def coastline(rng):
    start, width = rng.uniform(0.0, 360.0), rng.uniform(90.0, 180.0)
    land = float(10 ** rng.uniform(-1.5, -0.5))
    offshore = (30.0 * np.arange(12) - start) % 360.0 < width
    return tuple(R.Charnock() if water else land for water in offshore)


coastal = oowl.Sampler(
    B.OffshorePolygon,
    L.Grid(40),
    resource=(R.WindResource, {"roughness_length": coastline}),
    area=40e6,
    seed=2,
).sample(3)

fig, ax = plt.subplots(figsize=(7.0, 3.6))
for index, farm in enumerate(coastal):
    resource = farm.resource
    ax.plot(
        resource.theta,
        resource.sector_turbulence_intensity,
        marker="o",
        label=f"farm {index}",
    )
ax.set_xticks([0.0, 90.0, 180.0, 270.0], ["N", "E", "S", "W"])
ax.set(xlabel="wind direction", ylabel="sector mean turbulence intensity [-]")
ax.legend(frameon=False)
fig.tight_layout()
../_images/420416240bdc1ee5f283cdbb114fa63028aed4fe287dc8e3cc25932a21a961fb.png