"""Offshore wind-farm boundaries sampled from a fitted model.
A support-function PCA generates a convex envelope, then a second PCA generates
inward edge paths on a beta-binomially sampled subset of its sides. The packaged
model was fitted to quality-controlled, operating EMODnet wind-farm polygons.
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from functools import cache
from pathlib import Path
import numpy as np
from shapely.geometry import LineString, Polygon
from shapely.geometry.base import BaseGeometry
from shapely.geometry.polygon import orient
from .._random import as_rng
from .._validation import _validate_finite, _validate_integer
from .base import (
Boundary,
Point2D,
_optional_area,
_oriented,
_resolve_center,
)
from .fourier import _simplify_to_v
__all__ = ["OffshorePolygon"]
_DEFAULT_MODEL_PATH = Path(__file__).parents[1] / "data" / "offshore_polygon_model.npz"
@dataclass
class _OffshoreModel:
"""Fitted support-envelope and edge-path model of offshore lease outlines."""
support_mean: np.ndarray
support_components: np.ndarray
support_score_std: np.ndarray
support_score_location: float
support_score_scale: float
hull_vertex_log_mean: float
hull_vertex_log_std: float
hull_vertex_min: int
hull_vertex_max: int
path_mean: np.ndarray
path_components: np.ndarray
path_feature_mean: np.ndarray
path_feature_cholesky: np.ndarray
path_landmarks: int
path_count_min: int
path_count_max: int
path_posterior_degrees: int
path_posterior_scale: float
occupancy_alpha: float
occupancy_beta: float
metadata: dict
@classmethod
def load(cls, path: str | Path) -> "_OffshoreModel":
"""Load a packaged model from the ``.npz`` archive at ``path``.
Reading is pickle-free and gated on ``schema_version``. Any version
other than 1 raises ``ValueError``.
"""
with np.load(Path(path).with_suffix(".npz"), allow_pickle=False) as data:
schema_version = int(data["schema_version"])
if schema_version != 1:
raise ValueError(
f"unsupported offshore polygon model schema {schema_version}"
)
return cls(
support_mean=data["support_mean"],
support_components=data["support_components"],
support_score_std=data["support_score_std"],
support_score_location=float(data["support_score_location"]),
support_score_scale=float(data["support_score_scale"]),
hull_vertex_log_mean=float(data["hull_vertex_log_mean"]),
hull_vertex_log_std=float(data["hull_vertex_log_std"]),
hull_vertex_min=int(data["hull_vertex_min"]),
hull_vertex_max=int(data["hull_vertex_max"]),
path_mean=data["path_mean"],
path_components=data["path_components"],
path_feature_mean=data["path_feature_mean"],
path_feature_cholesky=data["path_feature_cholesky"],
path_landmarks=int(data["path_landmarks"]),
path_count_min=int(data["path_count_min"]),
path_count_max=int(data["path_count_max"]),
path_posterior_degrees=int(data["path_posterior_degrees"]),
path_posterior_scale=float(data["path_posterior_scale"]),
occupancy_alpha=float(data["occupancy_alpha"]),
occupancy_beta=float(data["occupancy_beta"]),
metadata=json.loads(str(data["metadata_json"].item())),
)
@cache
def _load_default_model() -> _OffshoreModel:
return _OffshoreModel.load(_DEFAULT_MODEL_PATH)
def _clip_halfplane(points, normal_x, normal_y, offset):
"""Clip a convex ring of ``(x, y)`` tuples to ``x @ normal <= offset``.
The ring is a few dozen points, so Python floats beat NumPy calls, and
the plain products round as NumPy's do.
"""
result = []
previous_x, previous_y = points[-1]
previous_value = previous_x * normal_x + previous_y * normal_y - offset
for x, y in points:
value = x * normal_x + y * normal_y - offset
previous_inside = previous_value <= 1e-12
inside = value <= 1e-12
if inside != previous_inside:
fraction = previous_value / (previous_value - value)
result.append(
(
previous_x + fraction * (x - previous_x),
previous_y + fraction * (y - previous_y),
)
)
if inside:
result.append((x, y))
previous_x, previous_y, previous_value = x, y, value
return result
def _polygon_from_support(support):
"""Intersect the half-planes of equally spaced support values, or ``None``."""
radius = 4.0 * float(np.max(np.abs(support)))
points = [
(-radius, -radius),
(radius, -radius),
(radius, radius),
(-radius, radius),
]
angles = np.arange(len(support)) * (2.0 * np.pi / len(support))
normals = np.column_stack([np.cos(angles), np.sin(angles)]).tolist()
for (normal_x, normal_y), offset in zip(normals, support.tolist(), strict=True):
points = _clip_halfplane(points, normal_x, normal_y, offset)
if len(points) < 3:
return None
polygon = Polygon(points)
if not polygon.is_valid or polygon.area <= np.finfo(float).eps:
return None
return orient(polygon, sign=1.0)
def _exact_vertices(coordinates, count):
"""Drop the least significant vertices of a ring until ``count`` remain.
Significance is the cross product of the two adjacent edges, twice the
area the removal loses. Returns ``None`` when no removal keeps the
polygon valid.
"""
coordinates = np.asarray(coordinates).copy()
while len(coordinates) > count:
previous = np.roll(coordinates, 1, axis=0)
following = np.roll(coordinates, -1, axis=0)
importance = np.abs(
(coordinates[:, 0] - previous[:, 0]) * (following[:, 1] - coordinates[:, 1])
- (coordinates[:, 1] - previous[:, 1])
* (following[:, 0] - coordinates[:, 0])
)
for index in np.argsort(importance):
candidate = np.delete(coordinates, index, axis=0)
polygon = Polygon(candidate)
if polygon.is_valid and polygon.area > 0:
coordinates = candidate
break
else:
return None
return coordinates
def _inscribed_share(corners: int) -> float:
"""Least share of a convex shape's area its largest ``corners``-gon covers.
The regular polygon in a circle attains it (Sas 1939), 0.41 for a
triangle and 0.64 for a quadrilateral.
"""
return corners / (2.0 * np.pi) * np.sin(2.0 * np.pi / corners)
def _reduce_open_path(coordinates, intermediate_count):
"""Drop the least significant interior vertices of an open path."""
coordinates = np.asarray(coordinates).copy()
target = intermediate_count + 2
while len(coordinates) > target:
previous = coordinates[:-2]
current = coordinates[1:-1]
following = coordinates[2:]
importance = np.abs(
(current[:, 0] - previous[:, 0]) * (following[:, 1] - current[:, 1])
- (current[:, 1] - previous[:, 1]) * (following[:, 0] - current[:, 0])
)
coordinates = np.delete(coordinates, int(np.argmin(importance)) + 1, axis=0)
return coordinates
def _sample_envelope(model, rng):
"""Draw a convex envelope with a sampled corner count, or ``None``."""
standardized = model.support_score_location + (
model.support_score_scale * rng.standard_normal(len(model.support_score_std))
)
scores = model.support_score_std * standardized
support = model.support_mean + scores @ model.support_components
envelope = _polygon_from_support(support)
if envelope is None:
return None
target = int(
np.rint(
np.exp(
model.hull_vertex_log_mean + rng.normal(scale=model.hull_vertex_log_std)
)
+ 2.0
)
)
if not model.hull_vertex_min <= target <= model.hull_vertex_max:
return None
coordinates = np.asarray(envelope.exterior.coords)[:-1]
if len(coordinates) > target:
reduced = _simplify_to_v(coordinates, target)
if reduced is not None:
reduced = _exact_vertices(np.asarray(reduced.exterior.coords)[:-1], target)
# Douglas-Peucker keeps the corners farthest from a chord, and at three
# corners kept as little as 0.01 % of the envelope, a sliver 18,000
# times longer than wide. Every convex shape holds a polygon of
# ``target`` corners with at least ``_inscribed_share`` of its area,
# so a smaller one is redone by dropping the corners that cost least.
if reduced is None or (
Polygon(reduced).area < _inscribed_share(target) * envelope.area
):
reduced = _exact_vertices(coordinates, target)
if reduced is None:
return None
coordinates = reduced
while len(coordinates) < target:
edges = np.roll(coordinates, -1, axis=0) - coordinates
index = int(np.argmax(np.linalg.norm(edges, axis=1)))
midpoint = 0.5 * (
coordinates[index] + coordinates[(index + 1) % len(coordinates)]
)
coordinates = np.insert(coordinates, index + 1, midpoint, axis=0)
polygon = Polygon(coordinates)
return orient(polygon, sign=1.0) if polygon.is_valid and polygon.area > 0 else None
def _sample_path(model, rng):
"""Draw an inward edge path in local coordinates from (0, 0) to (1, 0).
Returns ``None`` for a draw outside the fitted corner counts, or one that
is degenerate or self-intersecting.
"""
standardized = (
model.path_posterior_scale
* rng.standard_normal(len(model.path_feature_mean))
/ np.sqrt(
rng.chisquare(model.path_posterior_degrees) / model.path_posterior_degrees
)
)
features = model.path_feature_mean + model.path_feature_cholesky @ standardized
# A complex vector-matrix product through BLAS spins every BLAS thread for a
# few microseconds of work, so einsum forms it directly.
shape = model.path_mean + np.einsum("i,ij->j", features[:-2], model.path_components)
indentation_area = float(np.exp(features[-2]))
count = int(np.rint(np.exp(features[-1])))
if not model.path_count_min <= count <= model.path_count_max:
return None
x_coordinates = shape.real
profile = np.maximum(shape.imag, 0.0)
trial = np.column_stack([np.r_[0.0, x_coordinates, 1.0], np.r_[0.0, profile, 0.0]])
normalizer = float(Polygon(trial).area)
if normalizer <= np.finfo(float).eps:
return None
coordinates = np.column_stack(
[
np.r_[0.0, x_coordinates, 1.0],
np.r_[0.0, indentation_area * profile / normalizer, 0.0],
]
)
if not LineString(coordinates).is_simple:
return None
return _reduce_open_path(coordinates, count)
[docs]
class OffshorePolygon(Boundary):
"""Boundary sampled from the morphology of operating offshore wind farms.
Sampling is parametric and copies no reference polygon. The shape is scaled
to ``area`` and moved to ``center``. A heading is drawn per sample unless
``rotation`` pins it in degrees. Raises ``RuntimeError`` after
``max_attempts`` invalid draws.
"""
def __init__(
self,
*,
area: float | None = None,
center: Point2D | None = None,
rotation: float | None = None,
seed: int | np.random.Generator | None = None,
max_attempts: int = 256,
) -> None:
self.target_area = _optional_area(area)
self.center, self._positioned = _resolve_center(center)
self.rotation = (
None if rotation is None else _validate_finite("rotation", rotation)
)
self.seed = self._resolve_own_seed(seed)
self.max_attempts = _validate_integer("max_attempts", max_attempts)
def _build_geometry(self) -> BaseGeometry:
model = _load_default_model()
rng = as_rng(self.seed)
propensity = rng.beta(model.occupancy_alpha, model.occupancy_beta)
for _ in range(self.max_attempts):
envelope = _sample_envelope(model, rng)
if envelope is None:
continue
coordinates = np.asarray(envelope.exterior.coords)[:-1]
edges = np.roll(coordinates, -1, axis=0) - coordinates
lengths = np.linalg.norm(edges, axis=1)
irregular_count = int(rng.binomial(len(coordinates), propensity))
selected = set(
map(
int,
rng.choice(
len(coordinates),
size=irregular_count,
replace=False,
p=lengths / lengths.sum(),
),
)
)
result = []
failed = False
for index, start in enumerate(coordinates):
result.append(start)
if index not in selected:
continue
local = _sample_path(model, rng)
if local is None:
failed = True
break
edge = edges[index]
length = lengths[index]
direction = edge / length
inward = np.asarray([-direction[1], direction[0]])
result.extend(
start
+ local[1:-1, 0, None] * edge
+ local[1:-1, 1, None] * length * inward
)
if failed:
continue
polygon = Polygon(result)
if polygon.is_valid and polygon.area > np.finfo(float).eps:
return orient(_oriented(polygon, self.rotation, rng), sign=1.0)
raise RuntimeError(
f"could not generate a valid offshore polygon in {self.max_attempts} attempts"
)