Source code for parq_blockmodel.polygon_field

from __future__ import annotations

from dataclasses import dataclass
from pathlib import Path
from typing import Iterable, Any

import numpy as np
import shapely
from shapely.geometry import Polygon, MultiPolygon, GeometryCollection
from shapely.ops import unary_union
from shapely.validation import explain_validity

from parq_blockmodel.geometry import RegularGeometry


PolygonInput = Polygon | MultiPolygon | GeometryCollection | Iterable[Polygon | MultiPolygon]


def _coerce_polygon_geometry(geometry: PolygonInput) -> Polygon | MultiPolygon:
    union = getattr(shapely, "union_all", unary_union)

    if isinstance(geometry, (Polygon, MultiPolygon)):
        polygonal = geometry
    elif isinstance(geometry, GeometryCollection):
        polygon_parts = [g for g in geometry.geoms if isinstance(g, (Polygon, MultiPolygon))]
        if not polygon_parts:
            raise ValueError("PolygonField requires polygonal geometry; no Polygon/MultiPolygon found.")
        polygonal = union(polygon_parts)
    else:
        try:
            parts = list(geometry)
        except TypeError as exc:
            raise TypeError(
                "geometry must be a shapely Polygon/MultiPolygon, a GeometryCollection, "
                "or an iterable of Polygon/MultiPolygon geometries."
            ) from exc

        if not parts:
            raise ValueError("PolygonField requires at least one polygon geometry.")
        if not all(isinstance(part, (Polygon, MultiPolygon)) for part in parts):
            raise TypeError("All iterable geometry items must be Polygon or MultiPolygon.")
        polygonal = union(parts)

    if polygonal.is_empty:
        raise ValueError("PolygonField geometry cannot be empty.")
    if not polygonal.is_valid:
        raise ValueError(f"Invalid polygon geometry: {explain_validity(polygonal)}")

    if not isinstance(polygonal, (Polygon, MultiPolygon)):
        raise ValueError("PolygonField requires polygonal geometry after union operation.")

    return polygonal


def _resolve_geometry_like(grid: Any) -> RegularGeometry:
    if isinstance(grid, RegularGeometry):
        return grid

    if hasattr(grid, "geometry") and isinstance(grid.geometry, RegularGeometry):
        return grid.geometry

    raise TypeError("evaluate() expects a RegularGeometry or an object exposing .geometry as RegularGeometry.")


[docs] @dataclass(frozen=True) class PolygonField: """2D polygon classifier evaluated against block centroids in world XY.""" geometry: Polygon | MultiPolygon name: str | None = None
[docs] @classmethod def from_shapely(cls, geometry: PolygonInput, name: str | None = None) -> "PolygonField": """Create a PolygonField from shapely polygon geometry.""" return cls(geometry=_coerce_polygon_geometry(geometry), name=name)
[docs] @classmethod def from_geoparquet( cls, filepath: str | Path, *, name: str, name_column: str = "name", geometry_column: str | None = None, ) -> "PolygonField": """Create a PolygonField from a named polygon row in a GeoParquet file.""" import geopandas as gpd gdf = gpd.read_parquet(filepath) if name_column not in gdf.columns: raise ValueError(f"GeoParquet file does not contain name column '{name_column}'.") if geometry_column is not None: if geometry_column not in gdf.columns: raise ValueError(f"GeoParquet file does not contain geometry column '{geometry_column}'.") gdf = gdf.set_geometry(geometry_column) matches = gdf[gdf[name_column] == name] if len(matches) == 0: raise ValueError(f"No polygon found with {name_column}='{name}'.") if len(matches) > 1: raise ValueError( f"Multiple polygons found with {name_column}='{name}'. " "Use unique names for PolygonField.from_geoparquet." ) geometry = matches.geometry.iloc[0] return cls.from_shapely(geometry=geometry, name=str(name))
[docs] def evaluate(self, grid: Any) -> np.ndarray: """Return a boolean mask where centroid XY points intersect polygon geometry. Evaluation is strictly 2D in the world XY plane. Z is ignored. Boundary points are classified as inside. """ geometry = _resolve_geometry_like(grid) x = np.asarray(geometry.centroid_x, dtype=float) y = np.asarray(geometry.centroid_y, dtype=float) if hasattr(shapely, "intersects_xy"): # intersects(point, polygon) includes interior and boundary. mask = shapely.intersects_xy(self.geometry, x, y) else: points = shapely.points(x, y) mask = shapely.intersects(self.geometry, points) return np.asarray(mask, dtype=bool)