"""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"
)