Sampling#

A Sampler describes the scenarios you want. sample() returns n of them as a tuple and stream() yields them, both from index start. Building the sampler checks every fixed value, range, component type and recipe argument, and what a recipe, class or callable returns is checked as each scenario is drawn. Drawing twice gives the same scenarios. This page states the rules, and Describing a population of farms, Sampling with a density and Drawing, streaming and replaying a population work through examples.

Specifications#

Any value may be given as one of these forms, and the forms nest.

form

example

each scenario takes

a fixed value

n_turbines=60

that value

a (low, high) range

area=(30e6, 90e6)

a uniform draw, inclusive for whole numbers

a frozen scipy.stats distribution

stats.loguniform(0.03, 1.0)

a draw from it

a callable

lambda rng: rng.integers(40, 80)

what it returns for the scenario’s rng

a Choice

SP.Choice([L.Grid, L.PoissonDisc])

one option, by its probabilities or a prefix-stable schedule with selection="balanced"

a class or (constructor, kwargs) recipe

(B.SmoothBlob, {"roughness": (0.05, 0.5)})

an instance built from its drawn arguments

Literal(value)

SP.Literal(stats.norm())

the value unchanged

An argument whose name ends in _range, such as elevation_range, and the points and vectors center and gradient take their pair as given. A pair of ranges such as ((0, 10), (90, 110)) draws each end or coordinate. configuration overrides several values together, its "density" replacing the sampler’s whole, and may be a mapping, a Choice of mappings or a callable, so a Choice of configurations can vary which density a scenario takes. factory(rng) -> Farm replaces generation entirely and draws every random value from rng, seeds included.

import oowl
from oowl import boundaries as B, layouts as L, sampling as SP

population = oowl.Sampler(
    boundary=SP.Choice([B.OffshorePolygon, (B.SmoothBlob, {"roughness": (0.05, 0.5)})]),
    layout=SP.Choice([L.Grid, L.PoissonDisc], probabilities=[0.7, 0.3]),
    n_turbines=(40, 120),
    area=(30e6, 90e6),
    seed=7,
)
farms = population.sample(100)

Scenario i is the same whether 1 or 1000 are drawn, so sample(n, start=i) shards a population. A component given its own seed stays the same in every scenario, and an unseeded one is drawn again from each scenario’s seed.

Sizing#

A farm’s geometry links three quantities, the turbine count n_turbines, the area in square meters and its density. The density is one slot, so it is one mapping whose key names the measure, as in density={"minimum_spacing": 800.0}, and whose value is any specification.

density key

unit

meaning

area_per_turbine

m²

area over count

power_density

W/m²

installed capacity over area, which needs turbines of one rated power

minimum_spacing

m

no pair closer than it, farm.minimum_spacing()

mean_spacing

m

the mean nearest-neighbor distance, farm.mean_spacing()

maximum_spacing

m

no turbine farther than it from its nearest neighbor, farm.maximum_spacing()

Give any two and the third follows. All three over-constrain the farm and are refused, so a spacing a farm of fixed count and area must clear is a requirement instead.

given

derived

how

n_turbines + area

density

whatever that count in that area produces

n_turbines + a spacing

area

the farm is sized so the spacing is met

area + a spacing

n_turbines

the layout fills the boundary with the most turbines that hold a minimum or mean, or the fewest that hold a maximum

n_turbines + area_per_turbine or power_density

area

the area from the count

area + area_per_turbine or power_density

n_turbines

the count from the area

A boundary given its own area, as B.Circle(area=6e7), fixes the area as area does, while one built without, as B.Circle(), takes whatever size the other two give. A Polygon keeps the area of its coordinates and a Union that of its members. A layout’s own count or density stands in for the sampler’s. A spacing may be SP.RotorDiameters(7), which each scenario turns into meters with the largest rotor among its turbines.

Uniform and Sobol place turbines at random, so a minimum spacing sizes them by their single closest pair, and the area or count it gives varies widely between seeds, where a mean spacing is steady. A spacing is met by scaling the farm, or, for a boundary with a setback, gap or exclusion zone in meters, by redrawing it at other sizes until the spacing is met to a millionth, a minimum never from below and a maximum never from above. A layout that jumps as its site grows can stop short of that, and closest_pair records the spacing reached. With a minimum spacing, PoissonDisc, Grid, Circular, Contour, Sunflower, Perimeter, BoundaryGrid and Mixture fill the whole boundary in one pass, so a partitioned boundary keeps the spacing across its cells, and the other layouts fill each cell on its own. Layout modifiers run once on the filled farm, Relaxation and PositionNoise held to a minimum spacing. A spacing that fits fewer than two turbines gives a farm of the one that fits, recorded as metadata["fits_one_turbine"].

requested

recorded in metadata["sample"]

measured on the farm

n_turbines

n_turbines

farm.n_turbines

area

area, of the realized boundary

farm.boundary.area

area_per_turbine, power_density

the same names

minimum_spacing

minimum_spacing, and the achieved closest_pair

farm.minimum_spacing()

mean_spacing

mean_spacing

farm.mean_spacing()

maximum_spacing

maximum_spacing

farm.maximum_spacing()

Requirements#

requirements only checks each farm built and sizes nothing. Each key names a farm measure, and its value is the fixed (low, high) range the measure must lie in, None leaving an end open.

import itertools

sampler = oowl.Sampler(
    B.Circle(area=1.7e8),
    L.PoissonDisc(n_turbines=79),
    requirements={"spacing": (1_200.0, None), "coverage": (0.7, None)},
    on_error="skip",
    seed=1,
)
farms = list(itertools.islice(sampler.stream(), 100))

requirement

the range holds

measured by

spacing

every nearest-neighbor distance in meters, so the low end keeps pairs apart and the high end leaves no turbine alone

farm.minimum_spacing(), farm.maximum_spacing()

n_turbines

the turbine count, when an area and a density or a drawn range set it

farm.n_turbines

capacity

the installed capacity in watts, which needs turbines

farm.capacity()

coverage

the share of the boundary inside the convex hull of the turbines, about 0.85 for a full grid

farm.coverage()

aspect_ratio

how many times as far the turbine positions spread along their long axis as across it

metrics.aspect_ratio(farm)

A spacing end may be SP.RotorDiameters of a number. Every farm of a cluster or region is checked, and each records the ranges it met in metadata["sample"]["requirements"]. A farm of one turbine has no spacing to measure, and turbines in one line fail any aspect ratio. A range that runs downward or can never vary is refused when the sampler is built.

Scenarios that fail#

A scenario that cannot be built or misses a requirement raises a ValueError, and on_error decides what follows. A wrong kind of input raises a TypeError, which no mode retries or skips.

on_error

a scenario that raises

"raise"

stops the draw with the error, the default

"resample"

is drawn again, up to max_attempts attempts in all, the earlier errors in metadata["sample"]["errors"]

"skip"

is left out, while the others keep their indices

A layout given its own seed draws each retry from a seed derived from it, and farm.metadata["layout"]["seed"] holds the one that built the farm. With "skip", sample(n) holds the scenarios of indices start to start + n - 1 that could be built, an endless stream() yields only those, and 1,000 skipped in a row raise rather than stream forever.

Clusters and regions#

n_farms makes each scenario a Cluster, even n_farms=1, so a population never mixes farms and clusters, and domain= makes it a region of up to n_sites sites. gap is the clearance farms keep as they are placed, so cluster members are packed that far apart and region sites are scattered at least that far apart. A site that finds no room is replaced by another, and 12 in a row that find none end the fill. Top-level sizing values are refused for both, so size each member in its boundary or layout recipe or in a configuration. Multipart boundaries and clusters shows both, neighboring farms and sites scattered across a domain.

Sampling without a farm#

A sampler given only a resource or turbines, as oowl.Sampler(resource=(R.WindResource, {"mean_wind_speed": (6.0, 11.0)})), draws that component, the same one a farm of that index would carry.

Linking values#

A Link is a dial between 0 and 1 that several values read, so they move together, as Describing a population of farms shows. link((first, second)) runs from first to second as the dial rises, and scale="log", ("power", p) or a rising function change how it maps. link(SP.Choice(...)) splits the dial into bands sized by the probabilities. A link can follow another at a rank correlation. The dial turns once per scenario, or once per farm with per="farm", and selection="balanced" spreads it evenly over the population. A value every farm of a cluster shares, such as its resource or surface, reads a per-scenario link. Each scenario records its dials in metadata["sample"]["links"].

oowl.Sampler([boundary, layout, resource, ...])

A reproducible description of wind farm scenarios to draw from.

oowl.sampling.Choice(values[, ...])

A weighted choice among values, made separately for each index drawn.

oowl.sampling.Link(*[, follows, ...])

A dial that several sampled values read together.

oowl.sampling.Literal(value)

A value the sampler passes through unchanged rather than drawing from.

oowl.sampling.RotorDiameters(diameters)

A spacing given in rotor diameters, as RotorDiameters(7) for 7D.

class oowl.Sampler(boundary=None, layout=None, resource=None, *, configuration=None, n_turbines=None, area=None, density=None, turbines=None, surface=None, domain=None, gap=0.0, n_farms=None, n_sites=None, requirements=None, factory=None, on_error='raise', max_attempts=10, seed=None)[source]#

A reproducible description of wind farm scenarios to draw from.

Building the sampler checks every fixed value, range and component type, and the arguments of every recipe, so most mistakes raise before anything is drawn. What a recipe, class or callable returns is known only when it runs, so it is checked as each scenario is drawn. Any component may be a fixed value or a specification, meaning a (low, high) range, a frozen scipy.stats distribution such as stats.loguniform(0.03, 1.0), a callable taking one Generator, a Choice, a class, or a (constructor, kwargs) recipe. An argument whose name ends in _range, and the points and vectors center and gradient, take their pair as given, and a pair of ranges draws each end or coordinate. The points of exterior, holes, sites and coordinates, given as numbers, are taken as given too. A distribution is drawn once per scenario, so wrap it in Literal for an argument that takes the distribution itself. Literal passes any value through unresolved. A range or choice read from a Link moves with the other values on that link.

boundary and layout describe one farm. configuration is a mapping specification that varies boundary, layout, count, area and density together. It is resolved once per farm, or once per member of a cluster or region.

n_turbines is the turbine count and area the farm’s area in square meters. density is one mapping naming the farm’s density, {"area_per_turbine": square meters}, {"power_density": watts per square meter}, which needs turbines of one rated power, {"minimum_spacing": meters}, the closest pair, {"mean_spacing": meters}, the mean nearest-neighbor distance, or {"maximum_spacing": meters}, the loneliest turbine’s nearest-neighbor distance. Its value is any specification, and a spacing may also be RotorDiameters of the farm’s largest rotor. Give at most two of count, area and density and the third follows. A boundary given its own area fixes the area as area does. A layout’s own count or density stands in for the sampler’s.

requirements only checks each farm built and sizes nothing. Each key names a farm measure and maps to the fixed (low, high) range it must lie in, None leaving an end open. "spacing" holds every turbine’s distance to its nearest neighbor in meters, an end also given as RotorDiameters, so its low end keeps pairs apart even in a farm of fixed count and area, and its high end leaves no turbine alone. "n_turbines" holds a count an area and a density derive, "capacity" the installed capacity in watts, "coverage" the share of the boundary inside the convex hull of the turbines and "aspect_ratio" how many times as far the turbine positions spread along their long axis as across it. A farm that misses one raises a ValueError, which on_error handles, and each records the ranges it met in metadata["sample"]["requirements"].

resource, turbines and surface attach to every scenario. In a cluster turbines are drawn per farm, one resource is shared by the region and one surface covers the whole scenario.

By default a scenario is one Farm. Without boundary, layout and configuration it is the drawn resource or turbines alone, the same one a farm scenario of that index carries. n_farms makes it a Cluster of adjacent farms with clearance gap in meters. domain makes it a region scattered with sites described by boundary, added at least gap apart until n_sites are placed. A site that finds no room is replaced by another, and 12 in a row that find none end the fill. Each farm records its scenario in metadata["sample"]["index"] and, in a region, its site in metadata["group"].

factory is an alternative f(rng) -> Farm that excludes the declarative fields. It draws every random value from rng, seeds included, as L.Grid(n, seed=rng), since an unseeded component inside it draws a new seed each call. on_error="resample" draws a scenario that raises a ValueError again, up to max_attempts attempts in all, redrawing its random values and random choices, while balanced and seeded choices keep theirs. A layout given its own seed draws each retry from a seed derived from it and the attempt, so the retries replay, and the farm records the seed it was drawn from. on_error="skip" leaves out a scenario that raises a ValueError, so sample(n) and stream(n) hold the scenarios of indices start to start + n - 1 that could be built, each recording its index, and itertools.islice(sampler.stream(), n) gives n of them. After 1,000 scenarios in a row are skipped it raises a ValueError, as a description nothing meets would otherwise stream forever. seed is the root seed, and a generator or None is resolved to an integer once.

Parameters:
  • factory (Callable[[np.random.Generator], Farm] | None)

  • on_error (Literal['raise', 'resample', 'skip'])

  • max_attempts (int)

  • seed (int | np.random.Generator | None)

sample(n=1, *, start=0)[source]#

Draw n scenarios starting at index start and return a tuple.

Each scenario is a Farm, or a Cluster when the sampler has n_farms, a domain or one boundary per member. A sampler without a boundary and layout draws its resource, a WindResource or Dimitrov, or else its turbines, one Turbine or a tuple of them. Scenario i is the same however many are drawn, so repeated calls give the same result and start shards a population.

Parameters:
Return type:

tuple

stream(n=None, *, start=0)[source]#

Iterate over the scenarios sample() would return, one at a time.

n of None streams indefinitely from index start. n and start are checked when the stream is created.

Parameters:
Return type:

Iterator

class oowl.sampling.Choice(values, probabilities=None, *, selection='random', seed=None)[source]#

A weighted choice among values, made separately for each index drawn.

values may hold plain objects, constructors, recipes (constructor, {kwargs}), or mappings of farm configuration overrides. probabilities defaults to uniform. selection="random" draws each index independently, and "balanced" follows a schedule that keeps every prefix of a population close to the probabilities. seed fixes the selection whatever sampler draws it, and None follows the sampler.

Parameters:
choose(index=0, *, seed=None)[source]#

Return (position, value) of the member chosen for index.

seed roots the selection when the choice has no seed of its own. With neither, the selection draws fresh entropy.

Parameters:
  • index (int)

  • seed (int | None)

Return type:

tuple[int, Any]

A dial that several sampled values read together.

link(spec) returns a linked version of a (low, high) range or a Choice, usable wherever a range or a choice is. Values linked to one dial move together. A reversed range moves the other way.

A scenario that on_error="resample" retries turns its random dials again, as it redraws its other random values, while a balanced dial keeps its place in the population. A farm retried inside a cluster keeps the cluster’s dials, which turn again when the whole cluster is retried.

Parameters:
  • follows (Link | None) – Another link this dial follows, or None.

  • correlation (float | None) – Rank correlation with follows, in [-1, 1]. Requires follows. Defaults to 1, so the two dials turn together.

  • per (Literal['scenario', 'farm']) – "scenario" turns the dial once per scenario, so every farm of a cluster reads the same value. "farm" turns it once per farm. A value one cluster shares, its resource, surface, domain and farm or site count, has no farm to read the dial for, so it cannot read a per-farm link, and a per-scenario link cannot follow one.

  • selection (Literal['random', 'balanced']) – "random" draws the dial independently per scenario. "balanced" spreads it evenly over scenarios, so linked choices meet their probabilities and linked ranges are evenly covered.

class oowl.sampling.Literal(value)[source]#

A value the sampler passes through unchanged rather than drawing from.

Parameters:

value (Any)

class oowl.sampling.RotorDiameters(diameters)[source]#

A spacing given in rotor diameters, as RotorDiameters(7) for 7D.

A sampler turns it into meters with each scenario’s turbines, the largest rotor diameter among them, so a farm of a drawn turbine keeps its spacing in diameters. diameters is any scalar specification, such as a number or a (low, high) range.