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