Multipart boundaries and clusters#

n_farms on the sampler separates one farm in several parts from several farms.

import matplotlib.pyplot as plt
from shapely.geometry import Point

import oowl
from oowl import boundaries as B
from oowl import layouts as L
from oowl import modifiers as M
from oowl import sampling as SP

One farm in several parts#

Without n_farms, a divided boundary is one farm with one layout. The count is split deterministically across parts by area and sums to exactly \(N\). Metadata records each slice.

channelled = B.Rectangle(9, 5, area=45_000_000).modify(
    M.Partition.Channels(n_farms=3, gap=500, seed=3)
)
farm = oowl.Sampler(channelled, L.Grid(n_turbines=92, seed=7)).sample()[0]
allocation = [component["n_turbines"] for component in farm.metadata["components"]]

fig, ax = plt.subplots(figsize=(8, 4.5))
farm.plot(
    ax=ax,
    title=f"one channelled farm: N={farm.n_turbines}, allocation={allocation}",
)
fig.tight_layout()
../_images/65c890a5339dc0288236156cee270cdc43a1cb23978a44b1a904080882c1e771.png

Partition cells as farms#

With n_farms=3, a boundary of exactly three parts makes each part a farm with its own layout and exact count. Every multi-farm scenario is a Cluster that saves, loads and summarizes as a whole, and n_farms=1 also returns one. The partition methods are compared in Boundary modifiers.

partitioned = B.OffshorePolygon(area=45_000_000, seed=1).modify(
    M.Partition.Voronoi(
        n_farms=3, sites=[(-2200, -1500), (1800, -1200), (500, 2600)], gap=400
    )
)
cell_layouts = {
    "Grid": L.Grid(38, seed=1),
    "PoissonDisc": L.PoissonDisc(34, seed=2),
    "Delaunay": L.Delaunay(30, seed=3),
}
cluster = oowl.Sampler(partitioned, list(cell_layouts.values()), n_farms=3).sample()[0]

cells = ", ".join(
    f"{name} ({member.n_turbines})" for name, member in zip(cell_layouts, cluster.farms)
)

fig, ax = plt.subplots(figsize=(7, 6))
cluster.plot(
    ax=ax, s=18, title=f"Voronoi-partitioned cluster: N={cluster.n_turbines}\n{cells}"
)
fig.tight_layout()
../_images/4af47e76a41a6fbe9653b4cb1b692424c4b1ff23b276333309742e84b95420b3.png

Saving a cluster#

Cluster.save writes each farm as a windIO document, a system with its site where it has a boundary and a layout where it has none, and a manifest.json holding the cluster’s metadata and every farm’s. A resource or surface that several farms share is written once and included. Cluster.load reads them back with their partition cells, so the farms keep their metadata as well as their positions.

import tempfile
from pathlib import Path

with tempfile.TemporaryDirectory() as folder:
    directory = cluster.save(Path(folder) / "cluster")
    print("files:", sorted(path.name for path in directory.iterdir()))
    loaded = oowl.Cluster.load(directory)
kept = [
    (farm.positions == again.positions).all() and farm.metadata == again.metadata
    for farm, again in zip(cluster, loaded)
]
print(f"{loaded.n_farms} farms reloaded, positions and metadata kept: {kept}")
files: ['farm_0000.yaml', 'farm_0001.yaml', 'farm_0002.yaml', 'manifest.json']
3 farms reloaded, positions and metadata kept: [True, True, True]

Neighboring farms#

Boundaries without an explicit center are arranged automatically. Positioned ones stay put.

Boundary options are (constructor, {"area": ...}) recipes, built per farm so each farm gets its own shape. A pre-seeded instance in the Choice repeats one shape.

A scalar gap gives a compact cluster with one clearance. A (minimum, maximum) range draws each farm’s clearance from it, so the clearance varies and the minimum stays a hard limit.

boundary_choice = SP.Choice(
    [
        (B.OffshorePolygon, {"area": 24_000_000}),
        (B.SmoothBlob, {"area": 20_000_000, "roughness": 0.12}),
        (B.RandomRadialPolygon, {"area": 18_000_000}),
    ]
)
layout_choice = SP.Choice(
    [L.Grid(60), L.PoissonDisc(60), L.Delaunay(60)],
    probabilities=[0.5, 0.25, 0.25],
)
gaps = {"one 750 m clearance": 750, "clearances from 750 to 5,000 m": (750, 5000)}

fig, axes = plt.subplots(1, 2, figsize=(13, 5.5))
for ax, (label, gap) in zip(axes, gaps.items()):
    neighbors = oowl.Sampler(
        boundary_choice, layout_choice, n_farms=8, gap=gap, seed=7
    ).sample()[0]
    neighbors.plot(
        ax=ax,
        title=f"{label}\n{neighbors.n_farms} farms, {neighbors.n_turbines} turbines",
    )
fig.tight_layout()
../_images/e739e0849301c0f0d69d588f0326f04f5f9e14b2a777db397ac166924d263d14.png

Scattering sites across a domain#

A domain scatters farms within a region, gap meters apart, until n_sites are placed, here all 5. A site that finds no room is replaced by another, and 12 in a row that find none end the fill. An ExclusionZones modifier on the domain excludes whole farms. The layout count is the cluster total, shared across sites. The share of the domain developed is recorded as coverage. Plots draw the domain beneath the farms unless regions=False.

exclusion = Point(0.0, 0.0).buffer(6_000.0)
domain = B.Rectangle(2, 1, area=900_000_000).modify(M.ExclusionZones([exclusion]))
scattered = oowl.Sampler(
    (B.OffshorePolygon, {"area": 30_000_000}),
    L.Grid(40),
    domain=domain,
    n_sites=5,
    gap=2_000.0,
    seed=5,
).sample()[0]

placement = scattered.metadata["placement"]
print(f"{scattered.n_farms} farms carrying {scattered.n_turbines} turbines")
print(f"per farm: {[farm.n_turbines for farm in scattered]}")
print(f"share of the domain developed: {placement['coverage']:.1%}")
print(
    "no site inside the exclusion:",
    not any(farm.boundary.geometry.intersects(exclusion) for farm in scattered),
)

fig, ax = plt.subplots(figsize=(9.0, 4.8))
scattered.plot(ax=ax, s=8, title="sites scattered around an excluded area")
fig.tight_layout()
5 farms carrying 40 turbines
per farm: [8, 8, 8, 8, 8]
share of the domain developed: 19.1%
no site inside the exclusion: True
../_images/fe2c6d373e077442027fa2ff630bd3266ad596d42be09088371b44eb12bd57cc.png