Source code for oowl.layouts.perimeter

"""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