Source code for oowl.layouts.circular

"""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, }