"""Turbine layouts distributed along string guide geometry."""
from __future__ import annotations
from copy import copy
from typing import TYPE_CHECKING
import numpy as np
import shapely
from .._random import derive_seed, resolve_seed
from ..strings import StringGuide
from .base import (
_SPACINGS,
Farm,
LayoutAlgorithm,
_allocate_counts,
_median_spacing,
_nearest_neighbor_distances,
)
from .pattern import _PatternLayout
if TYPE_CHECKING: # pragma: no cover
from ..boundaries.base import Boundary
from .fitters import RigidPatternFitter
__all__ = ["String"]
def _interpolate_segment(segment, count):
if count == 0:
return np.empty((0, 2))
distances = (
[segment.length / 2] if count == 1 else np.linspace(0.0, segment.length, count)
)
return shapely.get_coordinates(
shapely.line_interpolate_point(segment, np.asarray(distances, dtype=float))
)
[docs]
class String(_PatternLayout):
"""Distribute turbines along one or more string guides.
Turbines are shared between strings and their segments by arc length and
equally spaced along each segment. Boundary fitting keeps the relative
geometry and sets the scale. Without a boundary a guide has no size of its
own, so a spacing density, as ``density={"minimum_spacing": meters}``,
sets it beside ``n_turbines`` and one is required.
The farm carries the realized guide as ``farm.guide``. ``seed`` also
redraws every random part of the guide given no seed of its own, so each
sampled farm gets its own guide, and the farm records the guide it used.
"""
def __init__(
self,
strings: StringGuide,
n_turbines: int | None = None,
*,
density: dict[str, float] | None = None,
fitter: RigidPatternFitter | None = None,
seed: int | np.random.Generator | None = None,
):
super().__init__(
n_turbines,
density=density,
fitter=fitter,
seed=seed,
)
if not isinstance(strings, StringGuide):
raise TypeError("strings must be a StringGuide")
self.strings = strings
[docs]
def generate(self, boundary: "Boundary | None" = None) -> Farm:
layout = self
if not self.strings._pinned_seed:
# The guide is drawn from the layout's seed, so a farm replays it.
layout = copy(self)
layout.seed = resolve_seed(self.seed)
layout.strings = self.strings._reseeded(derive_seed(layout.seed, "strings"))
farm = LayoutAlgorithm.generate(layout, boundary)
farm.metadata["guide"] = layout.strings.metadata()
return farm
def _fill_estimate(
self, boundary: "Boundary", spacing: float, mean: bool = False
) -> int:
"""A first count from the guide fitted once at a moderate count.
Along a string the turbines lie evenly, so one estimate serves a
minimum and a mean ``spacing``.
The fitted guide keeps nearly its size whatever the count, so each of
its segments holds about its fitted length over the spacing, plus one.
Each part of a multipart boundary holds its own fitted guide.
"""
from ..boundaries.base import _farm_pieces, _GeometryBoundary
pieces = _farm_pieces(boundary)
if len(pieces) > 1:
# Parts share the count by area, so the part holding the fewest
# turbines for its area bounds the count.
area = sum(piece.area for piece in pieces)
return min(
int(
self._fill_estimate(_GeometryBoundary(piece), spacing, mean)
* area
/ piece.area
)
for piece in pieces
)
n_segments = sum(len(string.segments) for string in self.strings._strings)
# About four turbines a segment span nearly all of the guide, so its
# fitted size is that of the filled farm.
probe = self._with_count(max(16, 4 * n_segments))
probe.seed = resolve_seed(self.seed)
guide = probe.generate(boundary).guide
assert guide is not None
return sum(
int(segment.length // spacing) + 1
for string in guide._strings
for segment in string.segments
)
def _pattern(self, boundary, rng):
pattern, metadata, spacing_scale = self._build_pattern()
guide = self.strings._transformed(spacing_scale, (0.0, 0.0))
return pattern, metadata, guide
def _build_pattern(self):
logical_strings = self.strings._strings
string_counts = _allocate_counts(
self._required_n, [string.length for string in logical_strings]
)
positions = []
string_metadata = []
start = 0
for string_index, (logical_string, count) in enumerate(
zip(logical_strings, string_counts)
):
count = int(count)
segment_counts = (
_allocate_counts(
count, [segment.length for segment in logical_string.segments]
)
if count
else np.zeros(len(logical_string.segments), dtype=int)
)
segment_metadata = []
string_start = start
for segment_index, (segment, segment_count) in enumerate(
zip(logical_string.segments, segment_counts)
):
segment_count = int(segment_count)
stop = start + segment_count
positions.append(_interpolate_segment(segment, segment_count))
segment_metadata.append(
dict(
segment_index=segment_index,
n_turbines=segment_count,
start=start,
stop=stop,
)
)
start = stop
string_metadata.append(
dict(
string_index=string_index,
guide_type=logical_string.guide_type,
n_turbines=count,
start=string_start,
stop=start,
segments=segment_metadata,
)
)
pattern = np.concatenate(positions) if positions else np.empty((0, 2))
if len(pattern) > 1 and _nearest_neighbor_distances(pattern).min() == 0.0:
raise ValueError(
"the string guides place two turbines at the same point, so "
"move overlapping guides apart"
)
# The pattern is scaled to a unit median spacing before it is fitted.
spacing_scale = 1.0
median = _median_spacing(pattern)
if median is not None:
spacing_scale = 1.0 / median
pattern *= spacing_scale
return pattern, dict(strings=string_metadata), spacing_scale
def _generate_standalone(self, rng, seed):
sizing = next(
(name for name in _SPACINGS if getattr(self, name) is not None), None
)
if sizing is None:
raise ValueError(
"a String without a boundary needs a minimum, mean or maximum "
"spacing, since a guide has no size of its own"
)
spacing = getattr(self, sizing)
positions, metadata, spacing_scale = self._build_pattern()
if len(positions) > 1:
nearest = _nearest_neighbor_distances(positions)
held = {
"minimum_spacing": nearest.min,
"mean_spacing": nearest.mean,
"maximum_spacing": nearest.max,
}[sizing]()
factor = spacing / float(held)
positions = positions * factor
spacing_scale *= factor
center = positions.mean(axis=0)
positions -= center
guide = self.strings._transformed(spacing_scale, -center)
return self._single_result(
positions,
None,
seed,
{
"method": "standalone",
"scale": 1.0,
"translation": (-center).tolist(),
sizing: spacing,
**metadata,
},
guide=guide,
)