Vector Shoreline & Coastal Geometry Processing

Operational intent: this topic covers the vector half of a coastal pipeline — the shoreline polygons, channel centrelines, habitat boundaries and survey footprints that every raster product is eventually clipped, masked or attributed against. It sits inside Marine Spatial Data Fundamentals & Architecture, downstream of CRS alignment and upstream of every masking and zonal-statistics stage in the fleet. The scale is awkward rather than large: a national shoreline is a few hundred megabytes of geometry, small enough to hold in memory and complex enough that a single invalid ring will abort a spatial join four stages later, after twenty minutes of raster work has already been done.

Coastal vector data has a property that inland vector data does not: its boundary is a modelled surface, not an observed line. A shoreline is the intersection of the land with a tidal datum, so “the coastline” is only meaningful once you say which datum — mean high water, mean lower low water, the highest astronomical tide — and the same physical beach yields lines tens or hundreds of metres apart under different choices. Everything below assumes that choice has been made explicitly and recorded; if it has not, no amount of geometric rigour downstream will make the result mean anything.

Which Datum You Choose Moves the Shoreline by Metres or by Hundreds of Metres Two beach cross-sections drawn side by side, each cut by three horizontal tidal datum lines: mean high water at the top, mean sea level in the middle, and mean lower low water at the bottom, with a total vertical separation of about 1.6 metres in both. The left profile is a gently sloping sand beach at roughly one in eighty; the three datum intersections with the beach face are spread about 130 metres apart horizontally, and the resulting three shoreline positions are marked on a plan strip beneath. The right profile is a steep rocky shore at roughly one in two; the same three datums intersect within about 3 metres of each other. A caption states that the horizontal disagreement between shoreline definitions is set by beach slope, so a national shoreline product carries a positional difference that varies by two orders of magnitude along its own length. gentle sand beach — 1:80 MHW MSL MLLW ≈ 130 m of “coastline” rocky shore — 1:2 MHW MSL MLLW ≈ 3 m Same 1.6 m of vertical separation in both profiles. The horizontal disagreement is set by slope, so a single national shoreline carries a positional difference that varies by two orders of magnitude along its own length. This is why the datum belongs in the layer’s metadata and in its name, not in a report nobody opens.

Reference Configuration

Parameter Value Why
Working CRS EPSG:32618 (UTM 18N) or local equivalent Metric units; area and length operations are undefined in degrees
Shoreline datum Declared per layer, e.g. MHW (1983–2001 epoch) The line is a datum intersection, not an observation
Geometry engine Shapely ≥ 2.0 / GEOS ≥ 3.11 Vectorised predicates; make_valid with the structure-preserving method
Validity policy is_valid asserted after every operation GEOS operations may produce invalid output from valid input
Simplification Topology-preserving, tolerance in metres Douglas-Peucker per feature breaks shared edges
Precision model Grid size 0.001 m applied at ingestion Removes the float-noise self-intersections that dominate repair work
Vertex budget ≤ 50,000 per polygon before splitting Beyond this, per-feature predicates dominate runtime non-linearly
Output format GeoPackage (.gpkg) Single file, CRS travels with the data, no .prj to lose

The Three Ways Coastal Geometry Is Invalid

GEOS defines validity precisely, and coastal data violates it in three characteristic ways that have different causes and different correct repairs. Conflating them is why buffer(0) has such a reputation: it “fixes” all three by producing output that is valid, and for two of them the output is wrong.

Self-intersecting rings. The exterior ring crosses itself, typically at a narrow spit, a jetty digitised as a zero-width sliver, or a river mouth where two survey lines were joined. GEOS reports Self-intersection with a coordinate. The correct repair splits the ring into the polygons it actually describes, which make_valid does and buffer(0) does not — buffer(0) discards whichever lobe it considers the smaller, silently deleting a real headland.

Ring order and nesting errors. An interior ring — a lagoon, a harbour basin — that is not contained by its exterior, or that is wound in the same direction as the exterior. Shapefiles encode nesting by winding order, and a producer that got the winding wrong emits a file that opens without complaint and describes land where there is water. The repair is to recompute containment from geometry rather than to trust the recorded order.

Duplicate and near-duplicate vertices. Two vertices at the same location, or a few nanometres apart after a reprojection. These are legal in isolation but make almost every predicate fragile, and they are the direct cause of the “worked yesterday, fails today” class of GEOS errors, because the failure depends on floating-point rounding in the specific operation. The repair is a precision model applied once, at ingestion, before any spatial work.

The distinction between the first and third matters operationally. A self-intersection is a data error that a human should look at. A near-duplicate vertex is float noise that should never have reached a human. Applying a precision grid at ingestion removes the entire third category, which in practice is most of the volume, and leaves a much smaller set of genuine geometry problems that are worth reviewing individually.

make_valid Splits the Bowtie; buffer(0) Deletes Half of It Three panels. The left panel shows the input geometry: a coastal polygon whose exterior ring crosses itself at a narrow spit, forming a bowtie with a large southern lobe and a smaller northern lobe, labelled as invalid with a self-intersection at the crossing point. The middle panel shows the result of make_valid: a multipolygon of two parts, both lobes retained, total area unchanged at 4.81 square kilometres. The right panel shows the result of buffer with distance zero: a single polygon containing only the larger lobe, with the smaller headland gone, total area 3.96 square kilometres. Both outputs report is_valid as true and neither operation raises. A caption gives the difference as 0.85 square kilometres of land silently removed. input — invalid self-intersection is_valid → False area → undefined make_valid() MultiPolygon, 2 parts area → 4.81 km² is_valid → True buffer(0) gone Polygon, 1 part area → 3.96 km² is_valid → True Neither call raises. Both outputs pass a validity assertion. The right-hand one has removed 0.85 km² of land, and the only way to notice is to compare areas before and after — which is why the repair function below returns both.

Production Repair: Precision, Then Validity, Then Audit

The order matters. Snapping to a precision grid first removes the noise-driven invalidities so that the validity pass deals only with real geometry problems; auditing area afterwards catches the case where a repair succeeded and lost land. Each step logs rather than prints, and the function raises rather than returning a silently degraded layer.

"""Coastal vector repair: precision model, validity, and an area audit."""
from __future__ import annotations

import logging
from dataclasses import dataclass

import geopandas as gpd
import shapely
from shapely import set_precision
from shapely.geometry.base import BaseGeometry

logger = logging.getLogger(__name__)

PRECISION_GRID_M = 0.001      # 1 mm; below any real survey resolution
AREA_LOSS_TOLERANCE = 1e-4    # 0.01% of layer area


@dataclass(frozen=True)
class RepairReport:
    """What the repair actually did, so the caller can gate on it."""
    n_features: int
    n_invalid_in: int
    n_repaired: int
    area_before_m2: float
    area_after_m2: float

    @property
    def relative_area_loss(self) -> float:
        if self.area_before_m2 <= 0.0:
            return 0.0
        return (self.area_before_m2 - self.area_after_m2) / self.area_before_m2


def _valid_area(geom: BaseGeometry | None) -> float:
    """Area of a geometry that may be invalid — measured on a repaired copy."""
    if geom is None or geom.is_empty:
        return 0.0
    return shapely.make_valid(geom).area


def repair_coastal_layer(
    gdf: gpd.GeoDataFrame,
    *,
    grid_size: float = PRECISION_GRID_M,
    max_area_loss: float = AREA_LOSS_TOLERANCE,
) -> tuple[gpd.GeoDataFrame, RepairReport]:
    """Snap to a precision grid, repair validity, and refuse to lose land.

    Raises
    ------
    ValueError
        If the layer has no projected CRS (areas would be meaningless), or if
        the repair removed more area than `max_area_loss`.
    """
    if gdf.crs is None or gdf.crs.is_geographic:
        raise ValueError(
            "repair_coastal_layer requires a projected CRS; got "
            f"{gdf.crs}. Reproject before repairing so areas are in metres."
        )

    geoms = gdf.geometry
    n_invalid_in = int((~geoms.is_valid).sum())
    area_before = float(sum(_valid_area(g) for g in geoms))

    # 1. Precision model first: removes float-noise self-intersections so the
    #    validity pass below only sees genuine geometry problems.
    snapped = shapely.set_precision(geoms.values, grid_size)

    # 2. make_valid, never buffer(0): it splits a bowtie into its parts instead
    #    of discarding the smaller one.
    repaired = shapely.make_valid(snapped)

    out = gdf.copy()
    out.geometry = gpd.GeoSeries(repaired, crs=gdf.crs, index=gdf.index)
    out = out[~out.geometry.is_empty].copy()

    area_after = float(out.geometry.area.sum())
    report = RepairReport(
        n_features=len(gdf),
        n_invalid_in=n_invalid_in,
        n_repaired=n_invalid_in - int((~out.geometry.is_valid).sum()),
        area_before_m2=area_before,
        area_after_m2=area_after,
    )

    if not bool(out.geometry.is_valid.all()):
        raise ValueError("repair left invalid geometries; inspect them individually")

    if report.relative_area_loss > max_area_loss:
        raise ValueError(
            f"repair removed {report.relative_area_loss:.4%} of layer area "
            f"({area_before - area_after:,.0f} m²), above the "
            f"{max_area_loss:.4%} tolerance. This is a data problem, not a "
            "geometry problem — review the failing features before proceeding."
        )

    logger.info(
        "repaired %d/%d invalid feature(s); area delta %.6f%%",
        report.n_repaired, report.n_invalid_in, report.relative_area_loss * 100.0,
    )
    return out, report

Two design choices in that function are deliberate and worth stating. It raises on excessive area loss rather than warning, because a repair that deletes a square kilometre of coast is not a repair, and a warning in a log is not a control. And it measures area before repair using a repaired copy, because the area of an invalid polygon is undefined in GEOS and comparing it to anything is meaningless — the copy is thrown away immediately and exists only so the two numbers are commensurable.

Simplification Without Breaking the Topology

Coastal polygons are dense. A national shoreline digitised at 1:10,000 carries far more vertices than a 50-metre analysis grid can use, and carrying them costs time in every predicate and every render. Simplification is the obvious answer and the obvious implementation is wrong.

Per-feature Douglas-Peucker — gdf.geometry.simplify(tolerance) — treats each polygon independently. Where two polygons share an edge, as adjacent management zones or a shoreline and the estuary polygon inside it always do, the two copies of that edge are simplified with different vertex neighbourhoods and no longer coincide. The result is a layer full of slivers and gaps a few metres wide, invisible at the zoom level anyone reviews it at, and fatal to every subsequent overlay: a point-in-polygon test in a sliver returns no match, and a zonal statistic over the pair double-counts the overlap.

Topology-preserving simplification builds a shared-edge graph first, simplifies each edge once, and reassembles the polygons from the simplified edges. mapshaper, PostGIS’s ST_SimplifyPreserveTopology and GRASS v.generalize all implement it; Shapely does not, which is why the correct answer to “how do I simplify a coastline in Shapely” is usually “not in Shapely”.

The other constraint is the tolerance itself. It has to be chosen against the analysis, not against file size: a tolerance of 25 metres is negligible against a 100-metre habitat grid and catastrophic against a 5-metre dredging boundary, and the same layer often feeds both. Where that happens, publish the layer at two tolerances with the tolerance in the name, rather than picking a compromise that is wrong for both. The shoreline simplification guide works through both halves with runnable code.

Validation Gates

Three checks, all cheap, all run before a vector layer is allowed downstream.

Gate 1 — validity and emptiness. Every geometry is_valid, no geometry empty, no null geometries. This is a one-liner and it catches the majority of problems that would otherwise surface as an exception in an unrelated stage an hour later.

def assert_layer_clean(gdf: gpd.GeoDataFrame) -> None:
    if gdf.geometry.isna().any():
        raise ValueError(f"{int(gdf.geometry.isna().sum())} null geometries")
    if gdf.geometry.is_empty.any():
        raise ValueError(f"{int(gdf.geometry.is_empty.sum())} empty geometries")
    bad = gdf.index[~gdf.geometry.is_valid].tolist()
    if bad:
        raise ValueError(f"{len(bad)} invalid geometries at index {bad[:10]}")

Gate 2 — extent sanity. The layer’s bounds fall inside the survey’s expected extent, in the working CRS. This is the vector equivalent of the bounds assertion that catches axis-order inversion, and it catches exactly the same class of error: a layer that reprojected “successfully” into the wrong hemisphere.

Gate 3 — self-overlap. Within a layer that is supposed to partition space — management zones, survey blocks, habitat classes — no two features may overlap by more than a tolerance. gdf.sindex.query(gdf.geometry, predicate="overlaps") gives the candidate pairs in one call, and the total overlapping area is the number to threshold on. A layer that fails this gate will produce zonal statistics that sum to more than the whole, which is the kind of error that gets noticed in a briefing rather than in a log.

Spatial Indexing Is the Difference Between Seconds and Hours

Every coastal workflow eventually joins one layer against another: soundings against survey blocks, AIS positions against port polygons, habitat cells against management zones. Done naively that is a nested loop, and at coastal data volumes the nested loop does not finish.

Shapely 2.0 and GeoPandas expose an R-tree through gdf.sindex, and the important part of the API is query with a predicate argument. Without a predicate it returns bounding-box candidates, which still need refining; with one — "intersects", "within", "contains" — it returns the exact pairs, performing the bounding-box filter internally and the exact predicate only on survivors. The cost difference against a manual loop is three to four orders of magnitude on a million-point join, and the code is shorter:

import numpy as np


def points_in_zones(points: gpd.GeoDataFrame, zones: gpd.GeoDataFrame) -> np.ndarray:
    """Zone index for every point, -1 where a point falls outside all zones."""
    if points.crs != zones.crs:
        raise ValueError(f"CRS mismatch: {points.crs} vs {zones.crs}")

    tree_idx, point_idx = zones.sindex.query(points.geometry, predicate="within")
    assignment = np.full(len(points), -1, dtype="int64")
    assignment[tree_idx] = point_idx
    logger.info("assigned %d/%d points to a zone", int((assignment >= 0).sum()), len(points))
    return assignment

Two details bite people. The index is built lazily on first access and cached on the GeoDataFrame, so building it inside a loop over chunks rebuilds it every iteration — build it once, outside. And the returned arrays are index positions, not DataFrame index labels; on a layer with a non-default index, using them as labels produces a silent misalignment rather than an error.

For repeated joins against a layer that does not change — a national shoreline, a fixed set of management zones — the index can be persisted by keeping the GeoDataFrame alive across the run, which is the single largest speedup available in most coastal pipelines and requires no new dependency.

Per-Feature Simplification Opens Slivers Along Every Shared Edge Two adjacent coastal management polygons share a boundary drawn as a dense zigzag. In the per-feature simplification panel, each polygon's copy of that boundary is simplified against its own vertex neighbourhood, so the two simplified lines diverge and the panel shows alternating sliver overlaps and gaps between them, each a few metres across. In the shared-edge panel, the boundary is extracted once, simplified once, and both polygons are rebuilt from it, so the two remain exactly coincident. A note records that the slivers are invisible at review zoom and cause point-in-polygon misses and double-counted zonal statistics downstream. per-feature simplify() — two copies, two answers zone A simplified zone B simplified solid and dashed no longer coincide → slivers where they overlap, gaps where they do not shared-edge simplify — one edge, one answer zone A zone B the boundary is extracted, simplified once, reused → still an exact partition of the area The slivers are a few metres wide and invisible at any zoom anyone reviews at. They surface as point-in-polygon misses and as zonal statistics that sum to more than the whole area.

Common Failure Modes

A GeoDataFrame with a valid CRS and geometries in a different one. Assigning gdf.crs labels; it does not transform. A layer relabelled rather than reprojected passes every validity check and sits thousands of kilometres from where it belongs. Guard with Gate 2.

Mixed geometry types in one column. A shapefile of “coastline” containing both Polygon and MultiPolygon, or worse, a stray LineString from a digitising error. Most operations handle the first pair transparently and then one does not, usually a format writer. Normalise to multi-part on ingestion.

unary_union on a large layer, in one call. Memory grows super-linearly with the number of parts and the operation is not interruptible. Union in batches — union each thousand features, then union the results — which is both faster and bounded in memory.

Silent CRS loss through a pandas operation. pd.concat on GeoDataFrames returns a DataFrame with an object column, not a GeoDataFrame, and the CRS is gone. The result still plots, because matplotlib does not care, and fails at the next spatial join. Re-wrap explicitly after any pandas-level operation.

Pipeline Integration and Downstream Handoff

The output of this stage is a GeoPackage per layer, in the working projected CRS, with the shoreline datum and epoch recorded in the layer metadata and in the filename. Alongside it goes the RepairReport as JSON — feature counts, how many were repaired, the area delta — which becomes part of the run manifest described in the parent section. Downstream, the masking stage in bathymetric processing consumes the shoreline directly, and the AIS density products consume the same layer to clip traffic rasters to water.

Publish the vertex count and the simplification tolerance in that metadata too. They are the two numbers a downstream consumer needs to decide whether this layer is precise enough for their analysis, and they are the two numbers that are impossible to recover from the geometry alone once several layers have been merged.

Up: Marine Spatial Data Fundamentals & Architecture