"""Nested boundary-contour turbine layout."""
from __future__ import annotations
from functools import lru_cache
from typing import TYPE_CHECKING
import numpy as np
import shapely
from .._validation import _validate_positive
from .base import _grouped_by_piece
from .candidate import _CandidateLayout
from .fitters import SelectedContourFitter
if TYPE_CHECKING: # pragma: no cover
from ..boundaries.base import Boundary
from .fitters import SelectedCandidateFitter
__all__ = ["Contour"]
def _contour_lines(geometry):
"""Every exterior and hole ring of a polygon or multipolygon."""
polygons = geometry.geoms if geometry.geom_type == "MultiPolygon" else [geometry]
return [
ring for polygon in polygons for ring in (polygon.exterior, *polygon.interiors)
]
#: Relative to a boundary's larger side, the tolerance its deepest point is
#: found to.
_DEPTH_TOLERANCE = 1e-3
#: Fraction of the nominal spacing below which two candidates are too close.
_MIN_SEPARATION = 0.6
def _along(line, distances: np.ndarray) -> np.ndarray:
"""Points ``distances`` along ``line``, with a last axis for x and y.
The same points as ``shapely.line_interpolate_point`` to the last bit, as
GEOS finds them by summing segment lengths in order. That walked the
line from its start once per point, which on a smooth farm's insets of
hundreds of vertices took most of the time a Contour field took to build.
"""
coordinates = shapely.get_coordinates(line)
dx = coordinates[1:, 0] - coordinates[:-1, 0]
dy = coordinates[1:, 1] - coordinates[:-1, 1]
lengths = np.sqrt(dx * dx + dy * dy)
reached = np.cumsum(lengths)
before = np.concatenate([[0.0], reached[:-1]])
segment = np.minimum(
np.searchsorted(reached, distances, side="right"), len(lengths) - 1
)
# A distance past the end lands on the last segment, which may have no
# length, and is replaced by the end below.
with np.errstate(divide="ignore", invalid="ignore"):
fraction = (distances - before[segment]) / lengths[segment]
start = coordinates[segment]
stop = coordinates[segment + 1]
point = np.stack([dx[segment], dy[segment]], axis=-1) * fraction[..., None]
point += start
# GEOS returns a segment's ends for fractions outside it, and the end of
# the line past its length.
point = np.where((fraction <= 0.0)[..., None], start, point)
point = np.where((fraction >= 1.0)[..., None], stop, point)
return np.where((distances >= reached[-1])[..., None], coordinates[-1], point)
def _dropped(points: np.ndarray, center, separation: float) -> np.ndarray:
"""Which of one layer's ``points`` the field leaves out, as a mask.
``points`` has shape ``(phases, points, 2)``. Of each pair at most
``separation`` apart the later point goes, and every point that close to
``center``, unless it is ``None``. A chain of close points can lose one
more than a pass in order would drop, but such a pass made a Contour
layout half again as slow. Layers lie a scale apart, farther than the
separation, so each is thinned on its own, and one tree holds every
phase, set side by side.
"""
from scipy.spatial import cKDTree # ty: ignore[unresolved-import]
n_phases, count = points.shape[:2]
limit = separation * separation
dropped = np.zeros((n_phases, count), dtype=bool)
if center is not None:
gap = points - center
dropped |= gap[..., 0] * gap[..., 0] + gap[..., 1] * gap[..., 1] <= limit
if count < 2:
return dropped
width = float(np.ptp(points[..., 0])) + 4.0 * separation
side_by_side = points.reshape(-1, 2).copy()
side_by_side[:, 0] += np.repeat(width * np.arange(n_phases), count)
# The shift rounds, so pairs are found a little beyond the separation and
# measured again where they lie.
pairs = cKDTree(side_by_side).query_pairs(
separation * (1.0 + 1e-9), output_type="ndarray"
)
if len(pairs):
flat = points.reshape(-1, 2)
gap = flat[pairs[:, 0]] - flat[pairs[:, 1]]
close = gap[:, 0] * gap[:, 0] + gap[:, 1] * gap[:, 1] <= limit
dropped.reshape(-1)[pairs[close].max(axis=1)] = True
return dropped
@lru_cache(maxsize=1)
def _deepest(geometry):
"""A depth that no inset of ``geometry`` reaches.
It lies past the deepest point by more than the tolerance that point is
found to. A buffer rounds a corner with chords, which carry an inset past
the deepest point by about a thousandth of it, within that tolerance.
"""
minx, miny, maxx, maxy = geometry.bounds
tolerance = _DEPTH_TOLERANCE * max(maxx - minx, maxy - miny)
radius = shapely.maximum_inscribed_circle(geometry, tolerance).length
return radius + 2.0 * tolerance
@lru_cache(maxsize=1)
def _outermost(geometry, clearance: float):
"""``geometry`` inset by ``clearance``, the outermost contour at every scale.
The candidate search tries a hundred scales or more on one boundary, and
each would buffer it again.
"""
return geometry.buffer(-clearance)
[docs]
class Contour(_CandidateLayout):
"""Place turbines along nested inward offsets of the farm boundary.
``spacing_ratio`` is the target distance along each contour divided by the
distance between contour layers, and ``include_center`` adds a turbine at
an interior point. Layer count and turbine allocation follow from the
requested count and the available geometry. The innermost contour hugs
the deepest point of the farm, so it can pass a few meters from the
center or fold back on itself, and a contour turning an acute corner
cuts across it. A candidate closer than 0.6 of the smaller of the two
spacings to an earlier one, the center first and then the contours from
the outside in, is therefore left out.
"""
refine_candidates = False
def __init__(
self,
n_turbines: int | None = None,
*,
density: dict[str, float] | None = None,
spacing_ratio: float = 1.0,
include_center: bool = True,
fitter: SelectedCandidateFitter | None = None,
seed: int | np.random.Generator | None = None,
):
self.spacing_ratio = _validate_positive("spacing_ratio", spacing_ratio)
self.include_center = bool(include_center)
if fitter is None:
fitter = SelectedContourFitter()
super().__init__(
n_turbines,
density=density,
fitter=fitter,
seed=seed,
)
[docs]
def estimate_spacing(self, boundary: Boundary) -> float:
"""Return a starting contour-layer spacing in meters.
Assumes one square ``area / n_turbines`` cell per turbine. The estimate
only seeds the fitter's search and is not the final spacing.
"""
return float(np.sqrt(boundary.area / self._required_n))
def _candidate_origin(self, boundary):
return boundary.interior_point
def _required_candidates(self, boundary):
if not self.include_center:
return np.empty((0, 2))
return np.asarray(boundary.interior_point, dtype=float).reshape(1, 2)
@staticmethod
def _edge_clearance(boundary):
"""Inset of the outermost contour, so its points lie strictly inside."""
minx, miny, maxx, maxy = boundary.bounds
return max(float(np.hypot(maxx - minx, maxy - miny)) * 1e-7, 1e-9)
def _candidate_bounds(self, boundary, scale, phases, max_candidates):
# Where the second layer lies past the deepest point, the outermost
# contour is the only one, so its rings' lengths count the field.
clearance = self._edge_clearance(boundary)
deepest = _deepest(boundary.geometry)
outer = _outermost(boundary.geometry, clearance)
lengths = np.array(
[]
if clearance >= deepest or outer.is_empty
else [line.length for line in _contour_lines(outer)]
)
minx, miny, maxx, maxy = boundary.bounds
hull = boundary.geometry.convex_hull
separation = _MIN_SEPARATION * min(1.0, self.spacing_ratio)
def bound(scales):
scales = np.asarray(scales, dtype=float)
counts = np.round(lengths / (scales[:, None] * self.spacing_ratio))
totals = int(self.include_center) + np.maximum(1, counts.astype(int)).sum(
axis=1
)
# Nearer the edge, more layers may hold any number of points.
totals[clearance + scales < deepest] = np.iinfo(totals.dtype).max
# Thinning keeps no two points of a layer, or the center, within
# the separation, and layers lie a scale apart, farther still. So
# the bounding box holds at most one point per square whose
# diagonal is the separation, and Oler's inequality bounds how many
# the convex hull holds. The separation is a little smaller for
# rounding.
apart = separation * scales * (1.0 - 1e-9)
side = apart / np.sqrt(2.0)
boxed = (np.floor((maxx - minx) / side) + 1.0) * (
np.floor((maxy - miny) / side) + 1.0
)
oler = np.floor(
2.0 / np.sqrt(3.0) * hull.area / apart**2
+ hull.length / (2.0 * apart)
+ 1.0
)
# Capped below 2**63, so a tiny scale stays a large integer.
packed = np.minimum(np.minimum(boxed, oler), 2.0**62).astype(totals.dtype)
return np.repeat(np.minimum(totals, packed)[:, None], len(phases), axis=1)
return bound
def _candidate_fields(
self, boundary, scale, phases, max_candidates=None, min_candidates=None
):
if scale <= 0:
return [np.empty((0, 2)) for _ in phases]
clearance = self._edge_clearance(boundary)
minx, miny, maxx, maxy = boundary.bounds
maximum_layers = int(np.ceil(np.hypot(maxx - minx, maxy - miny) / scale)) + 1
if max_candidates is not None and maximum_layers > max_candidates:
return [None for _ in phases]
# Insets, their rings and point counts are shared by every phase.
contours = []
total = int(self.include_center)
deepest = _deepest(boundary.geometry)
inset = boundary.geometry
for layer_index in range(maximum_layers):
depth = clearance + layer_index * scale
# A buffer past the deepest point can leave a stray piece, which
# may lie outside the boundary, so no layer is laid there.
if depth >= deepest:
break
if layer_index == 0:
inset = _outermost(boundary.geometry, clearance)
elif layer_index == 1:
inset = boundary.geometry.buffer(-depth)
else:
# Insets by disks add, so a layer insets the one before it,
# whose smoother outline buffers several times faster than a
# cell cleared around many turbines.
inset = inset.buffer(-scale)
if inset.is_empty:
break
for contour_index, line in enumerate(_contour_lines(inset)):
length = line.length
count = max(1, int(round(length / (scale * self.spacing_ratio))))
total += count
if max_candidates is not None and total > max_candidates:
return [None for _ in phases]
contours.append((layer_index, contour_index, line, length, count))
# Insets are not clipped and thinning only drops points, so no phase
# holds more than ``total``.
if min_candidates is not None and total < min_candidates:
return [np.empty((0, 2)) for _ in phases]
if not contours:
# The edge clearance reaches the deepest point, so no layer fits
# and only a center remains.
center = np.asarray([boundary.interior_point], dtype=float)
return [center if self.include_center else np.empty((0, 2)) for _ in phases]
counts = np.asarray([count for *_, count in contours], dtype=int)
layers, rings, lengths = (
np.asarray([contour[column] for contour in contours], dtype=float)
for column in (0, 1, 3)
)
# Every phase at once, with the per-phase operations in the same order.
contour_phase, layer_phase = np.asarray(phases, dtype=float).reshape(-1, 2).T
offsets = (
contour_phase[:, None] + layers * layer_phase[:, None] + 0.5 * rings
) % 1.0
# The center, if any, comes first in every phase.
first = int(self.include_center)
points = np.empty((len(phases), first + int(counts.sum()), 2))
center = None
if self.include_center:
center = np.asarray(boundary.interior_point, dtype=float)
points[:, 0] = center
separation = _MIN_SEPARATION * scale * min(1.0, self.spacing_ratio)
dropped = np.zeros(points.shape[:2], dtype=bool)
stops = first + np.cumsum(counts)
starts = stops - counts
# The contours of a layer follow one another, from the outermost.
layer_ends = np.append(np.flatnonzero(np.diff(layers)) + 1, len(contours))
layer_starts = np.append(0, layer_ends[:-1])
# Layer by layer, innermost first, as most turbines the thinning drops
# lie there, so a scale it leaves short is mostly found before the
# rest are placed.
for begin, end in zip(layer_starts[::-1], layer_ends[::-1]):
for ring in range(begin, end):
steps = np.arange(counts[ring], dtype=float)
points[:, starts[ring] : stops[ring]] = _along(
contours[ring][2],
(steps + offsets[:, ring, None]) * lengths[ring] / counts[ring],
)
low, high = starts[begin], stops[end - 1]
dropped[:, low:high] = _dropped(points[:, low:high], center, separation)
if min_candidates is not None and np.all(
(~dropped).sum(axis=1) < min_candidates
):
return [np.empty((0, 2)) for _ in phases]
return [field[~mask] for field, mask in zip(points, dropped)]
def _nominal_scale(self, boundary, spacing, phase) -> float:
# Layers lie the scale apart and their points about the scale times
# spacing_ratio. Corners, necks and the center put a few points much
# closer, which would mislead a spacing measured from the field.
return spacing / min(1.0, self.spacing_ratio)
def _detailed_fit(self, boundary, positions, fit):
from ..boundaries.base import _farm_pieces
scale = float(fit["scale"])
clearance = self._edge_clearance(boundary)
distances = shapely.distance(
boundary.geometry.boundary, shapely.points(positions)
)
layer_indices = np.rint(np.maximum(0.0, distances - clearance) / scale).astype(
int
)
center_mask = np.zeros(len(positions), dtype=bool)
if self.include_center:
center = np.asarray(boundary.interior_point, dtype=float)
center_mask = np.all(np.isclose(positions, center), axis=1)
pieces = _farm_pieces(boundary)
# A farm filled over several parts lists its turbines part by part, so
# its layers are ordered and recorded within each part.
groups = (
[np.arange(len(positions))]
if len(pieces) == 1
else _grouped_by_piece(pieces, positions)
)
orders = []
layers = []
start = 0
for component_index, group in enumerate(groups):
group_layers = layer_indices[group]
group_center = center_mask[group]
if self.include_center:
non_center = group_layers[~group_center]
center_layer = int(non_center.max() + 1) if len(non_center) else 0
group_layers[group_center] = center_layer
order = np.argsort(group_layers, kind="stable")
group_layers = group_layers[order]
group_center = group_center[order]
group_distances = distances[group][order]
first = 0
for layer_index in np.unique(group_layers):
count = int(np.count_nonzero(group_layers == layer_index))
is_center = bool(np.all(group_center[first : first + count]))
layer = {
"layer_index": int(layer_index),
"kind": "center" if is_center else "contour",
"inset_distance": (
float(group_distances[first])
if is_center
else float(clearance + layer_index * scale)
),
"n_turbines": count,
"start": start,
"stop": start + count,
}
if len(groups) > 1:
layer = {"component_index": component_index, **layer}
layers.append(layer)
first += count
start += count
orders.append(group[order])
positions = np.asarray(positions)[np.concatenate(orders)]
return positions, {**fit, "spacing_ratio": self.spacing_ratio, "layers": layers}