Layout modifiers#

A modifier changes a finished layout without regenerating the pattern. It is attached with .modify() and applied in order after generation.

Relaxation, PositionNoise and TurbineRemoval act on generated turbine positions. Only TurbineRemoval drops turbines, and a layout given n_turbines places the ones it drops on top, so the count stays as asked. Breaks and Meander reshape a string guide instead and are covered in String modifiers.

import matplotlib.pyplot as plt

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


def show_moves(ax, before, after):
    # Unmodified positions in gray, joined to where each turbine went.
    ax.scatter(before.x, before.y, s=10, color="C7", zorder=4)
    for start, stop in zip(before.positions, after.positions):
        ax.plot([start[0], stop[0]], [start[1], stop[1]], color="C7", lw=0.6, zorder=3)

Relaxation#

M.Relaxation spreads turbines inside their boundary, keeping the count and the ordering. It ignores wind, power curves and wakes, so it is not a wake optimizer and promises no global optimum.

Let \(d_{ij}=\lVert\mathbf p_i-\mathbf p_j\rVert\) and let \(h\) be the characteristic spacing. Inverse-square "repulsion" uses

\[E_{ij}=\frac{h^2}{d_{ij}^2+\epsilon^2}.\]

Every pair interacts, so the pattern expands and turbines often reach the boundary. "gaussian" uses a short-ranged energy

\[E_G=\frac{1}{N}\sum_{i<j}\exp(-d_{ij}^2/h^2)+\frac{1}{2}\sum_{d_{ij}<h}(1-d_{ij}/h)^2+E_c, \qquad E_c=\frac{\lVert\bar{\mathbf p}-\mathbf c_B\rVert^2}{2h^2}.\]

The second term penalizes separations below \(h\). \(E_c\) pulls the layout centroid \(\bar{\mathbf p}\) toward the boundary centroid \(\mathbf c_B\), a translation that pair distances cannot see. With length_scale=None, \(h=\sqrt{A/N}\). Each turbine moves along its negative gradient by at most step times \(h\) per iteration, and backtracking shortens the step until the energy stops increasing. The relaxation stops once five steps lower the energy by less than the fraction tolerance. Inverse-square repulsion costs \(O(N^2)\) per iteration. The Gaussian energy only sums pairs within \(4h\), which hold all but \(e^{-16}\) of it, so its cost grows about linearly with \(N\).

offshore = B.OffshorePolygon(area=30_000_000, seed=7)
bases = {"Sunflower": L.Sunflower(70, seed=12), "Grid": L.Grid(70, seed=12)}
methods = {"original": None, "inverse-square": "repulsion", "gaussian": "gaussian"}

fig, axes = plt.subplots(2, 3, figsize=(13, 7.5))
for row, (name, base) in zip(axes, bases.items()):
    for ax, (label, method) in zip(row, methods.items()):
        layout = base if method is None else base.modify(M.Relaxation(method=method))
        farm = oowl.Sampler(offshore, layout).sample()[0]
        farm.plot(
            ax=ax,
            s=22,
            title=f"{name}: {label}\nclosest pair {farm.minimum_spacing():.0f} m",
        )
fig.tight_layout()
../_images/10d0cdf96702d3a5ce2a88047265b3a0ca0097c802031a08980ec7e738bce9e2.png

max_displacement caps the total distance any turbine may move, in meters, however many iterations run. The modifier record holds the method, resolved spacing, energies, iteration count, convergence state and realized displacement.

sunflower = bases["Sunflower"]
original = oowl.Sampler(offshore, sunflower).sample()[0]
limited = oowl.Sampler(
    offshore, sunflower.modify(M.Relaxation(max_displacement=200.0))
).sample()[0]

fig, ax = plt.subplots(figsize=(6, 5))
limited.plot(ax=ax, s=26, title="moves capped at 200 m")
show_moves(ax, original, limited)
fig.tight_layout()
limited.metadata["modifiers"][-1]
{'type': 'relaxation',
 'method': 'repulsion',
 'seed': 215076806960769446,
 'length_scale': 654.6536707079772,
 'iterations': 16,
 'maximum_iterations': 100,
 'step': 0.1,
 'tolerance': 0.001,
 'max_displacement': 200.0,
 'minimum_spacing': None,
 'maximum_displacement': 200.00000000000023,
 'initial_energy': 319.23274631771994,
 'final_energy': 278.7989496219323,
 'converged': True}
../_images/595f5272634a1b238f1836a3e09b1a11bec63f3ec0de0fed9fb16897c210dd53.png

A step whose path leaves the polygon slides to the nearest point of its edge, so turbines pressed against the boundary keep spreading along it. A step that would cross a hole or the gap between parts of a multipart boundary stops at the first crossing instead. A turbine therefore never enters an exclusion zone or moves between parts, and no route is searched around an obstacle. A single turbine has no pairs and stays put. minimum_spacing halves the moves of a pair that would come closer than it. A layout filled at a minimum_spacing relaxes the filled farm once and holds it to that spacing, so the fill keeps its count and its closest pair.

holed = B.Circle(area=40_000_000).modify(M.ExclusionZones([B.Circle(area=6_000_000)]))
channels = B.Rectangle(9, 5, area=45_000_000).modify(
    M.Partition.Channels(n_farms=3, gap=800, seed=3)
)
relax = M.Relaxation(max_iterations=200)

fig, axes = plt.subplots(1, 2, figsize=(11, 5))
for ax, (name, boundary, base) in zip(
    axes,
    [
        ("exclusion zone", holed, L.Sunflower(60, seed=3)),
        ("three components", channels, L.Sunflower(72, seed=5)),
    ],
):
    before = oowl.Sampler(boundary, base).sample()[0]
    after = oowl.Sampler(boundary, base.modify(relax)).sample()[0]
    after.plot(ax=ax, s=24, title=f"{name}: {after.n_turbines} turbines")
    show_moves(ax, before, after)
fig.tight_layout()
../_images/2fbd54d0139c295a9cd37b7b3180606f77abf83b987752f7a0dd6ed11351fffd.png

PositionNoise#

M.PositionNoise adds an independent Gaussian offset to every turbine. relative_standard_deviation is a fraction of the median nearest-neighbor spacing, so it scales with farm density. An offset that leaves the boundary part the turbine starts in or breaks an optional minimum_spacing is redrawn up to max_attempts times, and a turbine with no feasible offset stays put. On a farm with a string guide each turbine slides along its string, so it stays on the guide.

circle = B.Circle(area=40_000_000)
grid = L.Grid(80, seed=2)
quiet = oowl.Sampler(circle, grid).sample()[0]
noisy = oowl.Sampler(
    circle, grid.modify(M.PositionNoise(0.25, minimum_spacing=300.0, seed=9))
).sample()[0]
record = noisy.metadata["modifiers"][-1]

fig, ax = plt.subplots(figsize=(6, 5))
noisy.plot(
    ax=ax,
    s=24,
    title=(
        f"{record['moved']} of {noisy.n_turbines} moved, closest pair"
        f" {quiet.minimum_spacing():.0f} to {noisy.minimum_spacing():.0f} m"
    ),
)
show_moves(ax, quiet, noisy)
fig.tight_layout()
../_images/2213f671a0da759c812beec663f9e4df2c85dc39d58a8bbd3a23fb80b815d979.png

TurbineRemoval#

M.TurbineRemoval removes exactly n_remove turbines. A layout given n_turbines places n_remove more first, so exactly n_turbines remain, and a spacing fill keeps all but n_remove of the turbines it fits. A farm whose size is drawn, by a spacing or an area, may fit no more turbines than n_remove, which then raises, so relative_n_remove=0.1 removes a tenth of whatever the layout places, rounded down, and always leaves at least one. With n_turbines the layout places the fewest that leave that many. method="random" removes them uniformly. method="markov" walks the rows of a Grid and correlates consecutive decisions through transition_penalty, so removals clump into runs. Gray rings mark the positions placed before the removal, so empty rings are the removed ones.

removals = {
    "random": M.TurbineRemoval(24, seed=1),
    "markov": M.TurbineRemoval(24, method="markov", transition_penalty=2.5, seed=1),
}
# The same grid with the 24 removed turbines on top.
placed = oowl.Sampler(circle, L.Grid(grid.n_turbines + 24, seed=2)).sample()[0]

fig, axes = plt.subplots(1, 2, figsize=(11, 5))
for ax, (name, modifier) in zip(axes, removals.items()):
    thinned = oowl.Sampler(circle, grid.modify(modifier)).sample()[0]
    thinned.plot(
        ax=ax, s=24, title=f"{name}: {thinned.n_turbines} of {placed.n_turbines} kept"
    )
    ax.scatter(placed.x, placed.y, s=24, facecolor="none", edgecolor="C7", zorder=4)
fig.tight_layout()
../_images/a42649f30147751fbf65231a09fbf74cdc29c76cb62ac855fd5764822b3aae51.png

What each modifier preserves#

modifier

turbine count

ordering

Relaxation

preserved

preserved

PositionNoise

preserved

preserved

TurbineRemoval

n_turbines kept, the turbines it removes placed first

kept among the survivors

Each modifier appends a record with its effective seed to metadata["modifiers"], so a farm records what was done to it and the seeds that replay it identically. A windIO file saved from it keeps the positions, not this record.