"""Equal-spacing perimeter layout."""
from __future__ import annotations
import numpy as np
import shapely
from .._validation import _validate_finite
from ..boundaries.base import _edge_clearance, _project_inside
from .base import (
_MAX_FILL_TURBINES,
LayoutAlgorithm,
_closest_distance,
_too_many_error,
)
__all__ = ["Perimeter"]
#: Relative slack of the corner screen, far above the rounding of its
#: interpolation, so it never rejects a count that measures as spaced.
_SCREEN_SLACK = 1e-6
#: Most vertex pairs screened at once, which keeps the screen's arrays to a
#: few megabytes.
_SCREEN_SIZE = 1 << 18
def _contained_perimeter_points(geometry, count, start, line=None, interior=None):
"""``count`` points equally spaced along ``line`` from fraction ``start``.
``line`` defaults to the exterior of ``geometry``. Points on the edge are
ambiguous to containment tests, so they move a negligible distance inside,
toward ``interior``, the geometry inset by its edge clearance when given.
"""
if count == 0:
return np.empty((0, 2))
line = geometry.exterior if line is None else line
fractions = start + np.arange(count, dtype=float) / count
positions = shapely.get_coordinates(
shapely.line_interpolate_point(line, fractions % 1.0, normalized=True)
)
if interior is None:
interior = geometry.buffer(-_edge_clearance(geometry))
if not interior.is_empty:
positions = _project_inside(geometry, interior, positions)
return positions
def _spaced_perimeter_points(geometry, start, spacing, most=None):
"""The most points, up to ``most``, equally around the exterior and spaced.
No pair is closer than ``spacing``. Chords across a corner or a neck are
shorter than the arc between their ends, so counts are tried from the
most the exterior holds down. Below the most, each is screened at the
corners before it is measured. An exterior shorter than the spacing holds
none.
"""
exterior = geometry.exterior
count = int(exterior.length // spacing)
if most is not None:
count = min(count, most)
interior = geometry.buffer(-_edge_clearance(geometry))
if count < 2:
return _contained_perimeter_points(geometry, count, start, interior=interior)
# A gently curved exterior keeps the most, which needs no screen.
points = _contained_perimeter_points(geometry, count, start, interior=interior)
if _closest_distance(points) >= spacing:
return points
count -= 1
ring = shapely.get_coordinates(exterior)
arcs = np.concatenate([[0.0], np.cumsum(np.hypot(*np.diff(ring, axis=0).T))])
# Most counts pass the screen near the top, so the screened run starts
# short and doubles while none passes.
chunk, largest = 8, max(8, _SCREEN_SIZE // len(ring))
while count > 1:
counts = np.arange(count, max(count - chunk, 1), -1)
passing = np.flatnonzero(_corner_screen(ring, arcs, start, spacing, counts))
if not len(passing):
count = int(counts[-1]) - 1
chunk = min(2 * chunk, largest)
continue
count = int(counts[passing[0]])
points = _contained_perimeter_points(geometry, count, start, interior=interior)
if _closest_distance(points) >= spacing:
return points
count -= 1
return _contained_perimeter_points(geometry, count, start, interior=interior)
def _corner_screen(ring, arcs, start, spacing, counts) -> np.ndarray:
"""Mask of ``counts`` whose points around each vertex may keep ``spacing``.
Points equally spaced from fraction ``start`` lie a step apart along an
edge, a step of at least the spacing, so only the pair around a vertex
of ``ring`` can be closer. Those pairs are placed along ``arcs``, the
cumulative lengths, with no shapely call, so many counts are screened at
once. A pair across a neck is left to the measure.
"""
length = arcs[-1]
vertices = arcs[:-1] / length
totals = counts[:, None].astype(float)
before = np.floor((vertices - start) % 1.0 * totals)
ends = []
for index in (before, before + 1.0):
distance = (start + index / totals) % 1.0 * length
ends.append([np.interp(distance, arcs, ring[:, axis]) for axis in (0, 1)])
chords = np.hypot(ends[1][0] - ends[0][0], ends[1][1] - ends[0][1])
return chords.min(axis=1) >= spacing * (1.0 - _SCREEN_SLACK)
[docs]
class Perimeter(LayoutAlgorithm):
"""Place an exact turbine count equally along the exterior perimeter.
``start`` is a normalized perimeter fraction in ``[0, 1)``. When it is
``None``, a reproducible random start is selected from ``seed``. With
``minimum_spacing``, each polygon's exterior takes the most turbines, equally
spaced, whose closest pair keeps the spacing, so chords across corners and
across narrow necks count. A turbine closer than the spacing to one on
another part is left out.
"""
_fills_directly = True
def __init__(
self,
n_turbines: int | None = None,
*,
density: dict[str, float] | None = None,
start: float | None = None,
seed: int | np.random.Generator | None = None,
):
if start is not None:
start = _validate_finite("start", start)
if not 0.0 <= start < 1.0:
raise ValueError("start must be in [0, 1) or None")
self.start = start
super().__init__(
n_turbines,
density=density,
seed=seed,
)
def _generate_single(self, boundary, rng, seed):
geometry = boundary.geometry
line = geometry.exterior
start = float(rng.random()) if self.start is None else self.start
positions = _contained_perimeter_points(geometry, self.n_turbines, start)
return self._single_result(
positions,
boundary,
seed,
{
"method": "perimeter",
"start": start,
"perimeter": float(line.length),
"arc_spacing": float(line.length / self.n_turbines),
},
)
def _fill_positions(self, boundary, spacing, rng):
from ..boundaries.base import _farm_pieces
polygons = [
polygon
for piece in _farm_pieces(boundary)
for polygon in getattr(piece, "geoms", (piece,))
]
most = sum(int(polygon.exterior.length // spacing) for polygon in polygons)
if most > _MAX_FILL_TURBINES:
raise _too_many_error(spacing, most, "perimeter")
positions = []
starts = []
for polygon in polygons:
start = float(rng.random()) if self.start is None else self.start
points = _spaced_perimeter_points(polygon, start, spacing)
# An exterior shorter than the spacing still holds one turbine.
if not len(points):
points = _contained_perimeter_points(polygon, 1, start)
positions.append(points)
starts.append(start)
fit = {
"method": "perimeter",
"start": starts[0] if len(starts) == 1 else starts,
"perimeter": float(sum(polygon.exterior.length for polygon in polygons)),
}
return np.concatenate(positions), fit