"""Boundary-filling circular candidate layout."""
from __future__ import annotations
from typing import TYPE_CHECKING
import numpy as np
from .._validation import _validate_finite, _validate_positive
from .candidate import _CandidateLayout, _clip_fields, _ray_counter, _reach
from .fitters import SelectedCircularFitter
if TYPE_CHECKING: # pragma: no cover
from ..boundaries.base import Boundary
from .fitters import SelectedCandidateFitter
__all__ = ["Circular"]
[docs]
class Circular(_CandidateLayout):
"""Occupy selected positions from concentric circular rings.
Rings are centered on the boundary centroid, or on an interior point when
the centroid lies outside, and clipped to the farm. The fitter finds the
largest radial spacing that leaves at least ``n_turbines`` candidates, then
selects an exact, well-distributed subset, so rings may be incomplete.
``spacing_ratio`` is the target spacing along a ring divided by the radial
spacing, ``rotation`` turns the rings counterclockwise in degrees, and
``include_center`` keeps a turbine at the center.
"""
def __init__(
self,
n_turbines: int | None = None,
*,
density: dict[str, float] | None = None,
spacing_ratio: float = 1.0,
rotation: float = 0.0,
include_center: bool = True,
fitter: SelectedCandidateFitter | None = None,
seed: int | np.random.Generator | None = None,
):
self.spacing_ratio = _validate_positive("spacing_ratio", spacing_ratio)
self.rotation = _validate_finite("rotation", rotation)
self.include_center = bool(include_center)
if fitter is None:
fitter = SelectedCircularFitter()
super().__init__(
n_turbines,
density=density,
fitter=fitter,
seed=seed,
)
[docs]
def estimate_spacing(self, boundary: Boundary) -> float:
"""Return a starting radial spacing in meters.
Assumes one square ``area / n_turbines`` cell per turbine. The estimate
only seeds the fitter's search and is not the final spacing.
"""
return float(np.sqrt(boundary.area / self._required_n))
def _candidate_origin(self, boundary):
return boundary.interior_point
def _required_candidates(self, boundary):
if not self.include_center:
return np.empty((0, 2))
return np.asarray(boundary.interior_point, dtype=float).reshape(1, 2)
def _ring_angles(self, phases, counts):
"""Each point's ring index and each phase's point angles in radians."""
ring_index = np.repeat(np.arange(len(counts)), counts)
ring_count_per_point = counts[ring_index].astype(float)
ring_starts = np.cumsum(counts) - counts
position_in_ring = np.arange(len(ring_index)) - ring_starts[ring_index]
# numpy.linspace(0, 2 pi, count, endpoint=False) is i * (2 pi / count).
base_angles = position_in_ring * (2 * np.pi / ring_count_per_point)
base_rotation = np.deg2rad(self.rotation)
# Every phase at once, with the per-phase operations in the same order.
ring_phase, angular_phase = np.asarray(phases, dtype=float).reshape(-1, 2).T
offset = (
base_rotation
+ ring_phase[:, None] * 2 * np.pi / ring_count_per_point
+ ring_index * angular_phase[:, None] * 2 * np.pi / ring_count_per_point
)
return ring_index, offset + base_angles
def _candidate_bounds(self, boundary, scale, phases, max_candidates):
if scale <= 0:
return None
center = np.asarray(boundary.interior_point, dtype=float)
ring_count = int(np.floor(_reach(boundary, center) / scale)) + 1
sizes = 2 * np.pi * np.arange(1, ring_count + 1) / self.spacing_ratio
# A ring size this close to a half would round differently at some
# scales, so its rings could hold other points than the bound counts.
if np.any(np.abs(sizes - np.floor(sizes) - 0.5) <= 1e-9 * sizes):
return None
counts = np.maximum(1, np.rint(sizes).astype(int))
total = int(self.include_center) + int(counts.sum())
# A larger scale has no more rings, of the same sizes.
if max(ring_count, total) > max_candidates:
return None
ring_index, angles = self._ring_angles(phases, counts)
units = (ring_index + 1.0)[:, None] * np.stack(
[np.cos(angles), np.sin(angles)], axis=-1
)
if self.include_center:
units = np.concatenate([np.zeros((len(units), 1, 2)), units], axis=1)
phase = np.repeat(np.arange(len(units)), units.shape[1])
return _ray_counter(
boundary, center, units.reshape(-1, 2), phase, len(units), scale
)
def _candidate_fields(
self, boundary, scale, phases, max_candidates=None, min_candidates=None
):
if scale <= 0:
return [np.empty((0, 2)) for _ in phases]
center = np.asarray(boundary.interior_point, dtype=float)
maximum_radius = _reach(boundary, center)
ring_count = int(np.floor(maximum_radius / scale)) + 1
if max_candidates is not None and ring_count > max_candidates:
return [None for _ in phases]
radii = scale * np.arange(1, ring_count + 1, dtype=float)
# Ring sizes do not depend on the phase, so neither does the cap.
counts = np.maximum(
1, np.rint(2 * np.pi * radii / (scale * self.spacing_ratio)).astype(int)
)
total = int(self.include_center) + int(counts.sum())
if max_candidates is not None and total > max_candidates:
return [None for _ in phases]
if min_candidates is not None and total < min_candidates:
return [np.empty((0, 2)) for _ in phases]
ring_index, angles = self._ring_angles(phases, counts)
ring_radius = radii[ring_index]
points = center + ring_radius[:, None] * np.stack(
[np.cos(angles), np.sin(angles)], axis=-1
)
if self.include_center:
centers = np.broadcast_to(center, (len(points), 1, 2))
points = np.concatenate([centers, points], axis=1)
return _clip_fields(boundary, list(points))
def _detailed_fit(self, boundary, positions, fit):
return positions, {
**fit,
"spacing_ratio": self.spacing_ratio,
"rotation": self.rotation,
"include_center": self.include_center,
}