"""Boundary-grid layout parameterization from Stanley and Ning (2019).
Reference: https://doi.org/10.5194/wes-4-663-2019
"""
from __future__ import annotations
from functools import lru_cache
from typing import TYPE_CHECKING
import numpy as np
import shapely
from .._validation import _validate_unit_interval
from .base import (
_MAX_FILL_TURBINES,
LayoutAlgorithm,
_apart_from,
_closest_distance,
_NoFitError,
_spread_selection,
_too_many_error,
)
from .perimeter import _contained_perimeter_points, _spaced_perimeter_points
if TYPE_CHECKING: # pragma: no cover
from ..boundaries.base import Boundary
__all__ = ["BoundaryGrid"]
#: Row spacing over column spacing, ``dy = 4 dx`` from Stanley and Ning (2019).
_GRID_SPACING_RATIO = 4.0
#: Row offset angle in degrees from Stanley and Ning (2019).
_ROW_OFFSET_ANGLE = 20.0
#: Perimeter starts and grid rotations tried, keeping the best spaced. Eight
#: rotations stratified over a half turn spaced 80 farms as well as sixteen
#: drawn over the full circle, in a third of the time, and filled 2 % fewer
#: turbines at a spacing.
_N_REALIZATIONS = 8
#: Relative widening of the filled grid's column spacing, so rounding in the
#: lattice coordinates never brings a pair below the spacing.
_LATTICE_MARGIN = 1e-9
#: Row offset per unit of column spacing.
_ROW_OFFSET = float(np.tan(np.deg2rad(_ROW_OFFSET_ANGLE)))
def _realizations(rng):
"""Random perimeter starts beside grid rotations stratified over 180 degrees.
The lattice is symmetric under a half turn about its center, so rotations
drawn over the full circle tried half their lattices twice. One rotation
is drawn in each of :data:`_N_REALIZATIONS` equal sectors of a half turn.
"""
width = 180.0 / _N_REALIZATIONS
for index in range(_N_REALIZATIONS):
yield float(rng.random()), float((index + rng.random()) * width)
@lru_cache(maxsize=64)
def _rotation_transpose(rotation: float) -> np.ndarray:
"""The transposed rotation matrix of ``rotation`` degrees, read-only."""
angle = np.deg2rad(rotation)
matrix = np.array(
[
[np.cos(angle), -np.sin(angle)],
[np.sin(angle), np.cos(angle)],
]
).T
matrix.flags.writeable = False
return matrix
def _grid_basis(x_spacing, rotation):
y_spacing = _GRID_SPACING_RATIO * x_spacing
row_offset = _ROW_OFFSET * x_spacing
basis = np.array([[x_spacing, 0.0], [row_offset, y_spacing]])
return basis @ _rotation_transpose(rotation)
def _grid_points(geometry, center, x_spacing, rotation):
if x_spacing <= 0 or geometry.is_empty:
return np.empty((0, 2))
indices = _lattice_indices(geometry, center, x_spacing, rotation)
points = indices @ _grid_basis(x_spacing, rotation) + center
minx, miny, maxx, maxy = geometry.bounds
# The rotated index range covers far more than the bounding box, and no
# point outside the box can touch the boundary.
in_box = (
(points[:, 0] >= minx)
& (points[:, 0] <= maxx)
& (points[:, 1] >= miny)
& (points[:, 1] <= maxy)
)
points = points[in_box]
mask = shapely.intersects_xy(geometry, points[:, 0], points[:, 1])
return points[mask]
def _lattice_indices(geometry, center, x_spacing, rotation):
"""Lattice indices whose points at ``x_spacing`` or more may lie in the box.
Larger spacings only narrow the range, so the indices cover them too.
"""
basis = _grid_basis(x_spacing, rotation)
minx, miny, maxx, maxy = geometry.bounds
corners = np.array([[minx, miny], [minx, maxy], [maxx, miny], [maxx, maxy]])
coefficients = (corners - center) @ np.linalg.inv(basis)
lower = np.floor(coefficients.min(axis=0)).astype(int) - 1
upper = np.ceil(coefficients.max(axis=0)).astype(int) + 1
first = np.arange(lower[0], upper[0] + 1)
second = np.arange(lower[1], upper[1] + 1)
return np.column_stack([np.tile(first, len(second)), np.repeat(second, len(first))])
class _GridCounter:
"""The number of grid points in ``geometry`` at spacings inside a bracket.
Each point moves along a line from the center as the spacing grows, so
over a bracket it sweeps a short segment. A segment farther from the edge
than rounding could carry it stays on one side, and only the points whose
segments come near the edge are tested at each spacing. The count equals
that of :func:`_grid_points`, so a search over it decides alike.
"""
def __init__(self, geometry, center, rotation):
self.geometry = geometry
self.center = center
self.rotation = rotation
self.edge = geometry.boundary
shapely.prepare(self.edge)
minx, miny, maxx, maxy = geometry.bounds
self.box = (minx, miny, maxx, maxy)
size = max(abs(minx), abs(miny), abs(maxx), abs(maxy), *np.abs(center))
# Far beyond the rounding of a coordinate, far below a spacing.
self.margin = 1e-9 * max(size, 1.0)
def _points(self, indices, x_spacing):
return indices @ _grid_basis(x_spacing, self.rotation) + self.center
def classify(self, lower, upper):
"""Sort the points over ``lower`` to ``upper`` into sure and near ones."""
indices = _lattice_indices(self.geometry, self.center, lower, self.rotation)
start = self._points(indices, lower)
stop = self._points(indices, upper)
minx, miny, maxx, maxy = self.box
# A segment wholly past one side of the box lies outside.
beyond = (
(np.maximum(start[:, 0], stop[:, 0]) < minx - self.margin)
| (np.minimum(start[:, 0], stop[:, 0]) > maxx + self.margin)
| (np.maximum(start[:, 1], stop[:, 1]) < miny - self.margin)
| (np.minimum(start[:, 1], stop[:, 1]) > maxy + self.margin)
)
keep = np.flatnonzero(~beyond)
segments = shapely.linestrings(np.stack((start[keep], stop[keep]), axis=1))
clear = ~shapely.dwithin(self.edge, segments, self.margin)
sure = keep[clear]
inside = shapely.intersects_xy(self.geometry, start[sure, 0], start[sure, 1])
self.sure_inside = int(np.count_nonzero(inside))
self.near = indices[keep[~clear]]
def count(self, x_spacing):
"""Grid points in the geometry at ``x_spacing``, inside the bracket."""
points = self._points(self.near, x_spacing)
near = shapely.intersects_xy(self.geometry, points[:, 0], points[:, 1])
return self.sure_inside + int(np.count_nonzero(near))
def _fit_grid(geometry, count, rotation):
if count == 0:
return np.empty((0, 2)), float("inf"), 0
center = _grid_center(geometry)
if count == 1:
# The lattice is centered on a point inside, which it keeps at every
# spacing, so the search for the widest grid holding one point grew
# its spacing 80 times and bisected past 1e14 m. Beyond the bounding
# box's diagonal the center is the only point left.
min_x, min_y, max_x, max_y = geometry.bounds
return center[None, :], float(np.hypot(max_x - min_x, max_y - min_y)), 1
initial = max(np.sqrt(geometry.area / (count * _GRID_SPACING_RATIO)), 1e-12)
lower = initial
for _ in range(200):
if len(_grid_points(geometry, center, lower, rotation)) >= count:
break
lower /= 1.5
else:
return np.empty((0, 2)), 0.0, 0
upper = lower * 1.5
for _ in range(80):
if len(_grid_points(geometry, center, upper, rotation)) < count:
break
lower = upper
upper *= 1.5
# One classification over the whole bracket leaves a few dozen points near
# the edge, so each step tests those alone.
counter = _GridCounter(geometry, center, rotation)
counter.classify(lower, upper)
for _ in range(50):
if upper - lower <= 1e-6 * max(lower, 1.0):
break
midpoint = 0.5 * (lower + upper)
if counter.count(midpoint) >= count:
lower = midpoint
else:
upper = midpoint
candidates = _grid_points(geometry, center, lower, rotation)
return candidates, float(lower), int(len(candidates))
def _grid_center(geometry):
"""The centroid of ``geometry``, or an interior point when it falls outside."""
centroid = geometry.centroid
if not geometry.covers(centroid):
centroid = geometry.representative_point()
return np.array(centroid.coords[0])
def _inner_geometry(geometry, boundary_count: int, grid_count: int):
"""``geometry`` inset so an inner grid of ``grid_count`` keeps off the ring.
The inset is the spacing of ``boundary_count`` turbines around the
exterior or the row spacing of the grid over the whole area, whichever is
less, so a pair across ring and grid lies about as far apart as the closer
of the two.
"""
ring_spacing = geometry.exterior.length / boundary_count
row_spacing = np.sqrt(geometry.area / (grid_count * _GRID_SPACING_RATIO))
return geometry.buffer(-min(ring_spacing, row_spacing))
def _grid_option(geometry, boundary_points, grid_count: int, rotation, rng):
"""The ring and a grid of ``grid_count`` fitted in ``geometry``, or ``None``.
Returns the positions, the grid's spacing and candidate count, and the
closest pair, or ``None`` when the grid does not fit.
"""
candidates, x_spacing, candidate_count = _fit_grid(geometry, grid_count, rotation)
if len(candidates) < grid_count:
return None
grid_points = _select_interior(candidates, grid_count, boundary_points, rng)
positions = np.vstack([boundary_points, grid_points])
return {
"positions": positions,
"x_spacing": x_spacing,
"candidate_count": candidate_count,
"minimum_distance": _closest_distance(positions),
}
def _crowds_ring(option, geometry, boundary_count: int, grid_count: int) -> bool:
"""Whether a pair of ``option`` lies under half its ring or row spacing.
The inner grid is fitted to the whole boundary, so a grid turbine could
sit on the edge, 105 m from a ring turbine where both spaced 250 m and
more. Such a layout is compared with a grid inset from the ring, which a
few turbines in a small cell would crowd instead.
"""
if not boundary_count or not grid_count:
return False
ring_spacing = geometry.exterior.length / boundary_count
return option["minimum_distance"] < 0.5 * min(ring_spacing, option["x_spacing"])
def _ring_cap(fraction, grid_count):
"""Most ring turbines that hold at most ``fraction`` of the farm, ``None`` if any."""
if fraction >= 1.0:
return None
# The exact-count rule, ring count at most floor(fraction * total), solved
# for the ring, then stepped down where rounding overshot it.
count = int(np.floor(fraction * grid_count / (1.0 - fraction))) + 1
while count > int(np.floor(fraction * (count + grid_count))):
count -= 1
return count
def _ringed_grid(geometry, candidates, start, spacing, fraction):
"""The spaced ring around ``geometry`` and the ``candidates`` clear of it.
The ring holds at most ``fraction`` of the turbines. Grid turbines keep
the spacing from each ring turbine rather than from the whole edge, so
those between ring turbines stay.
"""
ring = _spaced_perimeter_points(
geometry, start, spacing, _ring_cap(fraction, len(candidates))
)
grid = _apart_from(candidates, ring, spacing)
# Grid turbines dropped near the ring can leave it above its share.
while (most := _ring_cap(fraction, len(grid))) is not None and len(ring) > most:
ring = _spaced_perimeter_points(geometry, start, spacing, most)
grid = _apart_from(candidates, ring, spacing)
return ring, grid
def _select_interior(candidates, count, boundary_points, rng):
if count == 0:
return np.empty((0, 2))
if len(candidates) == count:
return candidates
return _spread_selection(candidates, count, boundary_points, rng)
[docs]
class BoundaryGrid(LayoutAlgorithm):
"""Automatic boundary-grid layout based on Stanley and Ning (2019).
Up to ``boundary_fraction`` of the turbines are equally spaced around the
exterior boundary. The remainder occupy a centered, rotated inner grid.
Of the perimeter starts and grid rotations tried, the one with the widest
closest pair is kept, and when a grid turbine crowds the ring it is
refitted inside the boundary inset by the ring's spacing or the grid's
row spacing, whichever is less, keeping the wider of the two. The paper's
initialization values ``dy = 4 dx`` and a 20 degree row offset are fixed.
The seed chooses perimeter start and grid rotation.
With ``minimum_spacing`` the ring and the grid are both filled at that
spacing, the ring holding at most ``boundary_fraction`` of the turbines.
"""
def __init__(
self,
n_turbines: int | None = None,
*,
density: dict[str, float] | None = None,
boundary_fraction: float = 0.45,
seed: int | np.random.Generator | None = None,
):
self.boundary_fraction = _validate_unit_interval(
"boundary_fraction", boundary_fraction
)
super().__init__(
n_turbines,
density=density,
seed=seed,
)
def _generate_single(self, boundary, rng, seed):
geometry = boundary.geometry
exterior = geometry.exterior
n_turbines = self._required_n
maximum_boundary_count = int(np.floor(self.boundary_fraction * n_turbines))
best = None
for start, rotation in _realizations(rng):
for boundary_count in range(maximum_boundary_count, -1, -1):
boundary_points = _contained_perimeter_points(
geometry, boundary_count, start
)
grid_count = n_turbines - boundary_count
option = _grid_option(
geometry, boundary_points, grid_count, rotation, rng
)
if option is None:
continue
option.update(
boundary_count=boundary_count,
grid_count=grid_count,
start=start,
rotation=rotation,
)
if (
best is None
or option["minimum_distance"] > best["minimum_distance"]
):
best = option
break
if best is None:
raise ValueError("could not construct a feasible boundary-grid layout")
if _crowds_ring(best, geometry, best["boundary_count"], best["grid_count"]):
# Refit the chosen realization with the grid inset from its ring.
inner = _inner_geometry(
geometry, best["boundary_count"], best["grid_count"]
)
cleared = (
None
if inner.is_empty
else _grid_option(
inner,
_contained_perimeter_points(
geometry, best["boundary_count"], best["start"]
),
best["grid_count"],
best["rotation"],
rng,
)
)
if (
cleared is not None
and cleared["minimum_distance"] > best["minimum_distance"]
):
best = {**best, **cleared}
return self._single_result(
best["positions"],
boundary,
seed,
self._fit_record(best, best["start"] * exterior.length),
)
def _fit_record(self, best: dict, boundary_start: float | None) -> dict:
"""The fit record of the ``best`` realization."""
# Without inner-grid turbines there is no grid, so no grid spacing.
x_spacing = best["x_spacing"] if best["grid_count"] else None
realized_minimum = best["minimum_distance"]
if not np.isfinite(realized_minimum):
realized_minimum = None
return {
"method": "boundary_grid",
"boundary_fraction": self.boundary_fraction,
"boundary_count": best["boundary_count"],
"grid_count": best["grid_count"],
"boundary_start": boundary_start,
"boundary_start_fraction": best["start"],
"x_spacing": x_spacing,
"y_spacing": None if x_spacing is None else _GRID_SPACING_RATIO * x_spacing,
"row_offset": (
None
if x_spacing is None
else float(np.tan(np.deg2rad(_ROW_OFFSET_ANGLE)) * x_spacing)
),
"row_offset_angle": _ROW_OFFSET_ANGLE,
"rotation": best["rotation"],
"candidate_count": best["candidate_count"],
"realized_minimum_spacing": realized_minimum,
}
_fills_directly = True
def _fill_positions(
self, boundary: Boundary, spacing: float, rng: np.random.Generator
) -> tuple[np.ndarray, dict]:
"""Fill each part with a spaced ring and a spaced grid inside it.
The ring holds the most turbines no closer than the spacing, but at
most ``boundary_fraction`` of its part, spread evenly when it holds
fewer. The grid's column spacing is the spacing, the lattice's
shortest vector since its rows lie four times as far apart, and it
keeps only turbines that far from the ring. A part whose grid holds
more without a ring goes without one. Of the perimeter starts and
rotations the exact count tries, the one holding the most turbines
wins, ties going to the larger closest pair. ``boundary_start`` is
recorded for a single part only, since each part starts its ring at
the same fraction of its own exterior.
"""
from ..boundaries.base import _farm_pieces
fraction = self.boundary_fraction
x_spacing = spacing * (1.0 + _LATTICE_MARGIN)
# A cell in several parts rings each, as the exterior of each is its edge.
pieces = [
polygon
for piece in _farm_pieces(boundary)
for polygon in getattr(piece, "geoms", (piece,))
]
# The grid is drawn over each part's bounding box before it is clipped.
cell = _GRID_SPACING_RATIO * x_spacing**2
most = 0
for piece in pieces:
minx, miny, maxx, maxy = piece.bounds
most += int(piece.length // spacing + (maxx - minx) * (maxy - miny) / cell)
if most > _MAX_FILL_TURBINES:
raise _too_many_error(spacing, most, "ring and grid")
centers = [_grid_center(piece) for piece in pieces]
# Any realization beats this key, so ``best`` is always replaced.
best_key: tuple[int, float] = (-1, 0.0)
best: dict = {}
positions = np.empty((0, 2))
for start, rotation in _realizations(rng):
placed = np.empty((0, 2))
boundary_count = grid_count = candidate_count = 0
for index, piece in enumerate(pieces):
candidates = _grid_points(piece, centers[index], x_spacing, rotation)
ring, inner = np.empty((0, 2)), candidates
if fraction > 0:
ring_option = _ringed_grid(
piece, candidates, start, spacing, fraction
)
# The ring is the method, so it wins ties.
if sum(map(len, ring_option)) >= len(candidates):
ring, inner = ring_option
# Parts may lie closer than the spacing, so later ones give way.
ring = _apart_from(ring, placed, spacing)
inner = _apart_from(inner, placed, spacing)
placed = np.vstack([placed, ring, inner])
boundary_count += len(ring)
grid_count += len(inner)
candidate_count += len(candidates)
closest = _closest_distance(placed)
if (len(placed), closest) > best_key:
best_key, positions = (len(placed), closest), placed
best = {
"boundary_count": boundary_count,
"grid_count": grid_count,
"start": start,
"rotation": rotation,
"x_spacing": x_spacing,
"candidate_count": candidate_count,
"minimum_distance": closest,
}
if len(positions) < 2:
# The base error would claim no layout fits, where a ring alone may.
raise _NoFitError(
f"no boundary grid of two or more turbines with at most "
f"{fraction:g} of them on the boundary fits "
f"{float(boundary.area) / 1e6:.3g} km2 with {spacing:.3g} m spacing"
)
boundary_start = best["start"] * pieces[0].exterior.length
return positions, self._fit_record(
best, boundary_start if len(pieces) == 1 else None
)