Source code for oowl.boundaries.modifiers
"""Geometry modifiers shared by every boundary."""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Sequence
import numpy as np
import shapely
from shapely.geometry import MultiPolygon, Polygon
from shapely.geometry.base import BaseGeometry
from shapely.ops import unary_union
from .._random import spawn_seeds
from .._repr import _ArgumentRepr
from .._validation import _validate_real
from .base import Boundary
__all__ = ["BoundaryModifier", "ExclusionZones", "Setback"]
# Buffer segments per quadrant. More improve circularity at a cost in vertices.
_OFFSET_SEGMENTS = 32
[docs]
class BoundaryModifier(_ArgumentRepr, ABC):
"""Abstract transform applied to a boundary geometry."""
@property
def _in_meters(self) -> bool:
"""Whether the result depends on the meter scale of its input.
A resized boundary re-applies a chain holding such a modifier to its
resized base, so a distance or a position given in meters is kept.
A chain without one is scaled as a whole. Subclasses whose result
scales with the geometry may return ``False``.
"""
return True
[docs]
@abstractmethod
def apply(self, geometry: BaseGeometry) -> BaseGeometry:
"""Return a new geometry, leaving ``geometry`` unmodified.
Modifiers must be re-appliable to the same input without side effects.
"""
def _hole_geometry(index: int, hole) -> Polygon | MultiPolygon:
"""Return the polygonal geometry of exclusion zone ``index``.
A line or point has no area, so subtracting it would silently leave the
site whole, and an invalid polygon makes the GEOS overlay fail later.
"""
if isinstance(hole, Boundary):
return hole.geometry
if not isinstance(hole, (Polygon, MultiPolygon)):
raise TypeError(
f"holes[{index}] must be a Shapely Polygon, MultiPolygon or Boundary, "
f"not {type(hole).__name__}"
)
if not hole.is_valid:
raise ValueError(f"holes[{index}] must be a valid polygon")
return hole
[docs]
class ExclusionZones(BoundaryModifier):
"""Subtract exclusion zones, such as protected areas or cables, from a boundary."""
# Holes as given, so a boundary hole prints as the call that builds it.
_repr_attributes = {"holes": "_given_holes"}
def __init__(
self, holes: Sequence[shapely.Polygon | MultiPolygon | Boundary]
) -> None:
"""Collect the exclusion ``holes`` to subtract, in meter coordinates.
Each hole is a Shapely ``Polygon`` or ``MultiPolygon`` or a
``Boundary``, whose geometry is read once here. A random boundary hole
without a seed is redrawn for each farm a sampler draws. Holes are
fixed in the plane, so resizing the boundary moves its edge over them
rather than scaling them. Buffer a line, such as a cable route, to its
corridor width first. Raises ``TypeError`` for another kind of hole
and ``ValueError`` for an invalid polygon.
"""
if isinstance(holes, (BaseGeometry, Boundary)):
raise TypeError("holes must be a sequence of exclusion zones")
self._given_holes = list(holes)
self.holes = [
_hole_geometry(index, hole) for index, hole in enumerate(self._given_holes)
]
@property
def _in_meters(self) -> bool:
return bool(self.holes)
@staticmethod
def _unpinned(hole) -> bool:
return isinstance(hole, Boundary) and hole._is_random and not hole._pinned_seed
@property
def _is_random(self) -> bool:
return any(
isinstance(hole, Boundary) and hole._is_random for hole in self._given_holes
)
@property
def _pinned_seed(self) -> bool:
return not any(self._unpinned(hole) for hole in self._given_holes)
def _reseeded(self, seed: int) -> "ExclusionZones":
"""Return these zones, with unseeded random holes redrawn from ``seed``."""
if self._pinned_seed:
return self
seeds = spawn_seeds(seed, len(self._given_holes))
return ExclusionZones(
[
hole._reseeded(hole_seed) if self._unpinned(hole) else hole
for hole, hole_seed in zip(self._given_holes, seeds)
]
)
[docs]
def apply(self, geometry: BaseGeometry) -> BaseGeometry:
"""Return ``geometry`` with the union of all holes subtracted.
The result may be a multipolygon or empty.
"""
if not self.holes:
return geometry
return geometry.difference(unary_union(self.holes))
[docs]
class Setback(BoundaryModifier):
"""Hold turbines a fixed distance inside the boundary.
The distance is in meters and stays so when the boundary is resized.
"""
def __init__(self, distance: float) -> None:
"""Set the standoff ``distance`` from every boundary edge, in meters.
The margin applies to the outer ring and to the rim of any hole.
"""
distance = _validate_real("setback distance", distance)
if not np.isfinite(distance) or distance < 0.0:
raise ValueError("setback distance must be finite and >= 0")
self.distance = distance
@property
def _in_meters(self) -> bool:
return self.distance > 0.0
[docs]
def apply(self, geometry: BaseGeometry) -> BaseGeometry:
"""Return ``geometry`` eroded by at least ``distance`` on every edge.
The result may be a multipolygon. Raises ``ValueError`` when the setback
consumes the whole site. The buffer radius is divided by the half-angle
cosine so GEOS chords lie outside the true arc. The margin is then met
and exceeded by at most about 0.12 percent.
"""
if self.distance == 0.0:
return geometry
corrected = self.distance / np.cos(np.pi / (2 * _OFFSET_SEGMENTS))
inner = geometry.buffer(-corrected, quad_segs=_OFFSET_SEGMENTS)
if inner.is_empty or inner.area <= 0.0:
raise ValueError(
f"a setback of {self.distance:.4g} m removes the whole boundary"
)
return inner