"""Modifiers of boundaries, string guides and generated layouts.
Layout modifiers change a finished farm and attach to a layout with
:meth:`~oowl.layouts.LayoutAlgorithm.modify`. Boundary and string modifiers
are re-exported so every modifier is available from one module.
"""
from __future__ import annotations
import math
import typing
from abc import ABC, abstractmethod
from collections.abc import Callable
from copy import copy
from functools import lru_cache
from itertools import chain
import numpy as np
import shapely
from ._random import pin_seed
from ._repr import _ArgumentRepr
from ._validation import (
_validate_integer,
_validate_nonnegative,
_validate_positive,
_validate_real,
)
from .boundaries.modifiers import BoundaryModifier, ExclusionZones, Setback
from .boundaries.partition import Partition
from .layouts.base import Farm, _median_spacing
from .strings.modifiers import Breaks, Meander, StringModifier
__all__ = [
"LayoutModifier",
"BoundaryModifier",
"StringModifier",
"ExclusionZones",
"Setback",
"Partition",
"Breaks",
"Meander",
"PositionNoise",
"Relaxation",
"TurbineRemoval",
]
[docs]
class LayoutModifier(_ArgumentRepr, ABC):
"""Transform a completed layout after its base algorithm has run."""
seed = None
def _placed_for(self, n_kept: int) -> int:
"""Turbines to place for ``n_kept`` to remain after this modifier.
A layout given ``n_turbines`` places them so that many remain.
"""
return n_kept
def _held_apart(self, spacing: float) -> "LayoutModifier":
"""This modifier, bringing no pair of turbines closer than ``spacing``.
A layout filled at a spacing runs its modifiers once on the filled
farm through this. A modifier that only removes turbines returns
itself, and one that moves them returns a copy held to ``spacing``.
A layout raises ``ValueError`` when a pair ends up closer anyway.
"""
return self
[docs]
@abstractmethod
def apply(self, layout: Farm, rng: np.random.Generator, seed: int) -> Farm:
"""Return a new ``Farm``, leaving ``layout`` untouched.
``rng`` is seeded from ``seed``, this modifier's effective seed.
Implementations append a record to ``metadata["modifiers"]``.
"""
def _own_pieces(layout: Farm) -> list | None:
"""The boundary polygon each turbine starts in, or ``None`` without one."""
if layout.boundary is None:
return None
geometry = layout.boundary.geometry
polygons = list(getattr(geometry, "geoms", [geometry]))
if len(polygons) == 1:
return polygons * len(layout.positions)
tree = shapely.STRtree(polygons)
points = shapely.points(layout.positions)
owners = [polygons[0]] * len(points)
for point, polygon in zip(*tree.query(points, predicate="intersects")):
owners[int(point)] = polygons[int(polygon)]
return owners
def _updated_ranges(metadata, kept):
"""Update contiguous multipart slices after subsetting positions."""
for key in ("components", "parts"):
if key not in metadata:
continue
records = []
new_start = 0
for original in metadata[key]:
record = dict(original)
old_start = int(record["start"])
old_stop = int(record["stop"])
count = int(np.count_nonzero((kept >= old_start) & (kept < old_stop)))
record["n_turbines"] = count
record["start"] = new_start
record["stop"] = new_start + count
records.append(record)
new_start += count
metadata[key] = records
if key == "parts" and "counts" in metadata:
metadata["counts"] = [record["n_turbines"] for record in records]
def _subset_layout(layout, kept, modifier_record):
kept = np.sort(np.asarray(kept, dtype=int))
metadata = dict(layout.metadata)
_updated_ranges(metadata, kept)
if "origin" in metadata:
metadata["origin"] = np.asarray(metadata["origin"])[kept].tolist()
metadata["achieved_n"] = int(len(kept))
modifiers = list(metadata.get("modifiers", []))
modifiers.append(modifier_record)
metadata["modifiers"] = modifiers
turbines = layout.turbines
if isinstance(turbines, tuple):
turbines = tuple(turbines[index] for index in kept)
return Farm(
layout.positions[kept],
layout.boundary,
metadata=metadata,
guide=layout.guide,
resource=layout.resource,
turbines=turbines,
surface=layout.surface,
)
def _grid_rows(positions, basis, row_axis):
"""Return lattice-ordered position indices and row-start indicators."""
positions = np.asarray(positions, dtype=float)
basis = np.asarray(basis, dtype=float)
if basis.shape != (2, 2) or not np.all(np.isfinite(basis)):
raise ValueError("grid metadata contains an invalid grid_basis")
try:
coordinates = (positions - positions[0]) @ np.linalg.inv(basis)
except np.linalg.LinAlgError as exc:
raise ValueError("grid metadata contains a singular grid_basis") from exc
lattice = np.rint(coordinates).astype(int)
if not np.allclose(coordinates, lattice, atol=1e-5, rtol=0.0):
raise ValueError("positions do not align with their recorded grid_basis")
other_axis = 1 - row_axis
order = np.lexsort((lattice[:, row_axis], lattice[:, other_axis]))
ordered_rows = lattice[order, other_axis]
row_starts = np.r_[True, ordered_rows[1:] != ordered_rows[:-1]]
return order, row_starts
def _markov_order(layout, row_axis):
"""Find row order for a single or multipart Grid result."""
return _recorded_rows(layout.metadata, layout.positions, row_axis)
def _recorded_rows(metadata, positions, row_axis):
"""Row order of ``positions`` laid out as ``metadata`` records.
A multipart record holds a component per part, and a partition cell in
several polygons is a multipart record of its own, so components nest.
Each component's ``start`` and ``stop`` index its parent's positions.
"""
if metadata.get("kind") == "single":
basis = metadata.get("fit", {}).get("grid_basis")
if basis is None:
raise ValueError("markov turbine removal requires a Grid")
return _grid_rows(positions, basis, row_axis)
if metadata.get("kind") == "multipart":
orders = []
starts = []
for component in metadata.get("components", []):
start, stop = int(component["start"]), int(component["stop"])
if stop <= start:
continue
order, row_starts = _recorded_rows(
component.get("layout") or {}, positions[start:stop], row_axis
)
orders.append(order + start)
starts.append(row_starts)
if orders:
return np.concatenate(orders), np.concatenate(starts)
raise ValueError("markov turbine removal requires a Grid")
def _sample_markov_mask(n_positions, n_remove, row_starts, penalty, rng):
"""Sample a removal mask with exactly ``n_remove`` removed positions.
Each mask has weight ``exp(-penalty * transitions)``, where transitions
count kept and removed neighbors within a row. Forward filtering then
backward sampling draws a mask exactly from that distribution.
"""
row_starts = np.asarray(row_starts, dtype=bool)
alpha = np.full((n_positions, n_remove + 1, 2), -np.inf)
alpha[0, 0, 0] = 0.0
if n_remove:
alpha[0, 1, 1] = 0.0
for index in range(1, n_positions):
previous = alpha[index - 1]
if row_starts[index]:
alpha[index, :, 0] = np.logaddexp(previous[:, 0], previous[:, 1])
alpha[index, 1:, 1] = np.logaddexp(previous[:-1, 0], previous[:-1, 1])
else:
alpha[index, :, 0] = np.logaddexp(previous[:, 0], previous[:, 1] - penalty)
alpha[index, 1:, 1] = np.logaddexp(
previous[:-1, 0] - penalty, previous[:-1, 1]
)
mask = np.zeros(n_positions, dtype=bool)
remaining = n_remove
logits = alpha[-1, remaining]
probabilities = np.exp(logits - np.max(logits))
probabilities /= probabilities.sum()
state = int(rng.choice(2, p=probabilities))
mask[-1] = bool(state)
for index in range(n_positions - 1, 0, -1):
previous_removed = remaining - state
logits = alpha[index - 1, previous_removed].copy()
if not row_starts[index]:
logits -= penalty * (np.arange(2) != state)
probabilities = np.exp(logits - np.max(logits))
probabilities /= probabilities.sum()
state = int(rng.choice(2, p=probabilities))
mask[index - 1] = bool(state)
remaining = previous_removed
return mask
[docs]
class TurbineRemoval(LayoutModifier):
"""Remove turbines randomly or in correlated grid rows.
``n_remove`` removes that many turbines, and a layout given
``n_turbines`` places them on top, so exactly ``n_turbines`` remain.
``relative_n_remove`` in ``[0, 1)`` removes that share of the turbines
the layout places, rounded down, so at least one always remains however
small a sampled farm turns out. A layout given ``n_turbines`` then places
the fewest turbines that leave exactly that many. Give one of the two.
``method="random"`` removes a uniform random subset. ``method="markov"``
needs a :class:`~oowl.layouts.Grid` layout and removes runs of neighbors
along its rows, which follow the first grid vector for ``row_axis=0`` and
the second for ``row_axis=1``. A larger ``transition_penalty`` gives
longer runs.
"""
def __init__(
self,
n_remove: int | None = None,
*,
relative_n_remove: float | None = None,
method: typing.Literal["random", "markov"] = "random",
transition_penalty: float | None = None,
row_axis: int = 0,
seed: int | np.random.Generator | None = None,
) -> None:
if (n_remove is None) == (relative_n_remove is None):
raise TypeError("give one of n_remove and relative_n_remove")
if n_remove is not None:
n_remove = _validate_integer("n_remove", n_remove, minimum=0)
if relative_n_remove is not None:
relative_n_remove = _validate_real("relative_n_remove", relative_n_remove)
if not 0.0 <= relative_n_remove < 1.0:
raise ValueError("relative_n_remove must be in [0, 1)")
self.n_remove = n_remove
self.relative_n_remove = relative_n_remove
if method not in ("random", "markov"):
raise ValueError("method must be 'random' or 'markov'")
if method == "markov" and transition_penalty is None:
raise ValueError("transition_penalty is required for method='markov'")
if transition_penalty is not None:
transition_penalty = _validate_nonnegative(
"transition_penalty", transition_penalty
)
if isinstance(row_axis, (bool, np.bool_)) or not isinstance(
row_axis, (int, float, np.integer, np.floating)
):
raise TypeError(f"row_axis must be 0 or 1, not {type(row_axis).__name__}")
if row_axis not in (0, 1):
raise ValueError("row_axis must be 0 or 1")
self.method = method
self.transition_penalty = transition_penalty
self.row_axis = int(row_axis)
self.seed = pin_seed(seed)
def _removed_from(self, n_placed: int) -> int:
"""Turbines this removes from a layout of ``n_placed``."""
share = self.relative_n_remove
if share is None:
return self.n_remove or 0
return math.floor(share * n_placed)
def _placed_for(self, n_kept: int) -> int:
share = self.relative_n_remove
if share is None:
return n_kept + (self.n_remove or 0)
# What remains grows by none or one turbine per turbine placed, so
# the fewest placed is found from the estimate by single steps.
placed = max(n_kept, math.ceil(n_kept / (1.0 - share)))
while placed - self._removed_from(placed) < n_kept:
placed += 1
while placed > n_kept and placed - 1 - self._removed_from(placed - 1) >= n_kept:
placed -= 1
return placed
[docs]
def apply(self, layout: Farm, rng: np.random.Generator, seed: int) -> Farm:
"""Return a new farm with the turbines this removes dropped.
Removal is uniform for ``method="random"`` and row-correlated for
``method="markov"``. Records the seed, method and turbine counts in
``metadata["modifiers"]`` and updates ``metadata["achieved_n"]``.
Raises ``ValueError`` if ``n_remove`` would leave no turbine.
"""
n_remove = self._removed_from(layout.n_turbines)
if n_remove >= layout.n_turbines:
raise ValueError(
f"n_remove={n_remove} must leave at least one of the "
f"{layout.n_turbines} turbines the layout placed, so remove "
"a share of them with relative_n_remove"
)
keep_count = layout.n_turbines - n_remove
record = {
"type": "turbine_removal",
"method": self.method,
"seed": int(seed),
"input_n": layout.n_turbines,
"removed_n": n_remove,
"output_n": keep_count,
}
if self.relative_n_remove is not None:
record["relative_n_remove"] = self.relative_n_remove
if self.method == "random":
kept = rng.choice(layout.n_turbines, size=keep_count, replace=False)
return _subset_layout(layout, kept, record)
order, row_starts = _markov_order(layout, self.row_axis)
removed = _sample_markov_mask(
layout.n_turbines,
n_remove,
row_starts,
self.transition_penalty,
rng,
)
kept = order[~removed]
transitions = int(
np.count_nonzero((removed[1:] != removed[:-1]) & ~row_starts[1:])
)
record.update(
transition_penalty=self.transition_penalty,
row_axis=self.row_axis,
row_transitions=transitions,
)
return _subset_layout(layout, kept, record)
[docs]
class PositionNoise(LayoutModifier):
"""Add boundary-constrained Gaussian turbine-position noise.
``relative_standard_deviation`` is a fraction of the layout's median
nearest-neighbor spacing, not a distance. When the layout carries a
:class:`~oowl.strings.StringGuide`, a turbine on it moves only along it,
so it stays on the string. Turbines off the guide, such as those a
:class:`~oowl.layouts.Mixture` takes from another source, move freely.
``minimum_spacing`` in meters rejects moves that come closer to another
turbine, and each turbine gets ``max_attempts`` draws.
"""
def __init__(
self,
relative_standard_deviation: float,
*,
minimum_spacing: float | None = None,
seed: int | np.random.Generator | None = None,
max_attempts: int = 100,
) -> None:
self.relative_standard_deviation = _validate_nonnegative(
"relative_standard_deviation", relative_standard_deviation
)
self.minimum_spacing = (
None
if minimum_spacing is None
else _validate_nonnegative("minimum_spacing", minimum_spacing)
)
self.seed = pin_seed(seed)
self.max_attempts = _validate_integer("max_attempts", max_attempts)
def _held_apart(self, spacing: float) -> "PositionNoise":
held = copy(self)
held.minimum_spacing = max(self.minimum_spacing or 0.0, spacing)
return held
@staticmethod
def _reference_spacing(layout: Farm) -> float:
"""Median nearest-neighbor spacing, else the spacing of the area."""
median = _median_spacing(layout.positions)
if median is not None:
return median
if layout.boundary is not None:
return float(np.sqrt(layout.boundary.area / max(layout.n_turbines, 1)))
return 1.0
[docs]
def apply(self, layout: Farm, rng: np.random.Generator, seed: int) -> Farm:
"""Return a new farm with Gaussian noise added to every position.
The noise has ``relative_standard_deviation`` times the median
nearest-neighbor spacing. Turbines move in order, each trying up to
``max_attempts`` draws against the current positions of the others.
A turbine with no boundary- and spacing-feasible candidate stays put.
A turbine never leaves the piece of the boundary it starts in. Raises
``ValueError`` when the input already violates ``minimum_spacing``.
"""
positions = np.asarray(layout.positions, dtype=float).copy()
# The margin covers rounding in the distances a fill measured.
if self.minimum_spacing is not None and layout.minimum_spacing() < (
self.minimum_spacing * (1.0 - 1e-9)
):
raise ValueError("layout does not satisfy the requested minimum_spacing")
reference_spacing = self._reference_spacing(layout)
sigma = self.relative_standard_deviation * reference_spacing
moved = 0
along_string = False
pieces = _own_pieces(layout)
if sigma > 0:
from .strings import StringGuide
n_turbines = len(positions)
on_string = np.zeros(n_turbines, dtype=bool)
guide = None
nearest = np.zeros(n_turbines, dtype=int)
distances = np.zeros(n_turbines)
if isinstance(layout.guide, StringGuide):
guide = _GuideLines(layout.guide.geometry)
nearest, distances, offsets = guide.project(positions)
# A mixture's turbines from other sources lie off the guide
# and take ordinary noise. Turbines on it differ from it by
# rounding only, at most a few coordinate units in the last
# place.
on_string = offsets <= max(
1e-9 * reference_spacing,
64.0 * float(np.spacing(np.abs(positions).max(initial=0.0))),
)
along_string = bool(on_string.any())
strings = (on_string, guide, nearest, distances)
moved = _jitter(
positions,
_NormalDraws(rng, sigma, 2 * n_turbines),
self.max_attempts,
pieces,
strings,
None
if self.minimum_spacing is None or n_turbines == 1
else _MovingNeighbors(positions, self.minimum_spacing),
)
metadata = dict(layout.metadata)
modifiers = list(metadata.get("modifiers", []))
modifiers.append(
dict(
type="position_noise",
seed=int(seed),
relative_standard_deviation=self.relative_standard_deviation,
minimum_spacing=self.minimum_spacing,
reference_spacing=reference_spacing,
moved=moved,
along_string=along_string,
)
)
metadata["modifiers"] = modifiers
return Farm(
positions,
layout.boundary,
metadata=metadata,
guide=layout.guide,
resource=layout.resource,
turbines=layout.turbines,
surface=layout.surface,
)
class _GuideLines:
"""The lines of a string guide, measured once for many points along them."""
def __init__(self, geometry) -> None:
self.lines = shapely.get_parts(geometry)
self.lengths = shapely.length(self.lines)
self._vertices = []
self._segments = []
self._starts = []
for line in self.lines:
vertices = shapely.get_coordinates(line)
steps = np.diff(vertices, axis=0)
segments = np.sqrt(steps[:, 0] * steps[:, 0] + steps[:, 1] * steps[:, 1])
self._vertices.append(vertices)
self._segments.append(segments)
self._starts.append(np.concatenate(([0.0], np.cumsum(segments))))
def project(self, positions):
"""Each position's nearest line, distance along it and offset from it.
The first line wins a tie.
"""
points = shapely.points(positions)
nearest = np.zeros(len(points), dtype=int)
distances = np.zeros(len(points))
offsets = np.full(len(points), np.inf)
for index, line in enumerate(self.lines):
along = shapely.line_locate_point(line, points)
gaps = self.points(index, along) - positions
offset = np.sqrt(gaps[:, 0] * gaps[:, 0] + gaps[:, 1] * gaps[:, 1])
closer = offset < offsets
nearest[closer] = index
distances[closer] = along[closer]
offsets[closer] = offset[closer]
return nearest, distances, offsets
def points(self, index, distances):
"""The points ``distances`` along line ``index`` from its start.
They match ``shapely.line_interpolate_point`` bit for bit. A distance
at most zero gives the start, and one past the end gives the end.
"""
vertices = self._vertices[index]
segments = self._segments[index]
starts = self._starts[index]
last = len(segments) - 1
segment = np.minimum(np.searchsorted(starts[1:], distances, side="right"), last)
first = vertices[segment]
second = vertices[segment + 1]
fraction = ((distances - starts[segment]) / segments[segment])[:, np.newaxis]
points = (second - first) * fraction + first
points = np.where(
fraction <= 0.0, first, np.where(fraction >= 1.0, second, points)
)
points[distances >= starts[-1]] = vertices[-1]
points[distances <= 0.0] = vertices[0]
return points
class _NormalDraws:
"""The ``rng.normal(scale=sigma)`` draws in order, read ahead in blocks.
Separate ``rng.normal`` calls of any size continue one stream, so
reading ahead and consuming only the draws used gives the values of one
call per draw.
"""
def __init__(self, rng: np.random.Generator, sigma: float, block: int) -> None:
self._rng = rng
self._sigma = sigma
self._block = block
self._buffer = np.empty(0)
self._start = 0
def peek(self, count: int) -> np.ndarray:
"""The next ``count`` draws, leaving them unconsumed."""
if self._start + count > len(self._buffer):
self._buffer = np.concatenate(
[
self._buffer[self._start :],
self._rng.normal(scale=self._sigma, size=max(count, self._block)),
]
)
self._start = 0
return self._buffer[self._start : self._start + count]
def consume(self, count: int) -> None:
"""Mark the next ``count`` draws used."""
self._start += count
class _MovingNeighbors:
"""Spacing tests against ``positions`` while its turbines move one by one.
A tree holds the starting positions, and ``reach`` bounds how far any
turbine has moved since, so a search ``reach`` wider finds every turbine
near a candidate.
"""
def __init__(self, positions: np.ndarray, spacing: float) -> None:
from scipy.spatial import cKDTree # ty: ignore[unresolved-import]
self._positions = positions
self._start = positions.copy()
self._tree = cKDTree(self._start)
self._spacing = spacing
self._reach = 0.0
def _search(self, length, reach):
# The margin keeps every turbine whose distance rounds near the spacing.
return (length + self._spacing + reach) * (1.0 + 1e-9)
def clear_attempts(self, index: int, candidates: np.ndarray) -> np.ndarray:
"""Which ``candidates`` for unmoved turbine ``index`` keep the spacing."""
center = self._start[index]
offsets = candidates - center
length = float(np.sqrt(np.max((offsets * offsets).sum(axis=1))))
near = np.asarray(
self._tree.query_ball_point(center, self._search(length, self._reach)),
dtype=int,
)
near = near[near != index]
differences = self._positions[near] - candidates[:, np.newaxis]
squared = (differences * differences).sum(axis=2)
return np.sqrt(squared.min(axis=1, initial=np.inf)) >= self._spacing
def clear_first(self, turbines: np.ndarray, candidates: np.ndarray) -> np.ndarray:
"""Which ``candidates`` of consecutive unmoved ``turbines`` keep the spacing.
Each turbine sees the earlier ones of ``turbines`` at their candidates.
"""
offsets = candidates - self._start[turbines]
lengths = np.sqrt((offsets * offsets).sum(axis=1))
found = self._tree.query_ball_point(
self._start[turbines],
self._search(lengths, max(self._reach, float(lengths.max()))),
return_sorted=False,
)
counts = np.fromiter(map(len, found), dtype=int, count=len(found))
near = np.fromiter(
chain.from_iterable(found), dtype=int, count=int(counts.sum())
)
rows = np.repeat(np.arange(len(turbines)), counts)
kept = near != turbines[rows]
near, rows = near[kept], rows[kept]
others = self._positions[near]
earlier = (near >= turbines[0]) & (near < turbines[rows])
others[earlier] = candidates[near[earlier] - turbines[0]]
differences = others - candidates[rows]
closest = np.full(len(candidates), np.inf)
np.minimum.at(closest, rows, (differences * differences).sum(axis=1))
return np.sqrt(closest) >= self._spacing
def moved(self, turbines: np.ndarray) -> None:
"""Widen the search after ``turbines`` moved."""
if len(turbines):
offsets = self._positions[turbines] - self._start[turbines]
self._reach = max(
self._reach, float(np.sqrt((offsets * offsets).sum(axis=1).max()))
)
def _jitter(positions, draws, max_attempts, pieces, strings, neighbors) -> int:
"""Move each turbine in order to its first feasible noisy candidate.
``strings`` holds which turbines move along the guide, its
:class:`_GuideLines` or ``None``, and each turbine's nearest line and
distance along it. Returns how many turbines moved. A block of turbines
tries its first draws at once, each seeing the earlier ones at their
candidates, which holds up to the first that fails. That turbine tries
its remaining draws alone, and the next block starts after it. While
first draws keep failing, each turbine tries all its draws alone.
"""
on_string, guide, nearest, distances = strings
widths = np.where(on_string, 1, 2)
def along_guide(turbines, noise):
"""Points ``noise`` along the guide from on-string ``turbines``."""
points = np.empty((len(turbines), 2))
for line in np.unique(nearest[turbines]):
rows = nearest[turbines] == line
points[rows] = guide.points(
line,
np.clip(
distances[turbines[rows]] + noise[rows],
0.0,
guide.lengths[line],
),
)
return points
if pieces is not None:
pieces = np.asarray(pieces, dtype=object)
shapely.prepare(pieces)
def trial(turbines, noise, starts):
"""Candidates for ``turbines`` from ``noise`` at ``starts``, and which fit."""
candidates = np.empty((len(turbines), 2))
free = ~on_string[turbines]
candidates[free] = positions[turbines[free]] + np.column_stack(
(noise[starts[free]], noise[starts[free] + 1])
)
along = on_string[turbines]
if along.any():
candidates[along] = along_guide(turbines[along], noise[starts[along]])
if pieces is None:
return candidates, np.ones(len(turbines), dtype=bool)
return candidates, shapely.intersects_xy(
pieces[turbines], candidates[:, 0], candidates[:, 1]
)
def alone(index, tried):
"""Try turbine ``index`` from draw ``tried`` on, giving the one it took.
Returns -1 when no draw fits and the turbine stays put.
"""
width = int(widths[index])
count = 16
while tried < max_attempts:
count = min(count, max_attempts - tried)
noise = draws.peek(count * width)
if width == 1:
candidates = along_guide(np.full(count, index), noise)
else:
candidates = positions[index] + noise.reshape(count, 2)
feasible = (
np.ones(count, dtype=bool)
if pieces is None
else shapely.intersects_xy(
pieces[index], candidates[:, 0], candidates[:, 1]
)
)
if neighbors is not None and feasible.any():
feasible[feasible] = neighbors.clear_attempts(
index, candidates[feasible]
)
accepted = np.flatnonzero(feasible)
if len(accepted):
first = int(accepted[0])
draws.consume((first + 1) * width)
positions[index] = candidates[first]
if neighbors is not None:
neighbors.moved(np.array([index]))
return tried + first
draws.consume(count * width)
tried += count
count *= 4
return -1
moved = 0
start = 0
block = 4
while start < len(positions):
if block < 2:
# First draws mostly fail, so turbines go alone until one fits.
taken = alone(start, 0)
moved += taken >= 0
block = 4 if taken == 0 else 0
start += 1
continue
turbines = np.arange(start, min(start + block, len(positions)))
ends = np.cumsum(widths[turbines])
candidates, feasible = trial(
turbines, draws.peek(int(ends[-1])), ends - widths[turbines]
)
if neighbors is not None:
feasible &= neighbors.clear_first(turbines, candidates)
failed = np.flatnonzero(~feasible)
run = int(failed[0]) if len(failed) else len(turbines)
positions[turbines[:run]] = candidates[:run]
if neighbors is not None:
neighbors.moved(turbines[:run])
moved += run
start += run
block = min(256, 2 * run)
if run == len(turbines):
draws.consume(int(ends[-1]))
continue
# The failed turbine's first draw is used up too.
draws.consume(int(ends[run]))
moved += alone(start, 1) >= 0
start += 1
return moved
def _zero_gradient(positions):
"""Return a callable giving the zero position gradient."""
return lambda: np.zeros_like(positions)
def _pair_gradient(first, second, pair_gradient, n_turbines):
"""Accumulate equal and opposite pair contributions at each turbine."""
return np.column_stack(
[
np.bincount(first, weights=pair_gradient[:, axis], minlength=n_turbines)
- np.bincount(second, weights=pair_gradient[:, axis], minlength=n_turbines)
for axis in range(2)
]
)
# Pairs farther apart than this many spacings hold less than exp(-16) of the
# Gaussian energy, so leaving them out changes it by about 1e-7.
_GAUSSIAN_RADIUS = 4.0
def _gaussian_energy(positions, spacing, rng):
"""Return the Gaussian and spacing pair energy and a gradient callable.
The Gaussian term covers the pairs within ``_GAUSSIAN_RADIUS`` spacings,
which one KD-tree query finds, and the spacing penalty those closer than
``spacing``. ``rng`` gives exactly coincident pairs a direction.
"""
n_turbines = len(positions)
if n_turbines < 2:
return 0.0, _zero_gradient(positions)
from scipy.spatial import cKDTree # ty: ignore[unresolved-import]
pairs = cKDTree(positions).query_pairs(
_GAUSSIAN_RADIUS * spacing, output_type="ndarray"
)
first, second = pairs[:, 0], pairs[:, 1]
differences = positions[first] - positions[second]
squared_distance = np.einsum("ij,ij->i", differences, differences)
gaussian = np.exp(-squared_distance / spacing**2)
close = np.flatnonzero(squared_distance < spacing**2)
distance = np.sqrt(squared_distance[close])
violation = 1.0 - distance / spacing
energy = float(np.sum(gaussian)) / n_turbines + 0.5 * float(np.sum(violation**2))
# Randomize direction for coincident pairs.
directions = np.empty((len(close), 2))
coincident = distance <= spacing * np.finfo(float).eps
distinct = ~coincident
directions[distinct] = differences[close[distinct]] / distance[distinct, None]
if np.any(coincident):
angles = rng.uniform(0.0, 2.0 * np.pi, int(np.count_nonzero(coincident)))
directions[coincident] = np.column_stack((np.cos(angles), np.sin(angles)))
def gradient():
"""Return the Gaussian and spacing gradient at ``positions``."""
scale = (-2.0 / (n_turbines * spacing**2)) * gaussian
pair_gradient = scale[:, None] * differences
pair_gradient[close] += (-violation / spacing)[:, None] * directions
return _pair_gradient(first, second, pair_gradient, n_turbines)
return energy, gradient
@lru_cache(maxsize=8)
def _pair_indices(n_turbines):
"""Read-only upper-triangle index pairs, shared by every energy evaluation."""
first, second = np.triu_indices(n_turbines, k=1)
first.setflags(write=False)
second.setflags(write=False)
return first, second
# Pairs per block. Block arrays stay in cache, so the cost per pair does not
# grow with the turbine count.
_PAIR_BLOCK = 32768
def _pair_squared_distances(positions, first, second):
"""Yield slices of the pairs ``first``, ``second`` with their squared distances.
Each block holds at most ``_PAIR_BLOCK`` pairs. Its squared distances are
a fresh array that callers may overwrite.
"""
# A complex view gathers both coordinates of a turbine at once.
points = np.ascontiguousarray(positions, dtype=float).view(np.complex128).ravel()
for start in range(0, len(first), _PAIR_BLOCK):
block = slice(start, start + _PAIR_BLOCK)
differences = points[first[block]]
differences -= points[second[block]]
squared_distance = differences.real * differences.real
squared_distance += differences.imag * differences.imag
yield block, squared_distance
def _row_blocks(n_turbines):
"""Yield row ranges with about ``_PAIR_BLOCK`` upper-triangle pairs each.
Ranges hold at least two rows, except a final range with a single pair,
which keeps the reductions in ``_all_pair_gradient`` off the fast axis.
"""
start = 0
while start < n_turbines - 1:
rows = max(2, _PAIR_BLOCK // (n_turbines - start))
stop = min(n_turbines - 1, start + rows)
yield start, stop
start = stop
def _all_pair_gradient(positions, pair_scale, coincident=None):
r"""Return :math:`\sum_{j>i} w_{ij} - \sum_{k<i} w_{ki}` at each turbine ``i``.
The pair terms are :math:`w_{ij}=s_{ij}(p_i-p_j)`, where
``pair_scale(start, stop, differences)`` returns the scales of rows
``start`` to ``stop`` against columns ``start + 1`` onward. An optional
``coincident`` holds turbine index pairs and the gradient rows that
replace their terms.
Both sums run in the same order as ``np.bincount`` over upper-triangle
pairs, which keeps the result bit for bit. NumPy adds sequentially when
reducing along a slow axis, and here that axis vectorizes over turbines.
"""
n_turbines = len(positions)
x = np.ascontiguousarray(positions[:, 0], dtype=float)
y = np.ascontiguousarray(positions[:, 1], dtype=float)
first_sums = np.zeros((2, n_turbines))
second_sums = np.zeros((2, n_turbines))
for start, stop in _row_blocks(n_turbines):
rows, columns = stop - start, slice(start + 1, n_turbines)
buffer = np.empty((2, rows + 1, n_turbines - start - 1))
terms = buffer[:, 1:]
np.subtract(x[start:stop, None], x[None, columns], out=terms[0])
np.subtract(y[start:stop, None], y[None, columns], out=terms[1])
terms *= pair_scale(start, stop, terms)
if coincident is not None:
pairs, pair_gradient = coincident
inside = (pairs[:, 0] >= start) & (pairs[:, 0] < stop)
row, column = pairs[inside].T
terms[:, row - start, column - start - 1] = pair_gradient[inside].T
# Zero the columns at or before each row, which are not pairs.
terms[:, :, :rows][:, np.tri(rows, k=-1, dtype=bool)] = 0.0
# Each column continues its running sum over earlier rows.
buffer[:, 0] = second_sums[:, columns]
np.add.reduce(buffer, axis=1, out=second_sums[:, columns])
# Transposing puts each row's pairs along the slow axis. Starting
# from zero matches ``np.bincount`` for signed zeros.
np.add.reduce(
np.ascontiguousarray(terms.transpose(0, 2, 1)),
axis=1,
initial=0.0,
out=first_sums[:, start:stop],
)
return np.ascontiguousarray((first_sums - second_sums).T)
def _repulsion_energy(positions, spacing, rng, softening):
r"""Return dimensionless all-pairs inverse-square energy and a gradient callable.
``softening`` regularizes coincident points. The gradient is only computed
on request, since the line search never uses it for rejected candidates.
"""
n_turbines = len(positions)
if n_turbines < 2:
return 0.0, _zero_gradient(positions)
first, second = _pair_indices(n_turbines)
pair_energy = np.empty(len(first))
coincident_blocks = []
for block, denominator in _pair_squared_distances(positions, first, second):
coincident_blocks.append(np.flatnonzero(denominator == 0.0) + block.start)
denominator += softening**2
np.divide(spacing**2, denominator, out=pair_energy[block])
energy = float(np.sum(pair_energy))
# Randomize direction for exact coincidences.
coincident = np.concatenate(coincident_blocks)
if len(coincident):
angles = rng.uniform(0.0, 2.0 * np.pi, len(coincident))
def pair_scale(start, stop, differences):
"""Return the inverse-square gradient scales of a row block."""
scale = differences[0] * differences[0]
scale += differences[1] * differences[1]
scale += softening**2
np.square(scale, out=scale)
np.divide(-2.0 * spacing**2, scale, out=scale)
return scale
def gradient():
"""Return the inverse-square gradient at ``positions``."""
if not len(coincident):
return _all_pair_gradient(positions, pair_scale)
directions = np.column_stack((np.cos(angles), np.sin(angles)))
pairs = np.column_stack((first[coincident], second[coincident]))
pair_gradient = (-(spacing**2) / (2.0 * softening**3)) * directions
return _all_pair_gradient(positions, pair_scale, (pairs, pair_gradient))
return energy, gradient
def _limit_displacement(candidate, initial, maximum):
if maximum is None:
return candidate
displacement = candidate - initial
distance = np.linalg.norm(displacement, axis=1)
outside = distance > maximum
if np.any(outside):
displacement[outside] *= (maximum / distance[outside])[:, None]
return initial + displacement
# Line search, boundary bisection and the repulsion softening resolve
# distances to this fraction of the spacing.
_DISTANCE_TOLERANCE = 1e-3
# A relaxation stops once its energy falls by less than its tolerance over
# this many accepted steps.
_STALL_STEPS = 5
def _slid_to_edge(geometry, edge, target, distance_tolerance):
"""The edge point nearest each ``target``, ``distance_tolerance`` inside.
Rows are NaN for targets inside ``geometry`` or on its edge, which have
no inward direction to slide along.
"""
slid = np.full_like(target, np.nan)
outside = np.flatnonzero(~shapely.contains_xy(geometry, target[:, 0], target[:, 1]))
if not len(outside):
return slid
lines = shapely.shortest_line(shapely.points(target[outside]), edge)
nearest = shapely.get_coordinates(lines).reshape(-1, 2, 2)[:, 1]
inward = nearest - target[outside]
length = np.linalg.norm(inward, axis=1)
off_edge = length > 0.0
slid[outside[off_edge]] = (
nearest[off_edge]
+ inward[off_edge] * (distance_tolerance / length[off_edge])[:, None]
)
return slid
def _confine_step(
geometry,
edge,
current,
candidate,
distance_tolerance,
clearance=None,
reach=None,
):
"""Keep moves inside ``geometry``, sliding blocked ones along its edge.
A move whose path leaves ``geometry`` ends at the point of ``edge``
nearest its target, ``distance_tolerance`` inside, when the path there
stays inside and within ``reach``. Other blocked moves stop at their
first crossing, so no move crosses a hole or a gap between parts.
``clearance`` is each turbine's distance from ``edge``, and a move
shorter than it stays inside without a test. ``reach`` holds the initial
positions and the largest distance a turbine may move from them.
"""
tested = np.arange(len(current))
if clearance is not None:
length = np.linalg.norm(candidate - current, axis=1)
# The margin covers rounding in the distance, so a move it lets pass
# is one the containment test would pass.
tested = np.flatnonzero(length >= clearance * (1.0 - 1e-9))
if not len(tested):
return candidate
paths = shapely.linestrings(np.stack((current[tested], candidate[tested]), axis=1))
blocked = tested[~shapely.covers(geometry, paths)]
if not len(blocked):
return candidate
start = current[blocked]
slid = _slid_to_edge(geometry, edge, candidate[blocked], distance_tolerance)
can_slide = ~np.isnan(slid[:, 0])
if reach is not None:
initial, maximum = reach
distance = np.linalg.norm(slid - initial[blocked], axis=1)
can_slide &= distance <= maximum
sliding = np.flatnonzero(can_slide)
if len(sliding):
paths = shapely.linestrings(np.stack((start[sliding], slid[sliding]), axis=1))
sliding = sliding[shapely.covers(geometry, paths)]
candidate[blocked[sliding]] = slid[sliding]
cut = np.ones(len(blocked), dtype=bool)
cut[sliding] = False
blocked, start = blocked[cut], start[cut]
displacement = candidate[blocked] - start
length = np.linalg.norm(displacement, axis=1)
lower = np.zeros(len(blocked))
upper = np.ones(len(blocked))
# Segment containment handles holes and disconnected farms.
active = np.flatnonzero(
(length > 0.0) & ((upper - lower) * length > distance_tolerance)
)
while len(active):
fraction = 0.5 * (lower[active] + upper[active])
point = start[active] + fraction[:, None] * displacement[active]
segments = shapely.linestrings(np.stack((start[active], point), axis=1))
covered = shapely.covers(geometry, segments)
lower[active[covered]] = fraction[covered]
upper[active[~covered]] = fraction[~covered]
remaining = (upper[active] - lower[active]) * length[
active
] > distance_tolerance
active = active[remaining]
moved = length > 0.0
candidate[blocked[moved]] = start[moved] + lower[moved, None] * displacement[moved]
return candidate
def _centering_energy(positions, target, spacing):
"""Return dimensionless centroid-offset energy and a gradient callable."""
if not len(positions):
return 0.0, _zero_gradient(positions)
offset = positions.mean(axis=0) - target
energy = 0.5 * float(offset @ offset) / spacing**2
def gradient():
"""Return the centroid-offset gradient at ``positions``."""
return np.broadcast_to(
offset / (len(positions) * spacing**2), positions.shape
).copy()
return energy, gradient
def _held_off(current, candidate, minimum, distance_tolerance):
"""``candidate`` with moves halved until no pair is closer than ``minimum``.
Each round halves the moves of the turbines in a crowded pair, and a move
halved to ``distance_tolerance`` is dropped. No pair of ``current`` is
closer, so the rounds end. A point along a confined move stays inside
the boundary and the displacement limit, so a halved move does too.
"""
from scipy.spatial import cKDTree # ty: ignore[unresolved-import]
move = candidate - current
length = np.linalg.norm(move, axis=1)
moving = length > 0.0
while np.any(moving):
pairs = cKDTree(candidate).query_pairs(minimum, output_type="ndarray")
differences = candidate[pairs[:, 0]] - candidate[pairs[:, 1]]
crowded = pairs[np.einsum("ij,ij->i", differences, differences) < minimum**2]
crowded = np.unique(crowded)
crowded = crowded[moving[crowded]]
if not len(crowded):
break
move[crowded] *= 0.5
length[crowded] *= 0.5
dropped = crowded[length[crowded] <= distance_tolerance]
move[dropped] = 0.0
moving[dropped] = False
candidate[crowded] = current[crowded] + move[crowded]
return candidate
[docs]
class Relaxation(LayoutModifier):
r"""Relax an existing layout inside its boundary.
``method="repulsion"`` uses the all-pairs potential
.. math:: E_{ij}=\frac{h^2}{d_{ij}^2+\epsilon^2}.
Every pair pushes apart, so the layout expands into the available
geometry. ``method="gaussian"`` uses the shorter-range potential
.. math:: E_{ij}=\exp(-d_{ij}^2/h^2),
plus a penalty for pairs closer than ``length_scale`` and the centroid energy
.. math:: E_c=\frac{\|\bar p-c_B\|^2}{2h^2},
where ``c_B`` is the boundary centroid. Steps follow the per-turbine
normalized negative gradient and accept only energy decreases. A step
that would leave the boundary slides to the nearest point of its edge,
unless that crosses a hole or a gap between parts, where it stops at the
crossing instead. Turbine count and ordering are unchanged. The repulsion
energy visits every turbine pair in cache-sized blocks, so its time and
memory grow quadratically with the turbine count. The Gaussian energy
only visits pairs within four length scales, as farther pairs hold less than
``exp(-16)`` of it, and gradients are only computed for accepted steps.
Parameters
----------
method:
``"repulsion"`` for inverse-square spreading, ``"gaussian"`` for
local pair repulsion.
length_scale:
The length ``h`` of the potentials and the compact-penalty threshold,
in meters. Defaults to the square root of boundary area per turbine.
max_iterations:
Maximum number of energy-descent steps.
step:
Maximum movement per iteration as a fraction of ``length_scale``.
tolerance:
Stop once the energy falls by at most this fraction over five
accepted steps.
max_displacement:
Optional maximum total movement from each initial position, in meters.
``None`` leaves displacement unconstrained.
minimum_spacing:
Optional distance in meters no step brings a pair closer than. The
moves of a pair that would come closer are halved until it does not.
A layout filled at a minimum spacing holds its relaxation to it.
seed:
Seed used only to separate exactly coincident positions.
"""
def __init__(
self,
*,
method: typing.Literal["repulsion", "gaussian"] = "repulsion",
length_scale: float | None = None,
max_iterations: int = 100,
step: float = 0.1,
tolerance: float = 1e-3,
max_displacement: float | None = None,
minimum_spacing: float | None = None,
seed: int | np.random.Generator | None = None,
) -> None:
if method not in {"repulsion", "gaussian"}:
raise ValueError("method must be 'repulsion' or 'gaussian'")
self.method = method
self.length_scale = (
None
if length_scale is None
else _validate_positive("length_scale", length_scale)
)
self.max_iterations = _validate_integer("max_iterations", max_iterations)
self.step = _validate_positive("step", step)
if self.step > 1:
raise ValueError("step must be in (0, 1]")
self.tolerance = _validate_positive("tolerance", tolerance)
self.max_displacement = (
None
if max_displacement is None
else _validate_nonnegative("max_displacement", max_displacement)
)
self.minimum_spacing = (
None
if minimum_spacing is None
else _validate_positive("minimum_spacing", minimum_spacing)
)
self.seed = pin_seed(seed)
def _held_apart(self, spacing: float) -> "Relaxation":
held = copy(self)
held.minimum_spacing = max(self.minimum_spacing or 0.0, spacing)
return held
def _spacing(self, layout: Farm) -> float:
if self.length_scale is not None:
return self.length_scale
boundary = layout.boundary
if boundary is None:
raise ValueError("an automatic length_scale requires a farm boundary")
return float(np.sqrt(boundary.area / max(layout.n_turbines, 1)))
[docs]
def apply(self, layout: Farm, rng: np.random.Generator, seed: int) -> Farm:
"""Return a new farm with positions relaxed inside the boundary.
``rng`` only separates exactly coincident positions. Records the
energies, largest displacement and convergence in
``metadata["modifiers"]``. The string guide is dropped when any turbine
moves. Raises ``ValueError`` without a boundary, when a position
starts outside it, or when a pair starts closer than
``minimum_spacing``.
"""
if layout.boundary is None:
raise ValueError("relaxation requires a farm boundary")
if not np.all(layout.boundary._contains(layout.positions)):
raise ValueError(
"relaxation requires all initial positions inside the boundary"
)
# The margin covers rounding in the distances a fill measured.
if self.minimum_spacing is not None and layout.minimum_spacing() < (
self.minimum_spacing * (1.0 - 1e-9)
):
raise ValueError("layout does not satisfy the requested minimum_spacing")
initial = np.asarray(layout.positions, dtype=float)
positions = initial.copy()
spacing = self._spacing(layout)
distance_tolerance = _DISTANCE_TOLERANCE * spacing
centering_target = np.asarray(layout.boundary.centroid)
if self.method == "repulsion":
softening = distance_tolerance
def evaluate(points: np.ndarray) -> tuple[float, Callable[[], np.ndarray]]:
"""Return the inverse-square energy and its uphill gradient callable."""
return _repulsion_energy(points, spacing, rng, softening)
else:
def evaluate(points: np.ndarray) -> tuple[float, Callable[[], np.ndarray]]:
"""Return the Gaussian, spacing and centroid energy and gradient callable."""
pair_energy, pair_gradient = _gaussian_energy(points, spacing, rng)
center_energy, center_gradient = _centering_energy(
points, centering_target, spacing
)
def total_gradient() -> np.ndarray:
"""Return the summed uphill gradient at ``points``."""
return pair_gradient() + center_gradient()
return pair_energy + center_energy, total_gradient
# Gradients are only computed for accepted steps.
initial_energy, gradient_at = evaluate(positions)
gradient = gradient_at()
energy = initial_energy
converged = layout.n_turbines < 2 or self.max_displacement == 0.0
iteration = 0
# Every trial step tests its moves against the boundary.
geometry = layout.boundary.geometry
edge = geometry.boundary
shapely.prepare(geometry)
shapely.prepare(edge)
reach = (
None if self.max_displacement is None else (initial, self.max_displacement)
)
# The line search starts at the step last accepted, and at twice it
# once a step passes at its first try, never above the full step. A
# step that had to halve mostly halves again, so starting above it
# wastes an evaluation.
start_fraction = 1.0
history = [energy]
for iteration in range(1, self.max_iterations + 1):
if converged:
iteration = 0
break
# The gradient at ``positions`` came with its accepted energy.
gradient_norm = np.linalg.norm(gradient, axis=1)
if float(gradient_norm.max(initial=0.0)) == 0.0:
converged = True
break
direction = np.divide(
gradient,
gradient_norm[:, None],
out=np.zeros_like(gradient),
where=gradient_norm[:, None] > 0.0,
)
displacement = -self.step * spacing * direction
clearance = shapely.distance(edge, shapely.points(positions))
accepted = False
fraction = start_fraction
movement = 0.0
while (
fraction * float(np.linalg.norm(displacement, axis=1).max(initial=0.0))
> distance_tolerance
):
candidate = positions + fraction * displacement
candidate = _limit_displacement(
candidate, initial, self.max_displacement
)
candidate = _confine_step(
geometry,
edge,
positions,
candidate,
distance_tolerance,
clearance,
reach,
)
if self.minimum_spacing is not None:
candidate = _held_off(
positions, candidate, self.minimum_spacing, distance_tolerance
)
candidate_energy, candidate_gradient = evaluate(candidate)
if candidate_energy <= energy:
movement = float(
np.linalg.norm(candidate - positions, axis=1).max(initial=0.0)
)
positions = candidate
energy, gradient_at = candidate_energy, candidate_gradient
gradient = gradient_at()
accepted = True
break
fraction *= 0.5
if fraction == start_fraction:
start_fraction = min(1.0, 2.0 * fraction)
else:
start_fraction = fraction
if not accepted or movement <= distance_tolerance:
converged = True
break
history.append(energy)
if len(history) > _STALL_STEPS:
earlier = history[-_STALL_STEPS - 1]
if earlier - energy <= self.tolerance * abs(earlier):
converged = True
break
displacements = np.linalg.norm(positions - initial, axis=1)
metadata = dict(layout.metadata)
modifiers = list(metadata.get("modifiers", []))
record = {
"type": "relaxation",
"method": self.method,
"seed": int(seed),
"length_scale": spacing,
"iterations": iteration,
"maximum_iterations": self.max_iterations,
"step": self.step,
"tolerance": self.tolerance,
"max_displacement": self.max_displacement,
"minimum_spacing": self.minimum_spacing,
"maximum_displacement": float(displacements.max(initial=0.0)),
"initial_energy": initial_energy,
"final_energy": energy,
"converged": converged,
}
if self.method == "gaussian":
record["centering_target"] = centering_target.tolist()
modifiers.append(record)
metadata["modifiers"] = modifiers
moved = bool(np.any(displacements > 0.0))
return Farm(
positions,
layout.boundary,
metadata=metadata,
guide=None if moved else layout.guide,
resource=layout.resource,
turbines=layout.turbines,
surface=layout.surface,
)