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
Every pair interacts, so the pattern expands and turbines often reach the
boundary. "gaussian" uses a short-ranged energy
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()
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}
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()
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()
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()
What each modifier preserves#
modifier |
turbine count |
ordering |
|---|---|---|
|
preserved |
preserved |
|
preserved |
preserved |
|
|
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.