"""Spatial metrics for generated wind-farm layouts.
Each metric accepts a :class:`~oowl.layouts.base.Farm` or an ``(N, 2)`` array of
turbine positions, ``N`` zero included, and a metric undefined for so few
turbines is ``nan``. Area-based metrics use ``Farm.boundary`` when available and
fall back to the convex hull of the turbines. Pass an explicit ``boundary``, a
:class:`~oowl.boundaries.base.Boundary` or a Shapely polygon, to override, or
``boundary=False`` to force the hull fallback.
"""
from __future__ import annotations
from collections.abc import Iterable, Sequence
import numpy as np
import shapely
from shapely import contains_xy
from shapely.geometry.base import BaseGeometry
from ._validation import _validate_nonnegative, _validate_positive, _validate_real_array
#: Most hexagons :func:`hexagon_centers` lays out over a polygon's box.
_MAX_HEXAGONS = 10_000_000
__all__ = [
"nearest_neighbor_distances",
"n_turbines",
"nearest_neighbor_mean",
"nearest_neighbor_variation",
"hull_area",
"region_area",
"density",
"clark_evans",
"aspect_ratio",
"bounding_box_diagonal",
"ripley_k",
"ripley_l",
"quadrat_window",
"hexagon_vertices",
"hexagon_centers",
"quadrat_counts",
"quadrat_p_value",
"SCALAR_METRICS",
"DEFAULT_SUMMARY_METRICS",
"summary",
]
def _as_positions(obj: object) -> np.ndarray:
"""Validated ``(N, 2)`` finite float positions from a Farm or array.
A layout without turbines is valid, and a metric undefined for it is ``nan``.
"""
positions = obj.positions if hasattr(obj, "positions") else obj
positions = _validate_real_array("positions", positions)
if positions.size == 0:
positions = positions.reshape(0, 2)
if positions.ndim != 2 or positions.shape[1] != 2:
raise ValueError("positions must have shape (N, 2)")
if not np.all(np.isfinite(positions)):
raise ValueError("positions must contain only finite values")
return positions
def _as_radii(radii: Sequence[float] | np.ndarray) -> np.ndarray:
"""Radii in meters as a 1-D finite float array, shape ``(len(radii),)``."""
values = _validate_real_array("radii", radii)
if values.ndim != 1:
raise ValueError("radii must have shape (M,)")
if not np.all(np.isfinite(values)):
raise ValueError("radii must contain only finite values")
return values
def _resolve_boundary(obj, boundary):
"""Effective boundary, explicit argument first, else ``Farm.boundary``.
``boundary=False`` returns ``None`` to force the convex-hull fallback.
"""
from .boundaries.base import Boundary
if boundary is False:
return None
if boundary is not None:
if not isinstance(boundary, (Boundary, BaseGeometry)):
raise TypeError(
"boundary must be a Boundary, a Shapely geometry, None or False, "
f"not {type(boundary).__name__}"
)
return boundary
return getattr(obj, "boundary", None)
def _boundary_geometry(boundary):
"""Shapely geometry for a Boundary, or the geometry itself, or ``None``."""
if boundary is None:
return None
return getattr(boundary, "geometry", boundary)
def _region_geometry(obj, boundary) -> BaseGeometry:
"""Study-region geometry, the resolved boundary or else the position hull."""
geometry = _boundary_geometry(_resolve_boundary(obj, boundary))
if geometry is None:
geometry = shapely.multipoints(_as_positions(obj)).convex_hull
return geometry
[docs]
def n_turbines(obj: object) -> int:
"""Number of turbines in the layout, from a Farm or an ``(N, 2)`` array."""
return int(len(_as_positions(obj)))
[docs]
def nearest_neighbor_distances(obj: object) -> np.ndarray:
"""Nearest-neighbor distance in meters per turbine, shape ``(N,)``.
``nan`` for a single turbine, which has no neighbor.
"""
layout = _as_positions(obj)
if len(layout) < 2:
return np.full(len(layout), np.nan)
from .layouts.base import _nearest_neighbor_distances
return _nearest_neighbor_distances(layout)
[docs]
def nearest_neighbor_mean(obj: object) -> float:
"""Mean nearest-neighbor distance in meters between turbine positions.
``nan`` for fewer than two turbines.
"""
nnd = nearest_neighbor_distances(obj)
return float(nnd.mean()) if len(nnd) else float("nan")
[docs]
def nearest_neighbor_variation(obj: object) -> float:
"""Coefficient of variation of the nearest-neighbor distances.
Near 0 for a regular layout, larger for irregular or clustered ones. ``nan``
when the mean distance is zero or undefined.
"""
nnd = nearest_neighbor_distances(obj)
mean = float(nnd.mean()) if len(nnd) else float("nan")
if not np.isfinite(mean) or mean == 0.0:
return float("nan")
return float(nnd.std() / mean)
[docs]
def hull_area(obj: object) -> float:
"""Convex-hull area in square meters of the positions, ignoring any boundary.
``nan`` when the hull encloses no area.
"""
layout = _as_positions(obj)
if len(layout) < 3:
return float("nan")
from scipy.spatial import ConvexHull, QhullError
try:
return float(ConvexHull(layout).volume)
except QhullError:
return float("nan")
[docs]
def region_area(obj: object, boundary: object = None) -> float:
"""Study-region area in square meters, from the boundary or else the hull."""
resolved = _resolve_boundary(obj, boundary)
if resolved is not None:
area = getattr(resolved, "area", None)
if area is None:
area = _boundary_geometry(resolved).area
return float(area)
return hull_area(obj)
[docs]
def density(obj: object, boundary: object = None) -> float:
"""Turbines per square meter over the study region."""
a = region_area(obj, boundary)
return len(_as_positions(obj)) / a if a > 0 else float("nan")
[docs]
def clark_evans(obj: object, boundary: object = None) -> float:
"""Clark-Evans ratio R of the mean nearest-neighbor distance to its CSR value.
Under complete spatial randomness (CSR) the expected mean is
``0.5 * sqrt(A / N)`` with ``A`` the study-region area. ``R > 1`` indicates
regularity, ``R < 1`` clustering and ``R ~ 1`` CSR. ``nan`` when the area is
not positive or there are no turbines.
See Clark and Evans (1954), Ecology 35(4), https://doi.org/10.2307/1931034.
"""
a = region_area(obj, boundary)
layout = _as_positions(obj)
if not a > 0 or not len(layout):
return float("nan")
expected = 0.5 * np.sqrt(a / len(layout))
return float(nearest_neighbor_mean(layout) / expected)
[docs]
def aspect_ratio(obj: object) -> float:
"""Square root of the largest over the smallest position covariance eigenvalue.
This is the ratio of the spreads along the principal axes, 1 for an
isotropic layout and large for an elongated one. ``nan`` for a single
turbine or a degenerate spread, such as a straight row.
"""
layout = _as_positions(obj)
if len(layout) < 2:
return float("nan")
cov = np.cov(layout.T)
eig = np.linalg.eigvalsh(cov)
# Rounding in the covariance sums leaves collinear positions a smallest
# eigenvalue of order N eps times the largest, which is not a real spread.
if eig.min() <= len(layout) * np.finfo(float).eps * eig.max():
return float("nan")
return float(np.sqrt(eig.max() / eig.min()))
[docs]
def bounding_box_diagonal(obj: object) -> float:
"""Diagonal length in meters of the axis-aligned bounding box of the positions.
``nan`` for a layout without turbines.
"""
layout = _as_positions(obj)
if not len(layout):
return float("nan")
dx = layout[:, 0].max() - layout[:, 0].min()
dy = layout[:, 1].max() - layout[:, 1].min()
return float(np.sqrt(dx**2 + dy**2))
[docs]
def ripley_k(
obj: object,
radii: Sequence[float] | np.ndarray,
boundary: object = None,
) -> np.ndarray:
"""Ripley's K(r) in square meters at the given radii in meters.
``K(r) = A / N^2 * sum_{i != j} 1[d_ij <= r]`` with ``A`` the study-region
area. No edge correction is applied, so with the bounding box of the
positions as ``boundary`` it matches ``pointpats.distance_statistics.k``.
``radii`` must be one-dimensional. Returns shape ``(len(radii),)``, all
``nan`` for fewer than two turbines.
See Ripley (1977), Journal of the Royal Statistical Society B 39(2),
https://doi.org/10.1111/j.2517-6161.1977.tb01615.x.
"""
layout = _as_positions(obj)
radii = _as_radii(radii)
n = len(layout)
a = region_area(obj, boundary)
if n < 2 or not a > 0:
return np.full(radii.shape, np.nan)
from scipy.spatial.distance import pdist
pair_distances = np.sort(pdist(layout))
counts = np.searchsorted(pair_distances, radii, side="right")
return a * (2.0 * counts) / (n**2)
[docs]
def ripley_l(
obj: object,
radii: Sequence[float] | np.ndarray,
boundary: object = None,
) -> np.ndarray:
"""Ripley's L(r) = sqrt(K(r) / pi) in meters, shape ``(len(radii),)``.
``L(r) - r > 0`` indicates clustering and ``< 0`` regularity.
See https://doi.org/10.1111/j.2517-6161.1977.tb01615.x.
"""
return np.sqrt(ripley_k(obj, radii, boundary) / np.pi)
[docs]
def quadrat_window(
obj: object,
boundary: object = None,
hexagon_fraction: float = 0.15,
buffer_factor: float = 1.0,
) -> tuple[BaseGeometry, float]:
"""Study-region polygon and hexagon side length for the quadrat test.
The hexagon side length is ``hexagon_fraction * sqrt(region area)`` in
meters, so the grid depends on the region and not on turbine count. The
region is buffered outward by ``buffer_factor`` side lengths so the lattice
covers every turbine. ``hexagon_fraction`` must be finite and > 0, and
``buffer_factor`` finite and >= 0. Returns ``(polygon, side_length)``. A
region without area, such as the hull of collinear turbines, gives a ``nan``
or zero side length and is not buffered.
"""
hexagon_fraction = _validate_positive("hexagon_fraction", hexagon_fraction)
buffer_factor = _validate_nonnegative("buffer_factor", buffer_factor)
geometry = _region_geometry(obj, boundary)
side = hexagon_fraction * np.sqrt(region_area(obj, boundary))
poly = (
geometry.buffer(buffer_factor * side)
if buffer_factor and side > 0
else geometry
)
return poly, side
[docs]
def hexagon_vertices(
center_x: float, center_y: float, side_length: float
) -> np.ndarray:
"""Closed vertex ring ``(7, 2)`` of a flat-top hexagon centered at the point."""
cx, cy, side = center_x, center_y, side_length
sh = side * np.cos(np.pi / 6.0) # apothem (center-to-edge)
return np.array(
[
[cx + side, cy],
[cx + side / 2.0, cy + sh],
[cx - side / 2.0, cy + sh],
[cx - side, cy],
[cx - side / 2.0, cy - sh],
[cx + side / 2.0, cy - sh],
[cx + side, cy],
]
)
[docs]
def hexagon_centers(polygon: BaseGeometry, side_length: float) -> np.ndarray:
"""Centers ``(M, 2)`` of a flat-top hexagon lattice inside ``polygon``.
The cells tessellate, so nearest-center assignment equals hexagon membership.
Raises ``TypeError`` for a ``polygon`` that is no Shapely geometry, and
``ValueError`` for a ``side_length`` that is not finite and positive or
that would lay out more than ten million hexagons over the polygon's box.
"""
if not isinstance(polygon, BaseGeometry):
raise TypeError(
f"polygon must be a Shapely geometry, not {type(polygon).__name__}"
)
poly, side = polygon, _validate_positive("side_length", side_length)
minx, miny, maxx, maxy = poly.bounds
cells = (maxx - minx + side) * (maxy - miny + side) / (1.5 * np.sqrt(3.0) * side**2)
if cells > _MAX_HEXAGONS:
raise ValueError(
f"side_length {side:.4g} would lay out about {cells:.3g} hexagons "
"over the polygon, so choose a larger one"
)
sh = side * np.cos(np.pi / 6.0)
col_x = np.arange(minx, maxx + side, 1.5 * side)
centers = []
for col, cx in enumerate(col_x):
offset = sh if col % 2 == 1 else 0.0
cy = np.arange(miny + offset, maxy + sh, 2.0 * sh)
centers.append(np.column_stack([np.full(cy.shape, cx), cy]))
centers = np.vstack(centers)
inside = contains_xy(poly, centers[:, 0], centers[:, 1])
return centers[inside]
[docs]
def quadrat_counts(obj: object, centers: np.ndarray) -> np.ndarray:
"""Point count per hexagon, shape ``(len(centers),)``, by nearest cell center."""
layout = _as_positions(obj)
from scipy.spatial.distance import cdist
nearest = cdist(layout, centers).argmin(axis=1)
return np.bincount(nearest, minlength=len(centers)).astype(float)
def _cell_areas(centers: np.ndarray, side: float, region: BaseGeometry) -> np.ndarray:
"""Area of each flat-top hexagon cell inside ``region``, shape ``(len(centers),)``.
Cells that ``region`` holds properly keep their full area without an overlay.
"""
sh = side * np.cos(np.pi / 6.0)
cx = centers[:, 0, None]
cy = centers[:, 1, None]
dx = np.array([side, side / 2.0, -side / 2.0, -side, -side / 2.0, side / 2.0, side])
dy = np.array([0.0, sh, sh, 0.0, -sh, -sh, 0.0])
cells = shapely.polygons(np.stack([cx + dx, cy + dy], axis=-1))
shapely.prepare(region)
inside = shapely.contains_properly(region, cells)
area = shapely.area(cells)
outside = ~inside
area[outside] = shapely.area(shapely.intersection(cells[outside], region))
return area
[docs]
def quadrat_p_value(
obj: object,
boundary: object = None,
hexagon_fraction: float = 0.15,
buffer_factor: float = 1.0,
) -> float:
"""Chi-square CSR p-value from hexagonal quadrat counts.
Cells come from :func:`quadrat_window` and :func:`hexagon_centers`.
Each cell's expected count is proportional to its area inside the study
region, and cells with no such area are dropped.
A low value rejects complete spatial randomness, as under clustering. A high
value only fails to reject it and is not evidence of regularity. ``nan``
when fewer than two usable cells remain or no turbine lies in them.
"""
poly, side = quadrat_window(obj, boundary, hexagon_fraction, buffer_factor)
if not side > 0:
return float("nan")
centers = hexagon_centers(poly, side)
if len(centers) < 2:
return float("nan")
region = _region_geometry(obj, boundary)
cell_area = _cell_areas(centers, side, region)
usable = cell_area > 0.0
if usable.sum() < 2:
return float("nan")
counts = quadrat_counts(obj, centers)[usable]
# Turbines outside the study region leave no observation to test.
if not counts.sum() > 0:
return float("nan")
cell_area = cell_area[usable]
expected = counts.sum() * cell_area / cell_area.sum()
from scipy.stats import chisquare
return float(chisquare(counts, f_exp=expected).pvalue)
SCALAR_METRICS = {
"n_turbines": n_turbines,
"nearest_neighbor_mean": nearest_neighbor_mean,
"nearest_neighbor_variation": nearest_neighbor_variation,
"density": density,
"region_area": region_area,
"clark_evans": clark_evans,
"aspect_ratio": aspect_ratio,
"bounding_box_diagonal": bounding_box_diagonal,
"quadrat_p_value": quadrat_p_value,
"hull_area": hull_area,
}
"""Registry mapping public scalar metric names to their implementations.
Each entry takes a single Farm or ``(N, 2)`` array, and boundary-aware metrics
resolve the Farm's boundary automatically. Ripley's K and L take ``radii``, so
they are not scalar and are not included.
"""
DEFAULT_SUMMARY_METRICS = (
"n_turbines",
"nearest_neighbor_mean",
"nearest_neighbor_variation",
"density",
"region_area",
)
"""Metric names included by default in :func:`summary`."""
def _metric_names(metrics: Iterable[str] | None) -> tuple[str, ...]:
"""The requested metric names, :data:`DEFAULT_SUMMARY_METRICS` for ``None``."""
if isinstance(metrics, (str, bytes)):
raise TypeError(
"metrics must be a collection of metric names, not a single "
f"{type(metrics).__name__}"
)
names = tuple(DEFAULT_SUMMARY_METRICS) if metrics is None else tuple(metrics)
for name in names:
if not isinstance(name, str):
raise TypeError(f"metric names must be strings, not {type(name).__name__}")
return names
[docs]
def summary(obj: object, metrics: Iterable[str] | None = None) -> dict[str, float]:
"""Ordered ``name -> value`` dict of scalar metrics for a farm or array.
``metrics`` names entries of :data:`SCALAR_METRICS`, defaulting to
:data:`DEFAULT_SUMMARY_METRICS`. A string instead of a collection of names,
or a name that is not a string, raises ``TypeError``. Unknown names raise
``ValueError``.
"""
names = _metric_names(metrics)
result = {}
for name in names:
if name not in SCALAR_METRICS:
raise ValueError(
f"unknown metric {name!r}; available: {sorted(SCALAR_METRICS)}"
)
result[name] = SCALAR_METRICS[name](obj)
return result