Source code for oowl.layouts.sobol

"""Low-discrepancy Sobol layouts."""

from __future__ import annotations

import numpy as np
import shapely

from .base import Farm, LayoutAlgorithm

__all__ = ["Sobol"]

#: The longest sequence has 2**22 points, which bounds memory for boundaries
#: that fill little of their bounding box.
_MAX_SEQUENCE_POWER = 22


[docs] class Sobol(LayoutAlgorithm): """Place turbines using a scrambled Sobol low-discrepancy sequence. A Sobol sequence covers a rectangle more evenly than independent random draws. OOWL generates a balanced power-of-two sequence over the boundary's bounding box, retains points inside the boundary, and returns the first ``n_turbines`` accepted points. If the initial sequence is too short, its power is increased and the same scrambled sequence is regenerated. Unlike :class:`PoissonDisc`, this method imposes no minimum separation. A ``minimum_spacing`` sizes the farm by its closest pair, which one unlucky point sets, so the area or count it gives varies widely between seeds. It is useful when an irregular but spatially uniform layout is wanted without lattice alignment or a packing process. Parameters ---------- n_turbines: Exact number of turbines to generate. density: The one density setting the count, ``{"minimum_spacing": meters}`` for the most turbines with no pair closer, ``{"mean_spacing": meters}`` for the most whose :meth:`~oowl.Farm.mean_spacing` is at least it, ``{"maximum_spacing": meters}`` for the fewest leaving none farther from a neighbor, or ``{"area_per_turbine": square meters}``. seed: Random seed controlling the deterministic Sobol scramble. """ def __init__( self, n_turbines: int | None = None, *, density: dict[str, float] | None = None, seed: int | np.random.Generator | None = None, ) -> None: super().__init__( n_turbines, density=density, seed=seed, ) def _generate_single( self, boundary, rng: np.random.Generator, seed: int, ) -> Farm: from scipy.stats import qmc min_x, min_y, max_x, max_y = boundary.bounds width = max_x - min_x height = max_y - min_y box_area = width * height if not np.isfinite(box_area) or box_area <= 0: raise ValueError("Sobol requires a boundary with finite positive extent") area_fraction = boundary.area / box_area expected_size = max(self._required_n / area_fraction, 1.0) power = max(0, int(np.ceil(np.log2(expected_size)))) scramble_seed = int(rng.integers(0, np.iinfo(np.uint32).max, dtype=np.uint32)) while power <= _MAX_SEQUENCE_POWER: unit_points = qmc.Sobol( d=2, scramble=True, seed=scramble_seed ).random_base2(power) candidates = np.empty_like(unit_points) candidates[:, 0] = min_x + width * unit_points[:, 0] candidates[:, 1] = min_y + height * unit_points[:, 1] inside = shapely.contains_xy( boundary.geometry, candidates[:, 0], candidates[:, 1], ) accepted = candidates[inside] if len(accepted) >= self._required_n: positions = accepted[: self._required_n] return self._single_result( positions, boundary, seed, { "method": "sobol", "scramble_seed": scramble_seed, "sequence_size": int(len(candidates)), "accepted_candidates": int(len(accepted)), }, ) power += 1 most = 2**_MAX_SEQUENCE_POWER raise ValueError( f"Sobol draws at most {most:,} points over the boundary's bounding " f"box, which the boundary fills {area_fraction:.3g} of, so about " f"{int(most * area_fraction):,} land inside it, fewer than the " f"{self._required_n:,} turbines asked. Lower n_turbines or give a " "boundary that fills more of its box" )