"""Boundary partition modifiers used by farms and clusters."""
from __future__ import annotations
from abc import ABC, abstractmethod
from collections.abc import Sequence
from copy import copy
import numpy as np
import shapely
from shapely import affinity
from shapely.errors import GEOSException
from shapely.geometry import LineString, MultiPoint, MultiPolygon, Point, Polygon
from shapely.geometry.base import BaseGeometry
from shapely.ops import split, unary_union
from .._random import as_rng, derive_seed, resolve_seed, spawn_seeds
from .._validation import _validate_integer, _validate_nonnegative, _validate_real_array
from ..strings import Straight, StringGuide
from .base import _translated_copies
from .modifiers import BoundaryModifier
__all__ = ["Partition"]
#: Largest accepted error of a fitted power-cell area, as a fraction of the parent.
_AREA_FRACTION_TOLERANCE = 0.01
#: Site draws tried when a power-diagram fit is locally singular.
_MAX_SITE_FIT_ATTEMPTS = 4
#: Area-fraction error at which the power-weight fit stops, far below the
#: accepted error so the cell areas follow their weights exactly.
_POWER_FIT_TOLERANCE = 1e-10
#: Newton steps and halvings of each step the power-weight fit tries.
_POWER_FIT_ITERATIONS = 100
_POWER_LINE_SEARCH_STEPS = 40
#: Relative change of the power-weight dual within its rounding, from areas
#: and second moments summed over the cells.
_DUAL_ROUNDING = 1e-12
#: Length, as a share of the region's span, that the power-weight fit adds
#: to every cell pair's shared edge in its curvature. An edge across a gap
#: or hole, or a cell emptied by a step, has no length inside the region,
#: so the areas stay flat as it moves, and this still points the step on.
#: Near the answer it changes a Newton step by about this share. Over 900
#: diagrams 0.001 fitted as fast as 0.0001 and 25 % faster than 0.01.
_PAIR_EDGE_SHARE = 0.001
#: Relative component area below which clipping artifacts are discarded.
_SPLINTER_AREA = 1e-12
#: Segments per quarter circle of the half gap grown around each cell. The
#: grown cell circumscribes its arcs, which widens the gap by a share of
#: ``1 / cos(pi / 128) - 1``, 3e-4.
_GAP_QUAD_SEGMENTS = 32
def _scaled_details(details: dict, factor: float, origin) -> dict:
"""Return a partition record scaled by ``factor`` about ``origin``.
A boundary scaled as a whole scales its cells, so the sites and channel
lines recorded beside them scale alike, and power weights, squared
distances, by the square of ``factor``.
"""
origin = np.asarray(origin, dtype=float)
def scaled(points):
return (origin + factor * (np.asarray(points, dtype=float) - origin)).tolist()
record = dict(details)
if "sites" in record:
record["sites"] = scaled(record["sites"])
if "channels" in record:
record["channels"] = [scaled(line) for line in record["channels"]]
if "power_weights_m2" in record:
weights = np.asarray(record["power_weights_m2"], dtype=float)
record["power_weights_m2"] = (factor**2 * weights).tolist()
return record
# Grids for cutting gaps, relative to the span of the partitioned boundary.
# Floating-point overlay fails where a vertex lies within rounding error of a
# cutter edge. Snap rounding on a grid coarser than the one power cells lie on
# separates them, while rounding onto that same grid fails often, so these
# stay well above ``_partition_precision``. A coarser grid is tried if needed.
_CUT_GRIDS = (1e-9, 1e-8, 1e-7)
def _span(geometry) -> float:
"""Return the bounding-box diagonal of ``geometry``, at least 1 m."""
min_x, min_y, max_x, max_y = geometry.bounds
return max(float(np.hypot(max_x - min_x, max_y - min_y)), 1.0)
def _partition_precision(geometry) -> float:
"""Return a scale-aware grid that makes shared power-cell edges identical."""
return _span(geometry) * 1e-12
def _polygon_components(geometry) -> list[Polygon]:
"""Return every genuine positive-area polygon component."""
if isinstance(geometry, Polygon):
parts = [geometry]
elif isinstance(geometry, MultiPolygon):
parts = list(geometry.geoms)
else:
parts = [
part for part in shapely.get_parts(geometry) if isinstance(part, Polygon)
]
return [part for part in parts if not part.is_empty and part.area > 0.0]
def _polygons(geometry) -> list[Polygon]:
"""Return polygon components of an overlay result, dropping splinters.
Components below ``_SPLINTER_AREA`` of the largest are overlay artifacts.
"""
parts = _polygon_components(geometry)
if len(parts) < 2:
return parts
largest = max(part.area for part in parts)
return [part for part in parts if part.area >= _SPLINTER_AREA * largest]
#: Share of one farm's area below which a piece of the boundary joins its
#: nearest neighbor rather than taking a farm of its own.
_MIN_PIECE_SHARE = 0.5
def _farm_components(geometry, n_farms: int) -> list[Polygon | MultiPolygon]:
"""The polygon components of ``geometry``, merged until each holds a farm.
A setback or exclusion zone can split a boundary into more pieces than
farms, or pinch off a piece too small for one. The smallest group joins
the one nearest it while there are more groups than farms or it holds
under :data:`_MIN_PIECE_SHARE` of a farm's area, so a farm takes whole
neighboring pieces as one cell. A sliver pinched off had taken a farm
of its own, and the cut its neighbor needed vanished.
"""
groups = _polygon_components(geometry)
total = sum(group.area for group in groups)
while len(groups) > 1:
areas = [group.area for group in groups]
smallest = int(np.argmin(areas))
enough = areas[smallest] * n_farms >= _MIN_PIECE_SHARE * total
if len(groups) <= n_farms and enough:
break
piece = groups.pop(smallest)
distances = shapely.distance(piece, np.asarray(groups, dtype=object))
nearest = int(np.argmin(distances))
groups[nearest] = MultiPolygon(
[*_polygon_components(groups[nearest]), *_polygon_components(piece)]
)
return groups
def _allocate_components(total: int, geometries: list) -> np.ndarray:
"""Split ``total`` cells across ``geometries`` by area, at least one each.
``geometries`` holds at most ``total`` entries, as :func:`_farm_components`
gives. Largest-remainder rounding, with ties broken by order.
"""
areas = np.asarray([geometry.area for geometry in geometries], dtype=float)
quotas = total * areas / areas.sum()
counts = np.floor(quotas).astype(int)
remainder = total - int(counts.sum())
if remainder:
# Round fractional quotas for deterministic ties.
fractions = np.round(quotas - counts, 9)
order = np.argsort(-fractions, kind="stable")
counts[order[:remainder]] += 1
# Ensure each positive component receives one cell.
for missing in np.flatnonzero(counts == 0):
donors = np.flatnonzero(counts > 1)
surplus = np.round(counts[donors] - quotas[donors], 9)
donor = donors[int(np.argmax(surplus))]
counts[donor] -= 1
counts[missing] = 1
return counts
def _sample_points(geometry: Polygon, count: int, rng) -> np.ndarray:
"""Draw ``count`` uniform points strictly inside ``geometry`` by rejection."""
min_x, min_y, max_x, max_y = geometry.bounds
points: list[tuple[float, float]] = []
batch = max(32, 4 * count)
attempts = 0
while len(points) < count:
candidates = rng.uniform((min_x, min_y), (max_x, max_y), size=(batch, 2))
inside = shapely.contains_xy(geometry, candidates[:, 0], candidates[:, 1])
points.extend(map(tuple, candidates[inside]))
attempts += batch
if attempts > 100_000 and len(points) < count:
raise ValueError("could not sample Voronoi sites inside the boundary")
return np.asarray(points[:count], dtype=float)
def _sites_by_component(components, sites: np.ndarray) -> list[np.ndarray]:
"""``sites`` grouped by the component nearest each, every one given one.
Raises ``ValueError`` when a component is left without a site.
"""
distances = shapely.distance(
np.asarray(components, dtype=object)[:, None],
shapely.points(sites)[None, :],
)
nearest = np.argmin(distances, axis=0)
grouped = [sites[nearest == index] for index in range(len(components))]
if any(not len(group) for group in grouped):
raise ValueError("the carried sites leave a part of the boundary without one")
return grouped
def _validate_sites(sites, n_farms: int) -> np.ndarray | None:
"""Return ``sites`` as ``n_farms`` distinct finite points, or ``None``."""
if sites is None:
return None
values = _validate_real_array("sites", sites)
if values.shape != (n_farms, 2) or not np.all(np.isfinite(values)):
raise ValueError("sites must contain one finite (x, y) point per farm")
if len(np.unique(values, axis=0)) != len(values):
raise ValueError("sites must be distinct")
return values
def _voronoi_cells(
geometry: Polygon, sites: np.ndarray
) -> list[Polygon | MultiPolygon]:
"""Return the Voronoi cell of each site clipped to ``geometry``, in site order."""
if len(sites) == 1:
return [geometry]
envelope = geometry.envelope.buffer(_span(geometry))
diagram = shapely.voronoi_polygons(MultiPoint(sites), extend_to=envelope)
candidates = list(shapely.get_parts(diagram))
cells: list[Polygon | MultiPolygon] = []
for site in sites:
point = Point(site)
matches = [cell for cell in candidates if cell.covers(point)]
if len(matches) != 1:
raise ValueError("Voronoi construction did not produce one cell per site")
clipped = matches[0].intersection(geometry)
polygons = _polygons(clipped)
if not polygons:
raise ValueError("Voronoi construction produced an empty cell")
cell = unary_union(polygons)
if not cell.is_valid or cell.area <= 0:
raise ValueError("Voronoi construction produced an invalid cell")
cells.append(cell)
return cells
def _gap_cut(cells, extent, gap: float) -> list:
"""Each of ``cells`` without the points within half of ``gap`` of another.
Two cells then lie at least ``gap`` apart wherever the boundary lies
between them, along a shared edge, around a corner and where an edge
meets the boundary, and the cut moves continuously as the cells do. Each
grown cell circumscribes its arcs, so no kept point lies closer than half
the gap. Shared edges extended across the boundary had cut cells
kilometers from any edge, and jumped as the boundary grew.
"""
if gap == 0 or len(cells) < 2:
return list(cells)
radius = 0.5 * gap / np.cos(np.pi / (4 * _GAP_QUAD_SEGMENTS))
grown = [cell.buffer(radius, quad_segs=_GAP_QUAD_SEGMENTS) for cell in cells]
cut = []
for index, cell in enumerate(cells):
near = [
other
for position, other in enumerate(grown)
if position != index and other.intersects(cell)
]
if near:
cut.extend(_subtract([cell], shapely.union_all(near), extent))
else:
cut.append(cell)
return cut
def _subtract(geometries, cutter, extent) -> list:
"""Return each geometry minus ``cutter`` by snap-rounded overlay.
The cutter grows by two grid cells first, since rounding the cutter and
rounding the cut each move a vertex by less than one, so a cut is never
narrower than asked. Raises ``ValueError`` when the overlay fails on every
grid, like the other partition failures.
"""
span = _span(extent)
for relative in _CUT_GRIDS:
grid = span * relative
widened = cutter.buffer(2.0 * grid, join_style="mitre")
try:
return [
shapely.difference(geometry, widened, grid_size=grid)
for geometry in geometries
]
except GEOSException:
continue
raise ValueError("the gap could not be cut from the partition cells")
def _cleaned_cells(cells, name: str) -> list:
"""Return ``cells`` without splinters, refusing one the gap removed or broke."""
cleaned_cells = []
for cell in cells:
polygons = _polygons(cell)
if not polygons:
raise ValueError(f"gap is too wide for a {name} cell, which it removes")
cleaned = unary_union(polygons)
if not cleaned.is_valid or cleaned.area <= 0:
raise ValueError(f"gap produces an invalid {name} cell")
cleaned_cells.append(cleaned)
return cleaned_cells
class _PartitionModifier(BoundaryModifier, ABC):
"""A boundary modifier that splits a boundary and keeps its cells apart.
``n_farms`` is the exact number of cells, ``gap`` the distance in meters
kept between neighboring cells, and ``seed`` drives any random choice.
The gap stays in meters when the boundary is resized. ``details`` holds a
JSON-compatible record of the last partition.
"""
#: Whether the partition is cut around sites, which it samples if not given.
_has_sites: bool = False
#: Whether the sites were carried from another size of the boundary.
_carried: bool = False
def __init__(
self,
n_farms: int,
*,
gap: float = 0.0,
seed: int | np.random.Generator | None = None,
) -> None:
self.n_farms = _validate_integer("n_farms", n_farms)
self.gap = _validate_nonnegative("gap", gap)
self._pinned_seed = seed is not None
self.seed = resolve_seed(seed)
self.details: dict = {}
@property
def _in_meters(self) -> bool:
return self.gap > 0.0
@property
def _samples_sites(self) -> bool:
"""Whether the partition draws its own sites."""
return self._has_sites and getattr(self, "sites", None) is None
def _carrying(self, sites: np.ndarray) -> "_PartitionModifier":
"""A copy cutting its cells around ``sites`` carried from another size.
A resized boundary samples its sites once, at the size its base is
given, and carries them with the base, so its cells grow with it.
Sampled again at each size tried, a site near an exclusion zone was
kept at one size and rejected at the next, and the area jumped. A
carried site may lie in a zone the base grew around, where it still
bounds its cell, and each joins the component nearest it.
"""
carried = copy(self)
setattr(carried, "sites", np.asarray(sites, dtype=float))
carried._carried = True
return carried
@abstractmethod
def partition(self, geometry: BaseGeometry) -> tuple[Polygon | MultiPolygon, ...]:
"""Return the ``n_farms`` cells of ``geometry`` in a deterministic order."""
def apply(self, geometry: BaseGeometry) -> BaseGeometry:
"""Return the union of the cells ``partition`` retains.
Cells cut with no gap tile ``geometry``, which is returned as it is.
Use ``partition`` to keep individual cells.
"""
cells = self.partition(geometry)
return geometry if self.gap == 0 else unary_union(cells)
def __repr__(self) -> str:
# The classes are public only through the ``Partition`` namespace.
return f"Partition.{super().__repr__()}"
[docs]
class Voronoi(_PartitionModifier):
"""Partition a boundary into Voronoi cells clipped to it.
Each cell holds the points nearer its site than any other.
Parameters
----------
n_farms:
Exact number of partition cells.
sites:
Optional finite ``(x, y)`` site per farm, fixed in the plane, so a
resized boundary must still contain them. Sites are sampled uniformly
inside the boundary when omitted.
gap:
Distance in meters kept between cells, half taken from each.
seed:
Random seed used when sampling sites.
"""
_has_sites = True
def __init__(
self,
n_farms: int,
*,
sites: Sequence[Sequence[float]] | np.ndarray | None = None,
gap: float = 0.0,
seed: int | np.random.Generator | None = None,
) -> None:
super().__init__(n_farms, gap=gap, seed=seed)
self.sites = _validate_sites(sites, self.n_farms)
@property
def _in_meters(self) -> bool:
return super()._in_meters or self.sites is not None
[docs]
def partition(self, geometry: BaseGeometry) -> tuple[Polygon | MultiPolygon, ...]:
"""Return Voronoi cells clipped to ``geometry``, one per site.
Sites in a ``MultiPolygon`` parent are allocated across components by
area, at least one each. With more components than farms the smallest
joins its nearest until each farm has one. Holes are preserved. Each
cell loses the points within ``gap / 2`` of another. Sampled sites are redrawn
a few times when the gap removes a whole cell, and given sites or a
last draw that still loses one raise ``ValueError``. Sampled sites
replay from ``seed`` and the cell order is deterministic. Sites and
the draws taken are recorded in ``details``.
"""
components = _farm_components(geometry, self.n_farms)
if not components:
raise ValueError("partition boundary must contain polygonal geometry")
counts = _allocate_components(self.n_farms, components)
rng = as_rng(self.seed)
if self.sites is not None and self._carried:
component_sites = _sites_by_component(components, self.sites)
elif self.sites is not None:
if not np.all(
shapely.intersects_xy(geometry, self.sites[:, 0], self.sites[:, 1])
):
raise ValueError("sites must lie inside the boundary")
component_sites = []
for component in components:
mask = shapely.intersects_xy(
component, self.sites[:, 0], self.sites[:, 1]
)
component_sites.append(self.sites[mask])
if [len(values) for values in component_sites] != counts.tolist():
raise ValueError(
"sites must follow the area allocation across disconnected components"
)
def cells_of(component_sites):
cells_found = []
for component, sites in zip(components, component_sites):
cells = _voronoi_cells(component, sites)
cells = _gap_cut(cells, component, self.gap)
cells_found.extend(_cleaned_cells(cells, "Voronoi"))
return cells_found
site_attempts = 1
if self.sites is not None:
all_cells = cells_of(component_sites)
else:
# Two sites drawn close together bound a small cell, which a gap
# can remove whole, so sampled sites are redrawn a few times.
for site_attempts in range(1, _MAX_SITE_FIT_ATTEMPTS + 1):
component_sites = [
_sample_points(component, int(count), rng)
for component, count in zip(components, counts)
]
try:
all_cells = cells_of(component_sites)
except ValueError:
if site_attempts == _MAX_SITE_FIT_ATTEMPTS:
raise
else:
break
all_sites = [site for sites in component_sites for site in sites.tolist()]
self.details = {
"method": "voronoi",
"n_farms": self.n_farms,
"gap": self.gap,
"seed": self.seed,
"site_attempts": site_attempts,
"sites": all_sites,
}
return tuple(all_cells)
def _clipped_convex(
vertices: np.ndarray,
labels: list[int],
normal: np.ndarray,
cutoff: float,
label: int,
) -> tuple[np.ndarray, list[int]]:
"""Clip the convex ring ``vertices`` to ``normal @ point <= cutoff``.
Sutherland-Hodgman clipping. ``labels[k]`` names the line the edge from
vertex ``k`` lies on, and an edge along the clipping line gets ``label``.
A vertex on the line is kept once, so no vertex repeats.
"""
values = vertices @ normal - cutoff
inside = values <= 0.0
if inside.all():
return vertices, labels
if not inside.any():
return vertices[:0], []
def crossing(index: int, following: int) -> np.ndarray:
weight = values[index] / (values[index] - values[following])
return vertices[index] + weight * (vertices[following] - vertices[index])
clipped = []
clipped_labels = []
for index in range(len(vertices)):
following = (index + 1) % len(vertices)
if inside[index] and inside[following]:
clipped.append(vertices[index])
clipped_labels.append(labels[index])
elif inside[index] and values[index] < 0.0:
clipped += [vertices[index], crossing(index, following)]
clipped_labels += [labels[index], label]
elif inside[index]:
clipped.append(vertices[index])
clipped_labels.append(label)
elif inside[following] and values[following] < 0.0:
clipped.append(crossing(index, following))
clipped_labels.append(labels[index])
return np.asarray(clipped), clipped_labels
def _power_regions(
geometry: Polygon, sites: np.ndarray, weights: np.ndarray
) -> list[tuple[np.ndarray, list[int]]]:
r"""Return each site's convex power region around ``geometry``.
The region of site ``i`` is a box around ``geometry`` clipped to the
half-planes where
:math:`\lVert x-p_i\rVert^2-w_i \le \lVert x-p_j\rVert^2-w_j` for every
other site ``j``. Each region is its ring and the site index ``j`` of
each edge, ``-1`` for an edge of the box. An empty region has no vertices.
"""
min_x, min_y, max_x, max_y = geometry.bounds
margin = max(max_x - min_x, max_y - min_y, 1.0)
box = np.asarray(
[
[min_x - margin, min_y - margin],
[max_x + margin, min_y - margin],
[max_x + margin, max_y + margin],
[min_x - margin, max_y + margin],
]
)
squared_norms = np.einsum("ij,ij->i", sites, sites)
regions = []
for index, site in enumerate(sites):
vertices, labels = box, [-1] * 4
for other_index, other_site in enumerate(sites):
if index == other_index:
continue
normal = 2.0 * (other_site - site)
if not normal.any():
raise ValueError("power-diagram sites must be distinct")
cutoff = (
squared_norms[other_index]
- squared_norms[index]
+ weights[index]
- weights[other_index]
)
vertices, labels = _clipped_convex(
vertices, labels, normal, float(cutoff), other_index
)
if len(vertices) < 3:
vertices, labels = vertices[:0], []
break
regions.append((vertices, labels))
return regions
def _power_cells(geometry: Polygon, regions) -> list[Polygon | MultiPolygon | None]:
"""Return each power region clipped to ``geometry``, or ``None`` where empty."""
polygons = np.asarray(
[Polygon(vertices) if len(vertices) else Polygon() for vertices, _ in regions],
dtype=object,
)
cells: list[Polygon | MultiPolygon | None] = []
for cell in shapely.intersection(geometry, polygons):
parts = _polygons(cell)
cells.append(unary_union(parts) if parts else None)
return cells
def _facet_lengths(geometry: Polygon, regions) -> np.ndarray:
"""Return the length inside ``geometry`` of the edge shared by each region pair.
Each edge is measured once, from the region of the lower site index.
"""
pairs = []
segments = []
for index, (vertices, labels) in enumerate(regions):
for position, other_index in enumerate(labels):
if other_index > index:
following = (position + 1) % len(vertices)
pairs.append((index, other_index))
segments.append(LineString([vertices[position], vertices[following]]))
lengths = np.zeros((len(regions), len(regions)))
if pairs:
rows, columns = np.asarray(pairs).T
measured = shapely.length(shapely.intersection(geometry, segments))
lengths[rows, columns] = measured
lengths[columns, rows] = measured
return lengths
def _area_fractions(cells, area: float) -> np.ndarray:
"""Return each cell's share of ``area``, counting a missing cell as zero."""
return np.asarray([0.0 if cell is None else cell.area / area for cell in cells])
def _second_moment(geometry, point) -> float:
"""The integral over ``geometry`` of the squared distance to ``point``.
Oriented rings run counterclockwise round each shell and clockwise round
each hole, so the signed sum over every edge subtracts the holes.
"""
rings = shapely.get_rings(shapely.get_parts(shapely.orient_polygons(geometry)))
coordinates, ring = shapely.get_coordinates(rings, return_index=True)
joined = ring[1:] == ring[:-1]
x0, y0 = (coordinates[:-1][joined] - point).T
x1, y1 = (coordinates[1:][joined] - point).T
cross = x0 * y1 - x1 * y0
return float(
np.sum(cross * (x0 * x0 + x0 * x1 + x1 * x1 + y0 * y0 + y0 * y1 + y1 * y1))
/ 12.0
)
def _fit_power_cells(
geometry: Polygon,
sites: np.ndarray,
target_fractions: np.ndarray,
):
"""Fit power weights so cell areas match ``target_fractions``.
Returns the cells, the weights in square meters and a JSON-compatible fit
record. Raises ``ValueError`` when a cell stays empty or an area misses its
target by more than ``_AREA_FRACTION_TOLERANCE``.
"""
if len(sites) == 1:
return (
[geometry],
np.zeros(1),
{
"success": True,
"evaluations": 0,
"max_area_fraction_error": 0.0,
},
)
# Translate to the centroid before power fitting to preserve precision.
origin = np.asarray(geometry.centroid.coords[0], dtype=float)
local_geometry = affinity.translate(
geometry, xoff=-float(origin[0]), yoff=-float(origin[1])
)
local_sites = sites - origin
span = max(
local_geometry.bounds[2] - local_geometry.bounds[0],
local_geometry.bounds[3] - local_geometry.bounds[1],
1.0,
)
weight_scale = float(span**2)
def weights_from_free(free_weights: np.ndarray) -> np.ndarray:
"""Return square-meter weights for ``free_weights``, pinning the last to zero.
The diagram is invariant to a common shift of every weight, so one site
is fixed while the rest are scaled by the squared boundary span.
"""
return np.append(np.asarray(free_weights, dtype=float), 0.0) * weight_scale
area = local_geometry.area
targets = np.asarray(target_fractions, dtype=float)
def evaluate(free_weights: np.ndarray):
"""The dual value, cell area fractions and power regions at the weights.
The dual of fitting the areas, the sum over cells of the integral of
the power distance plus each weight times its target area, is concave
in the weights for any region, also one in pieces or with holes, and
its gradient is each target area less the cell's area.
"""
weights = weights_from_free(free_weights)
regions = _power_regions(local_geometry, local_sites, weights)
cells = _power_cells(local_geometry, regions)
fractions = _area_fractions(cells, area)
moments = sum(
_second_moment(cell, site)
for cell, site in zip(cells, local_sites)
if cell is not None
)
value = (
moments / (area * weight_scale)
+ weights @ (targets - fractions) / weight_scale
)
return value, fractions, regions
distances = np.linalg.norm(local_sites[:, None] - local_sites[None], axis=-1)
np.fill_diagonal(distances, np.inf)
free = np.zeros(len(sites) - 1)
value, fractions, regions = evaluate(free)
evaluations = 1
converged = False
for _ in range(_POWER_FIT_ITERATIONS):
errors = targets - fractions
if np.max(np.abs(errors)) <= _POWER_FIT_TOLERANCE:
converged = True
break
lengths = _facet_lengths(local_geometry, regions) + _PAIR_EDGE_SHARE * span
rates = lengths / (2.0 * distances)
curvature = (np.diag(rates.sum(axis=1)) - rates)[:-1, :-1]
step = np.linalg.solve(curvature * weight_scale / area, errors[:-1])
slope = float(errors[:-1] @ step)
scale = 1.0
for _ in range(_POWER_LINE_SEARCH_STEPS):
trial = free + scale * step
trial_value, trial_fractions, trial_regions = evaluate(trial)
evaluations += 1
# Near the answer the dual gains less than its rounding, so a step
# that shrinks the largest area error without losing more than
# rounding counts as progress too. Taken while the dual fell, such
# steps had cycled through layouts with four cells empty.
if trial_value >= value + 1e-4 * scale * slope or (
trial_value >= value - _DUAL_ROUNDING * (1.0 + abs(value))
and np.max(np.abs(targets - trial_fractions)) < np.max(np.abs(errors))
):
break
scale /= 2.0
else:
break
free, value, fractions, regions = (
trial,
trial_value,
trial_fractions,
trial_regions,
)
weights = weights_from_free(free)
local_cells = _power_cells(
local_geometry, _power_regions(local_geometry, local_sites, weights)
)
fractions = _area_fractions(local_cells, local_geometry.area)
maximum_error = float(np.max(np.abs(fractions - target_fractions)))
if any(cell is None for cell in local_cells) or (
maximum_error > _AREA_FRACTION_TOLERANCE
):
raise ValueError(
"could not fit nonempty power-diagram cells to the requested area weights"
)
cells = [
None
if cell is None
else affinity.translate(cell, xoff=float(origin[0]), yoff=float(origin[1]))
for cell in local_cells
]
return (
cells,
weights,
{
"success": converged,
"evaluations": evaluations,
"max_area_fraction_error": maximum_error,
},
)
[docs]
class PowerDiagram(_PartitionModifier):
r"""Partition one boundary into cells with controlled relative areas.
Cell ``i`` contains points where its weighted squared distance
.. math:: d_i(x) = \lVert x-p_i\rVert^2-w_i
is no larger than for any other site, the power diagram of Aurenhammer
(1987, `doi:10.1137/0216006 <https://doi.org/10.1137/0216006>`_).
Additive weights ``w_i`` are fitted so cell areas follow ``area_weights``
before any gap is removed.
Parameters
----------
n_farms:
Exact number of partition cells.
area_weights:
Positive relative target areas, one per farm. Equal by default.
sites:
Optional finite ``(x, y)`` site per farm. Sites are sampled inside the
boundary when omitted.
gap:
Distance in meters kept between cells, half taken from each.
seed:
Random seed used when sampling sites.
"""
_has_sites = True
def __init__(
self,
n_farms: int,
*,
area_weights: Sequence[float] | None = None,
sites: Sequence[Sequence[float]] | np.ndarray | None = None,
gap: float = 0.0,
seed: int | np.random.Generator | None = None,
) -> None:
super().__init__(n_farms, gap=gap, seed=seed)
self.sites = _validate_sites(sites, self.n_farms)
if area_weights is None:
values = np.ones(self.n_farms, dtype=float)
else:
values = _validate_real_array("area_weights", area_weights)
if values.shape != (self.n_farms,):
raise ValueError("area_weights must contain one value per farm")
if not np.all(np.isfinite(values)) or np.any(values <= 0):
raise ValueError("area_weights must be finite and positive")
self.area_fractions = values / values.sum()
@property
def _in_meters(self) -> bool:
return super()._in_meters or self.sites is not None
[docs]
def partition(self, geometry: BaseGeometry) -> tuple[Polygon | MultiPolygon, ...]:
"""Return power cells of ``geometry``, one per site.
Holes are honored. A parent in several disconnected polygons, as a
setback or exclusion zone can leave, is fitted as one region, so the
areas stay exact and a cell may take parts of several polygons.
Unseeded sites are redrawn a few times before a failed area fit
propagates. Each cell loses the points within ``gap / 2`` of another, and a
gap that consumes a whole cell raises ``ValueError``. Cell order is
deterministic. ``details`` records the sites, target, achieved and
retained area fractions, and the fitted weights in
``details["power_weights_m2"]``.
"""
components = _polygon_components(geometry)
if not components:
raise ValueError("partition boundary must contain polygonal geometry")
# GIS data often stores one connected polygon as a MultiPolygon.
geometry = components[0] if len(components) == 1 else MultiPolygon(components)
if self.sites is None:
rng = as_rng(self.seed)
for site_attempts in range(1, _MAX_SITE_FIT_ATTEMPTS + 1):
sites = _sample_points(geometry, self.n_farms, rng)
try:
fitted_cells, weights, fit = _fit_power_cells(
geometry, sites, self.area_fractions
)
except ValueError:
if site_attempts == _MAX_SITE_FIT_ATTEMPTS:
raise
else:
break
else:
site_attempts = 1
sites = self.sites.copy()
inside = shapely.intersects_xy(geometry, sites[:, 0], sites[:, 1])
if not self._carried and not np.all(inside):
raise ValueError("sites must lie inside the boundary")
# Duplicate supplied sites are rejected by ``_validate_sites``.
fitted_cells, weights, fit = _fit_power_cells(
geometry, sites, self.area_fractions
)
cells = [cell for cell in fitted_cells if cell is not None]
# Apply one common precision grid before finding shared cell edges.
precision = _partition_precision(geometry)
cells = [shapely.set_precision(cell, precision) for cell in cells]
achieved_fractions = [float(cell.area / geometry.area) for cell in cells]
cells = _gap_cut(cells, geometry, self.gap)
cleaned_cells = _cleaned_cells(cells, "power-diagram")
self.details = {
"method": "power_diagram",
"n_farms": self.n_farms,
"gap": self.gap,
"seed": self.seed,
"site_attempts": site_attempts,
"sites": sites.tolist(),
"target_area_fractions": self.area_fractions.tolist(),
"achieved_area_fractions": achieved_fractions,
"retained_area_fractions": [
float(cell.area / geometry.area) for cell in cleaned_cells
],
"power_weights_m2": weights.tolist(),
"fit": fit,
}
return tuple(cleaned_cells)
def _normalised_guide(guide: StringGuide) -> LineString:
"""Return the guide moved and turned to run from (0, 0) to (1, 0)."""
geometry = guide.geometry
if not isinstance(geometry, LineString):
raise ValueError("channel guide must contain one continuous string")
# Which side of a self-crossing cut lies below it is undefined, and every
# cut along one failed.
if not geometry.is_simple:
raise ValueError("channel guide must not cross itself")
coordinates = np.asarray(geometry.coords, dtype=float)
chord = coordinates[-1] - coordinates[0]
length = float(np.linalg.norm(chord))
if length == 0:
raise ValueError("channel guide endpoints must be distinct")
angle = -np.degrees(np.arctan2(chord[1], chord[0]))
transformed = affinity.translate(
geometry, xoff=-coordinates[0, 0], yoff=-coordinates[0, 1]
)
transformed = affinity.rotate(transformed, angle, origin=(0, 0))
transformed = affinity.scale(transformed, 1.0 / length, 1.0 / length, origin=(0, 0))
return transformed
def _guide_frame(
template: LineString, geometry
) -> tuple[LineString, float, np.ndarray]:
"""Return the guide stretched across ``geometry``, its scale span and centroid.
The halfway point of the guide lies on the x axis, so a cut through the
centroid follows the guide where it is curved rather than its chord.
"""
span = _span(geometry)
line = affinity.scale(template, 2.5 * span, 2.5 * span, origin=(0, 0))
line = affinity.translate(line, xoff=-1.25 * span)
# An arc of more than about 140 degrees bows so far from its chord that a
# cut along the chord through the cell never meets the curve.
bow = line.interpolate(0.5, normalized=True).y
if bow != 0.0:
line = affinity.translate(line, yoff=-bow)
return line, span, np.asarray(geometry.centroid.coords[0])
def _sides(geometry, line: LineString, region: Polygon) -> list | None:
"""Return the parts of ``geometry`` inside and outside ``region``, or ``None``.
``geometry`` is split along ``line``, the edge of ``region`` within it, and
each face goes to the side holding it. Splinter faces are dropped as in
``_polygons``. A side made of several faces is a ``MultiPolygon``. Returns
``None`` when the overlay fails, a side is empty, or a side is invalid.
"""
try:
faces = shapely.get_parts(split(geometry, line))
except (GEOSException, ValueError):
return None
faces = faces[shapely.get_type_id(faces) == shapely.GeometryType.POLYGON]
areas = shapely.area(faces)
faces = faces[areas >= _SPLINTER_AREA * areas.max(initial=0.0)]
if not len(faces):
return None
inside = shapely.contains(region, shapely.point_on_surface(faces))
if inside.all() or not inside.any():
return None
sides = [shapely.union_all(faces[inside]), shapely.union_all(faces[~inside])]
if not all(side.is_valid for side in sides):
return None
return sides
def _channel(lines, gap: float):
"""Return the band ``gap`` wide along each of ``lines``, with flat ends."""
return shapely.buffer(
lines, gap / 2.0, quad_segs=16, cap_style="flat", join_style="mitre"
)
def _region_below(line: LineString, span: float) -> Polygon:
"""Return the region under ``line``, closed far below and beside it.
``line`` runs left to right in its own frame, as ``_guide_frame`` returns
it. The closing edges lie more than ``span`` from the frame origin, so
within that distance the region's edge is ``line`` alone.
"""
coordinates = np.asarray(line.coords, dtype=float)
margin = 2.0 * span
left = coordinates[:, 0].min() - margin
right = coordinates[:, 0].max() + margin
low = coordinates[:, 1].min() - margin
start, end = coordinates[0], coordinates[-1]
closing = [(right, end[1]), (right, low), (left, low), (left, start[1])]
return Polygon([*map(tuple, coordinates), *closing])
[docs]
class Channels(_PartitionModifier):
"""Partition a boundary by recursive cuts along a string guide.
Each cut halves the number of farms left in a cell. Its orientation is
drawn at random and its offset gives that share of the area exactly. The
parts of a cell on one side of the cut form one new cell, so a cut across
a bay of the boundary leaves a cell in several parts. For a guide that is
a graph over its chord the cut moves continuously with the cell. The cuts
are chosen before any gap is removed, so a resized boundary is cut alike
and the area left changes smoothly with its size. Each cell then loses a
band ``gap`` wide along every cut that split one of its parents.
Parameters
----------
n_farms:
Exact number of partition cells.
guide:
``StringGuide`` with one continuous string and distinct endpoints
that does not cross itself, stretched across the cell to shape each
cut. ``Straight()`` by default.
A random guide given no seed is redrawn from ``seed``, so every
reseeded partition bends its channels anew. A seeded guide keeps its
shape.
gap:
Width in meters of the channel each cut removes.
seed:
Random seed for the cut orientations and an unseeded guide.
Raises
------
ValueError
If ``guide`` is not one continuous string with distinct endpoints, if
it crosses itself, if no orientation gives a cut with two valid sides,
or if the gap is too wide for a cell.
"""
def __init__(
self,
n_farms: int,
*,
guide: StringGuide | None = None,
gap: float = 0.0,
seed: int | np.random.Generator | None = None,
) -> None:
super().__init__(n_farms, gap=gap, seed=seed)
if guide is None:
guide = Straight()
if not isinstance(guide, StringGuide):
raise TypeError("guide must be a StringGuide")
# Reject a guide of several strings here rather than at the first cut.
_normalised_guide(guide)
self.guide = guide
def _realized_guide(self) -> StringGuide:
"""Return the guide the cuts follow, an unseeded one drawn from ``seed``."""
# The guide's own unseeded draw would stay fixed while a sampler
# reseeds the partition, giving every farm the same bend.
return self.guide._reseeded(derive_seed(self.seed, "guide"))
def _best_cut(self, geometry, template: LineString, target_fraction: float, rng):
"""Return the two sides of the cut and its line, the first side below it.
The cut's orientation is drawn at random, and its offset along the
normal is root-found so the cell's area below the guide is exactly
``target_fraction``. The faces the cut leaves go to the side holding
them, so a side may have several parts. For a guide that is a graph
over its chord, such as ``Straight`` or ``Organic``, the root is
unique and the cut moves continuously with the cell. A later of 32
orientations is tried only when the overlay fails or leaves an empty
or invalid side.
"""
# SciPy loads only when a partition needs it, keeping ``import oowl`` light.
from scipy.optimize import brentq
base, span, centroid = _guide_frame(template, geometry)
region = _region_below(base, span)
heights = np.asarray(base.coords, dtype=float)[:, 1]
area = geometry.area
points = shapely.get_coordinates(geometry)
def excess(offset: float, below: Polygon, normal: np.ndarray) -> float:
moved = _translated_copies(below, centroid + offset * normal)[0]
return shapely.intersection(geometry, moved).area / area - target_fraction
for angle in rng.uniform(0.0, 180.0, size=32):
angle = float(angle)
radians = np.deg2rad(angle)
normal = np.asarray([-np.sin(radians), np.cos(radians)])
rotated = affinity.rotate(base, angle, origin=(0, 0), use_radians=False)
below = affinity.rotate(region, angle, origin=(0, 0), use_radians=False)
reach = (points - centroid) @ normal
try:
# Below the lowest offset the region misses the cell, above
# the highest it holds the whole cell.
offset = brentq(
excess,
reach.min() - heights.max(),
reach.max() - heights.min(),
args=(below, normal),
xtol=1e-12 * span,
)
shift = centroid + offset * normal
line = _translated_copies(rotated, shift)[0]
sides = _sides(geometry, line, _translated_copies(below, shift)[0])
except GEOSException:
sides = None
if sides is not None:
return sides, line
raise ValueError("could not find a channel that splits the boundary in two")
def _partition_component(
self, geometry, template: LineString, n_farms: int, seed: int
):
"""Return the cells and channel lines of one connected component."""
pending: list[tuple[BaseGeometry, int, int, tuple[int, ...]]] = [
(geometry, n_farms, seed, ())
]
cells = []
# The indices in ``channels`` of the cuts that split each cell's parents.
ancestry = []
channels = []
while pending:
cell, leaves, child_seed, cuts = pending.pop()
if leaves == 1:
cells.append(cell)
ancestry.append(cuts)
continue
left_leaves = leaves // 2
right_leaves = leaves - left_leaves
pieces, line = self._best_cut(
cell, template, left_leaves / leaves, as_rng(child_seed)
)
channels.append(line)
cuts = (*cuts, len(channels) - 1)
child_seeds = spawn_seeds(child_seed, 2)
pending.append((pieces[1], right_leaves, child_seeds[1], cuts))
pending.append((pieces[0], left_leaves, child_seeds[0], cuts))
if self.gap == 0 or not channels:
return cells, channels
# A band is removed only from the cells its cut split, so it never
# reaches into a neighbor across an earlier cut.
bands = _channel(channels, self.gap)
cells = [
_subtract([cell], shapely.union_all(bands[list(cuts)]), geometry)[0]
for cell, cuts in zip(cells, ancestry)
]
return _cleaned_cells(cells, "Channels"), channels
[docs]
def partition(self, geometry: BaseGeometry) -> tuple[Polygon | MultiPolygon, ...]:
"""Return the cells cut from ``geometry`` by recursive channel splits.
Cells in a ``MultiPolygon`` parent are allocated across components by
area, at least one each. With more components than farms the smallest
joins its nearest until each farm has one. Holes survive the cuts.
Cells and their order replay from
``seed``. The guide and the channel lines are recorded in ``details``.
"""
components = _farm_components(geometry, self.n_farms)
if not components:
raise ValueError("partition boundary must contain polygonal geometry")
counts = _allocate_components(self.n_farms, components)
seeds = spawn_seeds(self.seed, len(components))
guide = self._realized_guide()
template = _normalised_guide(guide)
cells = []
channels = []
for component, count, seed in zip(components, counts, seeds):
local_cells, local_channels = self._partition_component(
component, template, int(count), seed
)
cells.extend(local_cells)
channels.extend(local_channels)
self.details = {
"method": "channels",
"n_farms": self.n_farms,
"gap": self.gap,
"seed": self.seed,
"guide": guide.metadata(),
"channels": [list(map(list, line.coords)) for line in channels],
}
return tuple(cells)
class Partition:
"""Namespace for concrete boundary-partition modifiers."""
Channels = Channels
PowerDiagram = PowerDiagram
Voronoi = Voronoi
def __new__(cls, *args, **kwargs):
raise TypeError(
"use Partition.Channels, Partition.PowerDiagram, or Partition.Voronoi"
)