"""Poisson disc sampling layout using the algorithm of Bridson (2007).
Reference: https://doi.org/10.1145/1278780.1278807
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import numpy as np
import shapely
from shapely.affinity import scale as shp_scale
from shapely.affinity import translate as shp_translate
from .._random import farthest_point_sample
from .._validation import _validate_integer
from .pattern import _PatternLayout
if TYPE_CHECKING: # pragma: no cover
from .fitters import RigidPatternFitter
__all__ = ["PoissonDisc"]
#: Bridson's method at minimum separation ``r`` places about
#: ``_AREA_DENSITY * area / r**2 + _PERIMETER_DENSITY * perimeter / r`` points,
#: a least-squares fit over circles, rectangles, annuli and organic shapes.
_AREA_DENSITY = 0.63
_PERIMETER_DENSITY = 0.135
#: Points sampled per turbine kept. Few spare points leave the closest pair
#: of the farthest-point selection wider and the spacing more even.
_OVERSAMPLING = 1.05
#: A pattern of at most ``_FEW`` turbines is selected from about
#: ``_FEW_OVERSAMPLING`` times as many points, which widens its closest pair.
_FEW = 4
_FEW_OVERSAMPLING = 3.0
#: Largest and smallest factor by which one retry shrinks the separation.
_MAX_SHRINK = 0.98
_MIN_SHRINK = 0.5
_MINIMUM_DISTANCE_FLOOR = 1e-3
#: Grid cells of padding around the bounding box. A candidate lies less than
#: three cells from an accepted point, which lies inside the box, and its
#: 5x5 neighborhood reaches two cells further.
_PADDING = 5
#: Candidate offsets drawn per refill of the random buffer.
_BUFFER_ROWS = 4096
_MAX_K = 10_000
#: Most sampling-grid cells a fill allocates, which bounds its memory to
#: under a hundred megabytes and its count to about two million turbines.
_MAX_FILL_CELLS = 4_000_000
def _normalized_geometry(boundary):
"""``boundary`` geometry moved and scaled into the unit square."""
minx, miny, maxx, maxy = boundary.bounds
factor = 1.0 / max(maxx - minx, maxy - miny)
geometry = shp_translate(boundary.geometry, -minx, -miny)
return shp_scale(geometry, factor, factor, origin=(0, 0))
def _grid_shape(bounds, min_dist: float) -> tuple[int, int]:
"""Rows and columns of the padded sampling grid over ``bounds``."""
minx, miny, maxx, maxy = bounds
cell = min_dist / np.sqrt(2)
rows = int(np.ceil((maxy - miny) / cell)) + 1 + 2 * _PADDING
cols = int(np.ceil((maxx - minx) / cell)) + 1 + 2 * _PADDING
return rows, cols
def _expected_count(area: float, perimeter: float, min_dist: float) -> float:
"""Points Bridson's method places at ``min_dist``, from the fitted densities."""
return (
_AREA_DENSITY * area / min_dist**2 + _PERIMETER_DENSITY * perimeter / min_dist
)
def _distance_for_count(area: float, perimeter: float, count: float) -> float:
"""The ``min_dist`` at which :func:`_expected_count` equals ``count``."""
# Positive root of count r**2 - P_d perimeter r - A_d area = 0, in the
# form without cancellation.
a = _AREA_DENSITY * area
b = _PERIMETER_DENSITY * perimeter
return 2.0 * a / (np.sqrt(b * b + 4.0 * a * count) - b)
def _cell_classes(geom, origin, cell: float, shape: tuple[int, int]) -> np.ndarray:
"""Per grid cell 1 when inside ``geom``, 0 when outside, -1 when undecided.
A cell the boundary may cross is undecided and needs a point test. Every
other cell lies wholly on one side, the side of its center.
"""
rows, cols = shape
xs = origin[0] + (np.arange(cols) + 0.5) * cell
ys = origin[1] + (np.arange(rows) + 0.5) * cell
classes = shapely.contains_xy(geom, xs[None, :], ys[:, None]).astype(np.int8)
# Vertices a quarter cell apart come within an eighth of a cell of every
# boundary point, so each cell the boundary crosses holds a vertex or
# borders a cell that does.
ring = shapely.segmentize(geom.boundary, cell / 4.0)
ix = np.floor((shapely.get_coordinates(ring) - origin) / cell).astype(np.intp)
crossed = np.zeros((rows + 2, cols + 2), dtype=bool)
crossed[ix[:, 1] + 1, ix[:, 0] + 1] = True
near = np.zeros(shape, dtype=bool)
for dy in range(3):
for dx in range(3):
near |= crossed[dy : dy + rows, dx : dx + cols]
classes[near] = -1
return classes
def _bridson(geom, min_dist: float, k: int, rng) -> np.ndarray:
"""Bridson Poisson-disc sampling inside ``geom``, batched ``k`` at a time.
Each step draws ``k`` candidates around a random active point and accepts
the first that is inside ``geom`` and ``min_dist`` from every point, as
Bridson's one-at-a-time loop does.
"""
minx, miny, _, _ = geom.bounds
cell = min_dist / np.sqrt(2)
origin = np.array([minx, miny]) - _PADDING * cell
shape = _grid_shape(geom.bounds, min_dist)
width = shape[1]
classes = _cell_classes(geom, origin, cell, shape).ravel()
# At most one point fits in each cell. An empty cell holds an infinite
# position, which no candidate comes near.
cell_x = np.full(shape[0] * width, np.inf)
cell_y = np.full(shape[0] * width, np.inf)
placed: list[int] = []
active: list[int] = []
neighbors = np.array(
[dy * width + dx for dy in range(-2, 3) for dx in range(-2, 3)],
dtype=np.intp,
)
strides = np.array([1, width], dtype=np.intp)
min_dist_sq = min_dist**2
def _cells_of(points: np.ndarray) -> np.ndarray:
# The padding keeps every point right of and above the origin, so
# truncation rounds down.
return ((points - origin) / cell).astype(np.intp) @ strides
def _nearest_sq(points: np.ndarray, flat: np.ndarray) -> np.ndarray:
"""Squared distance from each point to the nearest placed point."""
around = flat[:, None] + neighbors
dx = cell_x[around]
dx -= points[:, :1]
dy = cell_y[around]
dy -= points[:, 1:]
dx *= dx
dy *= dy
dx += dy
return dx.min(axis=1)
def _place(point: np.ndarray, flat: int) -> None:
cell_x[flat] = point[0]
cell_y[flat] = point[1]
placed.append(flat)
active.append(flat)
# Seed from a guaranteed interior point of every polygon, so each part
# fills. A single polygon gets one seed, as Bridson's method has.
for part in getattr(geom, "geoms", (geom,)):
interior = part.representative_point()
point = np.array([[interior.x, interior.y]], dtype=float)
flat = _cells_of(point)
if _nearest_sq(point, flat)[0] >= min_dist_sq:
_place(point[0], int(flat[0]))
# A refill holds at least one batch.
rows = max(_BUFFER_ROWS, k)
def _offsets() -> np.ndarray:
draws = rng.random((rows, 2))
radii = min_dist * (1.0 + draws[:, 0])
angles = (2.0 * np.pi) * draws[:, 1]
return np.column_stack([radii * np.cos(angles), radii * np.sin(angles)])
offsets = _offsets()
picks = rng.random(_BUFFER_ROWS)
used = 0
picked = 0
while active:
if picked == len(picks):
picks = rng.random(_BUFFER_ROWS)
picked = 0
ai = int(picks[picked] * len(active))
picked += 1
if used + k > len(offsets):
offsets = np.concatenate([offsets[used:], _offsets()])
used = 0
flat = active[ai]
candidates = offsets[used : used + k] + (cell_x[flat], cell_y[flat])
cells = _cells_of(candidates)
kind = classes[cells]
open_ = (_nearest_sq(candidates, cells) >= min_dist_sq) & (kind != 0)
accepted = int(open_.argmax()) if open_.any() else -1
if accepted >= 0 and kind[accepted] < 0:
# The first open candidate in a cell wholly inside is accepted
# unless an earlier open one in an undecided cell passes the
# point test.
inside = open_ & (kind > 0)
stop = int(inside.argmax()) if inside.any() else k
undecided = np.flatnonzero(open_[:stop])
hits = np.flatnonzero(
shapely.contains_xy(
geom, candidates[undecided, 0], candidates[undecided, 1]
)
)
accepted = (
int(undecided[hits[0]]) if len(hits) else (stop if stop < k else -1)
)
if accepted >= 0:
# Only the draws up to the accepted candidate are consumed, as
# Bridson's one-at-a-time loop would.
used += accepted + 1
_place(candidates[accepted], int(cells[accepted]))
else:
used += k
active[ai] = active[-1]
active.pop()
return np.column_stack([cell_x[placed], cell_y[placed]])
[docs]
class PoissonDisc(_PatternLayout):
"""Place turbines by Poisson-disc sampling, a blue-noise pattern.
Bridson's algorithm, with ``k`` candidates per active point, fills a copy
of the boundary scaled into the unit square. Farthest-point sampling keeps
exactly ``n_turbines`` points, and the pattern is then scaled and translated
to fill the boundary. With ``minimum_spacing``, Bridson's algorithm fills the
boundary itself with that minimum distance, starting in every polygon, and
the count is however many points fit.
The minimum separation starts where Bridson's method is expected to place
5% more points than ``n_turbines``, or three times as many for at most
four turbines, from the area and perimeter of the normalized boundary.
While too few fit, each retry rescales that estimate by the count the
last run placed, shrinking by a factor of 0.5 to 0.98. Few spare points
keep the closest pair of the selection wide. The search stops at 1e-3 for
a square bounding box and lower for an elongated one, by the square root
of the short side over the long side, so the sampling grid keeps the same
number of cells. The value used is reported as
``metadata["fit"]["minimum_distance"]``. Raises ``ValueError`` if too few
points fit even then.
"""
fit_method = "poisson_disc"
_fills_directly = True
def __init__(
self,
n_turbines: int | None = None,
*,
density: dict[str, float] | None = None,
k: int = 30,
fitter: RigidPatternFitter | None = None,
seed: int | np.random.Generator | None = None,
):
self.k = _validate_integer("k", k)
if self.k > _MAX_K:
raise ValueError(f"k must be a positive integer <= {_MAX_K}")
super().__init__(
n_turbines,
density=density,
fitter=fitter,
seed=seed,
)
def _fill_positions(self, boundary, spacing, rng):
geometry = boundary.geometry
rows, cols = _grid_shape(geometry.bounds, spacing)
if rows * cols > _MAX_FILL_CELLS:
raise ValueError(
f"a spacing of {spacing:.4g} m is too small for this boundary, "
f"which would hold about {int(2 * geometry.area / spacing**2):,} "
"turbines"
)
positions = _bridson(geometry, spacing, self.k, rng)
return positions, {
"method": self.fit_method,
"minimum_distance": spacing,
"k": self.k,
}
def _pattern(self, boundary, rng):
n = self._required_n
# Sample a normalized boundary, then scale and translate the result.
geometry = _normalized_geometry(boundary)
# An elongated farm lowers the floor, keeping the grid's cell count.
minx, miny, maxx, maxy = geometry.bounds
floor = _MINIMUM_DISTANCE_FLOOR * np.sqrt(
min(maxx - minx, maxy - miny) / max(maxx - minx, maxy - miny)
)
area = geometry.area
perimeter = geometry.length
target = (_FEW_OVERSAMPLING if n <= _FEW else _OVERSAMPLING) * n
min_dist = max(_distance_for_count(area, perimeter, target), floor)
pts = _bridson(geometry, min_dist, self.k, rng)
while len(pts) < n and min_dist > floor:
# The fitted count, scaled to what this boundary placed, gives
# the separation at which the target fits.
placed = len(pts) / _expected_count(area, perimeter, min_dist)
shrink = _distance_for_count(area, perimeter, target / placed) / min_dist
min_dist *= min(max(shrink, _MIN_SHRINK), _MAX_SHRINK)
pts = _bridson(geometry, min_dist, self.k, rng)
if len(pts) < n:
raise ValueError(
f"Poisson-disc sampling fitted only {len(pts)} of {n} turbines, "
"a larger k may fit more"
)
positions = farthest_point_sample(pts, n, rng)
# Report inverse normalization as a fit hint for rigid placement.
minx, miny, maxx, maxy = boundary.bounds
hint_scale = max(maxx - minx, maxy - miny)
hint_translation = positions.mean(axis=0) * hint_scale + np.array([minx, miny])
return (
positions,
{
"minimum_distance": float(min_dist),
"k": self.k,
"fit_hint": (hint_scale, hint_translation),
},
None,
)