"""Primitive turbine-string guide geometries."""
from __future__ import annotations
from typing import TYPE_CHECKING
import numpy as np
from shapely.affinity import rotate as shp_rotate
from shapely.affinity import translate as shp_translate
from shapely.geometry import LineString
from .._random import as_rng, resolve_seed
from .._validation import (
_validate_finite,
_validate_integer,
_validate_nonnegative,
_validate_positive,
_validate_real_array,
)
from .base import StringGuide, _LogicalString
if TYPE_CHECKING: # pragma: no cover
from numpy.typing import ArrayLike
__all__ = ["Straight", "Arc", "Organic", "Polyline"]
def _validate_center(center):
values = _validate_real_array("center", center)
if values.shape != (2,) or not np.all(np.isfinite(values)):
raise ValueError("center must contain two finite coordinates")
return (float(values[0]), float(values[1]))
def _place(line, rotation, center):
line = shp_rotate(line, rotation, origin=(0, 0), use_radians=False)
return shp_translate(line, xoff=center[0], yoff=center[1])
[docs]
class Straight(StringGuide):
"""A straight string guide.
The line of ``length`` runs along x, turns counterclockwise by
``rotation`` degrees and is centered on ``center``.
"""
def __init__(
self,
length: float = 1.0,
*,
rotation: float = 0.0,
center: tuple[float, float] = (0.0, 0.0),
):
length = _validate_positive("length", length)
rotation = _validate_finite("rotation", rotation)
center = _validate_center(center)
line = LineString([(-length / 2, 0), (length / 2, 0)])
super().__init__(
(_LogicalString("Straight", (_place(line, rotation, center),)),)
)
[docs]
class Arc(StringGuide):
"""A circular arc whose chord is horizontal before rotation.
``span`` is the arc's angle in degrees. A positive span bends toward +y
and a negative one toward -y. The arc's midpoint sits at ``center``, and
``rotation`` turns it counterclockwise in degrees.
"""
def __init__(
self,
radius: float = 1.0,
*,
span: float = 90.0,
rotation: float = 0.0,
center: tuple[float, float] = (0.0, 0.0),
):
radius = _validate_positive("radius", radius)
span = _validate_finite("span", span)
if not 0 < abs(span) < 360:
raise ValueError("abs(span) must be between 0 and 360 degrees")
rotation = _validate_finite("rotation", rotation)
center = _validate_center(center)
half = np.deg2rad(abs(span)) / 2
theta = np.linspace(-half, half, 129)
direction = 1.0 if span > 0 else -1.0
x = radius * np.sin(theta)
y = direction * radius * (1 - np.cos(theta))
line = _place(LineString(np.column_stack([x, y])), rotation, center)
super().__init__((_LogicalString("Arc", (line,)),))
[docs]
class Organic(StringGuide):
r"""A smooth random guide constructed from low-frequency harmonics.
The lateral offset along the normalized length :math:`s` is
.. math:: y(s)=\sum_{k=1}^{n} a_k \frac{\sin(k\pi s)}{k^2},
with standard normal :math:`a_k` and ``n = n_harmonics``. It is scaled so
the peak offset is ``roughness * length``. ``rotation`` and ``center``
place it as in :class:`Straight`. Without ``seed``, a
:class:`~oowl.layouts.String` layout draws a new shape for every farm.
"""
def __init__(
self,
length: float = 1.0,
*,
roughness: float = 0.25,
n_harmonics: int = 4,
rotation: float = 0.0,
center: tuple[float, float] = (0.0, 0.0),
seed: int | np.random.Generator | None = None,
):
length = _validate_positive("length", length)
roughness = _validate_nonnegative("roughness", roughness)
n_harmonics = _validate_integer("n_harmonics", n_harmonics)
rotation = _validate_finite("rotation", rotation)
center = _validate_center(center)
self._pinned_seed = seed is not None
self.seed = resolve_seed(seed)
rng = as_rng(self.seed)
progress = np.linspace(0.0, 1.0, 257)
x = length * (progress - 0.5)
coefficients = rng.normal(size=int(n_harmonics))
y = sum(
coefficient * np.sin(index * np.pi * progress) / index**2
for index, coefficient in enumerate(coefficients, start=1)
)
peak = float(np.max(np.abs(y)))
if peak > 0:
y = roughness * length * y / peak
line = _place(LineString(np.column_stack([x, y])), rotation, center)
super().__init__((_LogicalString("Organic", (line,)),))
def _polyline_coordinates(values) -> np.ndarray:
"""Return ``values`` as a copied finite ``(N, 2)`` array of distinct steps."""
try:
coordinates = _validate_real_array("coordinates", values).copy()
except ValueError as error:
raise ValueError("coordinates must have shape (N, 2)") from error
if coordinates.ndim == 0:
raise TypeError(
f"coordinates must be an (N, 2) array, not {type(values).__name__}"
)
if coordinates.ndim != 2 or coordinates.shape[1] != 2:
raise ValueError("coordinates must have shape (N, 2)")
if len(coordinates) < 2:
raise ValueError("coordinates must contain at least two points")
if not np.all(np.isfinite(coordinates)):
raise ValueError("coordinates must contain only finite coordinates")
if np.any(np.all(coordinates[1:] == coordinates[:-1], axis=1)):
raise ValueError("coordinates must not repeat a point consecutively")
with np.errstate(over="ignore"):
length = LineString(coordinates).length
if not np.isfinite(length):
raise ValueError("coordinates must span a finite length")
return coordinates
[docs]
class Polyline(StringGuide):
"""One string through given vertices, in the order given.
``coordinates`` is an ``(N, 2)`` array of at least two points, such as a
cable route or a measured turbine row. It is kept as given, since a
:class:`~oowl.layouts.String` layout sets the scale and position. As in
:class:`~oowl.boundaries.Polygon`, input is never repaired, so a point
repeated consecutively is refused rather than dropped. Scalar or
non-numeric ``coordinates`` raise ``TypeError``. A wrong shape, fewer than
two points, non-finite values, a repeated point or a length that overflows
raise ``ValueError``. Like the ``exterior`` of
:class:`~oowl.boundaries.Polygon`, the repr lists up to four vertices and
gives a longer array by its shape.
"""
def __init__(self, coordinates: ArrayLike):
self.coordinates = _polyline_coordinates(coordinates)
line = LineString(self.coordinates)
super().__init__((_LogicalString("Polyline", (line,)),))