Drawing, streaming and replaying a population#

Drawing never consumes a sampler. Concepts explains per-index seeds and what a scenario is, and Describing a population of farms explains the descriptions.

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 sampling as SP
from oowl import strings as S

The same population every time#

Scenario i takes a seed derived from the root and its index, so repeated draws agree and a short draw is the prefix of a long one.

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,
)
farms = simple.sample(12)
again, prefix = simple.sample(12), simple.sample(3)
assert all(np.array_equal(a.positions, b.positions) for a, b in zip(farms, again))
assert all(np.array_equal(a.positions, b.positions) for a, b in zip(prefix, farms))

Sharding and streaming#

start draws a later window without building the scenarios before it. stream yields what sample returns one at a time, stopping after n or never when n is omitted.

for farm in simple.sample(3, start=12):
    record = farm.metadata["sample"]
    print(record["index"], farm.n_turbines, record["seed"])

print(sum(farm.n_turbines for farm in simple.stream(5)), "turbines in five scenarios")

endless = simple.stream(start=1000)
print([next(endless).metadata["sample"]["index"] for _ in range(4)])
12 54 16534597274014820162
13 51 17053629545632478613
14 43 1229784763386802446
327 turbines in five scenarios
[1000, 1001, 1002, 1003]

The description decides what a scenario is#

n_farms makes each scenario a Cluster of that many farms, gap meters apart, and n still counts scenarios. A boundary that lists its own members needs no n_farms. Members share one coordinate frame and draw their own layout, so families can differ within a cluster.

clusters = oowl.Sampler(
    SP.Choice(
        [
            B.OffshorePolygon(area=24e6, seed=21),
            B.SmoothBlob(area=20e6, roughness=0.12, seed=22),
        ]
    ),
    SP.Choice([L.Grid(60), L.PoissonDisc(60)]),
    n_farms=3,
    gap=(600, 3000),
    seed=5,
)

fig, axes = plt.subplots(1, 3, figsize=(14, 4.5))
for ax, cluster in zip(axes, clusters.sample(3)):
    cluster.plot(ax=ax, s=14, title=f"{cluster.n_farms} farms, N={cluster.n_turbines}")
fig.tight_layout()
../_images/267a29251d1fcdfaee8055d440a853a939db72f4edec4e4d70cf2fdb0027e5a6.png

The scenario type follows from the description, so n_farms=1 still gives a Cluster. Top-level n_turbines, area and density size one farm, so a cluster sampler refuses them and each member boundary takes its own area. A built layout stands for the whole cluster, so its count is the total, shared out by area. A recipe, class or Choice is drawn per member, so its count is per farm.

blob = B.SmoothBlob(area=30e6)
oowl.Sampler(blob, L.Grid(100), n_farms=5)                    # 100 in total
oowl.Sampler(blob, (L.Grid, {"n_turbines": 100}), n_farms=5)  # 100 each
blob = B.SmoothBlob(area=30e6)
lone = oowl.Sampler(blob, L.Grid(40), n_farms=1, seed=3).sample()[0]
print(type(lone).__name__, lone.n_farms)

try:
    oowl.Sampler(blob, L.Grid, n_farms=3, n_turbines=60)
except ValueError as exc:
    print(exc)
Cluster 1
n_turbines sizes a single farm and cannot size a cluster. A built layout carries the cluster total, as in layout=L.Grid(100) with n_farms=5, and a layout recipe carries each farm's own count, as in layout=(L.Grid, {'n_turbines': 100})

In a cluster, configuration is resolved once per farm before the farms are placed, so each member sizes itself. Resources and surfaces stay shared by the whole cluster.

def member_configuration(rng):
    count = int(rng.integers(20, 41))
    return {
        "boundary": (B.Circle, {"area": count * 250_000.0}),
        "layout": (SP.Choice([L.Grid, L.Sunflower]), {"n_turbines": count}),
    }


configured = oowl.Sampler(
    configuration=member_configuration, n_farms=4, gap=600.0, seed=9
).sample()[0]
print([(farm.n_turbines, round(farm.boundary.area / 1e6, 1)) for farm in configured])
[(23, 5.8), (24, 6.0), (25, 6.2), (25, 6.2)]

Strings with or without a polygon boundary#

A standalone string layout uses its own spacing in meters and sets farm.boundary to None. The same layout also fits into a polygon, as in Describing a population of farms.

standalone = oowl.Sampler(
    boundary=None,
    layout=(
        L.String,
        {
            "strings": (
                S.Parallel,
                {"string": S.Straight, "n_strings": 5, "relative_spacing": 0.2},
            ),
            "density": {"minimum_spacing": 700.0},
        },
    ),
    n_turbines=40,
    seed=17,
)
standalone.sample()[0].plot(s=18);
../_images/cd90a09c6e91c9041604b5722f13ab6bb5c32e3b5e24c5696fbc9a2628402fa4.png

Replay#

An unseeded sampler still resolves a root seed, recorded under metadata["sample"]["root"] beside the index and seed. Rebuilding the description with that root replays the population.

def description(seed=None):
    return oowl.Sampler(
        SP.Choice([B.OffshorePolygon, B.SmoothBlob]),
        SP.Choice([L.Grid, L.PoissonDisc, L.Delaunay]),
        n_turbines=(40, 90),
        area=(30e6, 90e6),
        seed=seed,
    )


drawn = description().sample(3)
replayed = description(seed=drawn[0].metadata["sample"]["root"]).sample(3)
print(all(np.array_equal(a.positions, b.positions) for a, b in zip(drawn, replayed)))
True