from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass, field
from typing import Any, Literal
import numpy as np
from numpy.typing import ArrayLike, NDArray
from shapely import contains, points
from shapely.geometry import Polygon
from .wells import VerticalWell, _validate_well_sequence
[docs]
@dataclass(frozen=True)
class BoundaryPolygon:
"""
Represent a 2D area-of-interest boundary as a closed polygon in XY space.
The class stores validated polygon vertices and a cached Shapely polygon
object used for geometric operations such as point-in-polygon queries.
Parameters
----------
vertices : numpy.typing.ArrayLike
Polygon vertices with shape ``(N, 2)``. The ring is automatically closed
if the first and last points are not equal within tolerance.
name : str or None, default=None
Optional boundary label.
Raises
------
ValueError
Raised during initialization when vertices cannot form a valid finite
polygon.
Notes
-----
Initialization validates vertex shape, enforces finite coordinates, closes
the ring, and constructs an internal Shapely ``Polygon``.
"""
vertices: ArrayLike
name: str | None = None
_polygon: Polygon = field(init=False, repr=False)
[docs]
def __post_init__(self) -> None:
"""Validate vertices, close the ring, and build the internal polygon.
Raises
------
ValueError
If fewer than three unique points are provided or the polygon is
invalid (self-intersections, degenerate ring).
"""
xy = self._validate_vertices(self.vertices)
xy = self._close_ring(xy)
# Validate ring length after closing (needs >= 4 including closure)
if xy.shape[0] < 4:
raise ValueError("BoundaryPolygon requires at least 3 unique points.")
_polygon = Polygon(xy)
if not _polygon.is_valid:
raise ValueError("BoundaryPolygon is not a valid polygon (self-intersection or invalid ring).")
object.__setattr__(self, "_polygon", _polygon)
object.__setattr__(self, "vertices", xy)
@property
def bounds(self) -> tuple[float, float, float, float]:
"""Return the axis-aligned bounding box of the boundary polygon.
Parameters
----------
None
Returns
-------
tuple of float
Bounding box in the form ``(xmin, ymin, xmax, ymax)``.
"""
xy = self.vertices
xmin = float(np.min(xy[:, 0]))
ymin = float(np.min(xy[:, 1]))
xmax = float(np.max(xy[:, 0]))
ymax = float(np.max(xy[:, 1]))
return xmin, ymin, xmax, ymax
[docs]
def contains(self, xy: ArrayLike) -> NDArray[np.bool_]:
"""Evaluate whether points are strictly inside the polygon.
Uses Shapely's ``contains`` predicate, so points on the polygon boundary
are evaluated as ``False``.
Parameters
----------
xy : numpy.typing.ArrayLike
Candidate points of shape ``(N, 2)``.
Returns
-------
ndarray of bool
Boolean mask indicating whether each point lies inside the polygon.
Raises
------
ValueError
If ``xy`` is not a 2D array with two columns.
"""
pts = np.asarray(xy, dtype=float)
if pts.ndim != 2 or pts.shape[1] != 2:
raise ValueError("xy must be shape (N,2).")
poly = self._polygon
shp_points = points(pts[:, 0], pts[:, 1])
return contains(poly, shp_points)
# ----------------------------
# Constructors
# ----------------------------
[docs]
@classmethod
def from_vertices(
cls,
vertices: ArrayLike,
*,
name: str | None = None,
) -> "BoundaryPolygon":
"""
Create a boundary polygon from raw vertices.
Parameters
----------
vertices : numpy.typing.ArrayLike
Sequence of (x, y) coordinate pairs.
name : str or None, optional
Optional label for the polygon.
Returns
-------
BoundaryPolygon
Constructed boundary instance.
Examples
--------
>>> verts = [(0, 0), (100, 0), (100, 50), (0, 50)]
>>> boundary = BoundaryPolygon.from_vertices(verts, name="Field A")
"""
return cls(vertices, name=name)
[docs]
@classmethod
def from_xy(
cls,
x: Sequence[float],
y: Sequence[float],
*,
name: str | None = None,
) -> "BoundaryPolygon":
"""
Create a boundary polygon from separate x and y sequences.
Parameters
----------
x : Sequence[float]
X-coordinates.
y : Sequence[float]
Y-coordinates; must match shape of `x`.
name : str or None, optional
Optional label for the polygon.
Returns
-------
BoundaryPolygon
Constructed boundary instance.
Raises
------
ValueError
If `x` and `y` shapes differ.
Examples
--------
>>> boundary = BoundaryPolygon.from_xy([0, 100, 100, 0], [0, 0, 50, 50], name="Box")
"""
x = np.asarray(x, dtype=float)
y = np.asarray(y, dtype=float)
if x.shape != y.shape:
raise ValueError(f"x and y must have the same shape. Got {x.shape} vs {y.shape}.")
vertices = np.column_stack([x, y])
return cls.from_vertices(vertices, name=name)
[docs]
@classmethod
def from_bbox(
cls,
xmin: float,
ymin: float,
xmax: float,
ymax: float,
*,
name: str | None = None,
) -> "BoundaryPolygon":
"""
Create an axis-aligned rectangular boundary from bounding box limits.
Parameters
----------
xmin, ymin : float
Lower-left corner.
xmax, ymax : float
Upper-right corner; must satisfy xmax > xmin and ymax > ymin.
name : str or None, optional
Optional label for the polygon.
Returns
-------
BoundaryPolygon
Rectangular boundary instance.
Raises
------
ValueError
If the bounds are invalid.
Examples
--------
>>> boundary = BoundaryPolygon.from_bbox(0.0, 0.0, 200.0, 150.0, name="AOI")
"""
if not (xmin < xmax and ymin < ymax):
raise ValueError("Invalid bbox: require xmin < xmax and ymin < ymax.")
vertices = np.array(
[
[xmin, ymin],
[xmax, ymin],
[xmax, ymax],
[xmin, ymax],
[xmin, ymin],
],
dtype=float,
)
return cls(vertices=vertices, name=name)
def _validate_vertices(self, vertices: ArrayLike) -> NDArray[np.float64]:
"""Validate and normalize polygon vertices.
Parameters
----------
vertices : ArrayLike
Sequence of (x, y) coordinate pairs.
Returns
-------
numpy.ndarray
Validated float64 array of vertices.
"""
try:
arr = np.asarray(vertices, dtype=np.float64)
except (ValueError, TypeError) as e:
raise ValueError(f"Failed to convert vertices to float array: {e}") from e
if arr.ndim != 2 or arr.shape[1] != 2:
raise ValueError(
f"`vertices` must be array-like with shape (N, 2). "
f"Got shape {arr.shape}."
)
if arr.shape[0] < 3:
raise ValueError(
f"`vertices` must contain at least 3 points. "
f"Got {arr.shape[0]}."
)
if not np.isfinite(arr).all():
raise ValueError("`vertices` must not contain NaN or infinite values.")
return np.ascontiguousarray(arr)
def _close_ring(self, xy: NDArray[np.float64], *, tol: float = 1e-12) -> NDArray[np.float64]:
"""Ensure the polygon ring is closed.
Parameters
----------
xy : numpy.ndarray
Vertex coordinates.
tol : float, optional
Numerical tolerance for closure check.
Returns
-------
numpy.ndarray
Closed ring (first point repeated at end if needed).
"""
if np.allclose(xy[0], xy[-1], atol=tol):
return xy
return np.vstack((xy, xy[0]))
[docs]
def show(
self,
*,
facecolor: str | tuple[Any, ...] = "#7ec8e3",
edgecolor: str | tuple[Any, ...] = "#1f2937",
linewidth: float = 1.8,
alpha: float = 0.30,
show_fill: bool = True,
show_vertices: bool = False,
vertex_size: float = 24.0,
aspect: Literal["auto", "equal"] = "auto",
title: str | None = None,
wells: Sequence[VerticalWell] | VerticalWell | None = None,
**kwargs: Any,
) -> "Matplotlib2DViewer":
"""
Quick visualization of the boundary polygon using Matplotlib.
Parameters
----------
facecolor : str or tuple, default="#7ec8e3"
Fill color of the polygon.
edgecolor : str or tuple, default="#1f2937"
Edge (border) color of the polygon.
linewidth : float, default=1.8
Width of the polygon boundary line.
alpha : float, default=0.30
Transparency of the fill (0-1).
show_fill : bool, default=True
Whether to fill the polygon.
show_vertices : bool, default=False
Whether to show vertex markers.
vertex_size : float, default=24.0
Size of vertex markers.
aspect : {"auto", "equal"}, default="auto"
Axes aspect ratio used by the viewer theme.
title : str or None, default=None
Title of the plot. If ``None``, no title is shown.
wells : VerticalWell or Sequence[VerticalWell] or None, optional
Well(s) to plot on top of the grid. Can be a single VerticalWell or a sequence of them. If ``None``, no wells are plotted.
**kwargs : Any
Additional keyword arguments passed to
``Matplotlib2DViewer.add_boundary_polygon``.
Returns
-------
Matplotlib2DViewer
Viewer instance with the polygon added and shown.
Examples
--------
>>> boundary = BoundaryPolygon.from_bbox(0.0, 0.0, 100.0, 80.0)
>>> viewer = boundary.show(title="Boundary")
>>> type(viewer).__name__
'Matplotlib2DViewer'
"""
from ..viewers.viewer2d.matplotlib.viewer import Matplotlib2DViewer
from ..viewers.viewer2d.matplotlib.theme import Matplotlib2DViewerTheme
viewer = Matplotlib2DViewer(
theme=Matplotlib2DViewerTheme(aspect=aspect)
)
viewer.add_boundary_polygon(
self,
facecolor=facecolor,
edgecolor=edgecolor,
linewidth=linewidth,
alpha=alpha,
show_fill=show_fill,
show_vertices=show_vertices,
vertex_size=vertex_size,
**kwargs,
)
if wells is not None:
viewer.add_wells(_validate_well_sequence(wells))
viewer.show(title=title)
return viewer