Handling Overlapping Buffer Zones from Multiple Water Features

Problem statement

A single field rarely has a single setback. A stream runs along one edge, a farm pond sits in a low corner, an irrigation well is drilled near the headland, and a neighbor’s dwelling stands just past the fence. Each of those features carries its own required no-application distance under the product label and the standards described in the EPA’s pesticide worker-safety and setback guidance, and those distances routinely differ — a wide strip along a perennial stream, a tighter ring around a wellhead. Where the features are close together their buffers overlap, and where a small buffer sits entirely inside a larger one it is nested. Feed those overlapping shapes to a sprayer controller as separate polygons and two failures follow: the machine either double-counts the overlap and shuts off area that was never restricted, or it processes them independently and leaves a sliver between two buffers sprayable when the combined intent was to exclude it.

The correct output is one combined exclusion geometry: a single dissolved region that is the union of every feature’s own buffer, with overlaps merged and nested buffers absorbed, that the EPA buffer-zone enforcement engine can subtract from the field to leave the sprayable area. Three problems make this non-trivial:

  • Differing setback distances. Every feature must be buffered by its own distance before anything is merged. Buffering the union of the raw features by one distance applies the wrong setback to all of them — the classic mistake that silently under-protects a stream or over-restricts a wellhead.
  • Nested and coincident overlaps. A dwelling buffer fully inside a stream buffer must be absorbed cleanly, and two features at the same location must not produce a self-touching seam that later raises a topology error.
  • Auditability of a dissolved shape. Once buffers are merged, the individual contributions are no longer visible in the geometry. The merge has to record each feature’s setback and the combined area so a compliance reviewer can reconstruct why the exclusion has the shape it does.

The remedy is a deterministic pipeline: buffer each feature by its own distance, unary_union the results to dissolve every overlap and containment, repair validity, optionally simplify while preserving topology, and emit one geometry plus an audit record.

Merging per-feature setback buffers into one dissolved exclusion geometry Four setback features are each buffered by their own distance, then unary_union dissolves overlaps and nested containment, make_valid and topology-preserving simplify clean the result, and one combined exclusion MultiPolygon is emitted alongside an append-only audit ledger of per-feature setbacks and merged area. Stream buffer line · 30 m Pond buffer polygon · 15 m Well buffer point · 30 m Dwelling buffer point · 8 m Buffer each feature by its OWN setback float(setback_m) quad_segs = 16 unary_union() dissolve overlaps absorb nested make_valid() simplify preserve_topology Combined exclusion one MultiPolygon sprayable = field − exclusion Audit ledger (append-only) per-feature setback · merged area_m2 max_setback_m · EPA setback basis

Parameter reference table

The values below govern how faithfully the merged exclusion reproduces the intended no-application area and how cleanly it dissolves. Recommended values assume features supplied in a projected CRS with metre units (a local UTM zone), not raw WGS84 degrees.

Parameter Type Recommended value Effect on behavior
setback_m Decimal per-feature label value The regulated distance for one feature, buffered before any merge. Kept as Decimal so the exact label value survives into the audit record; converted to float only at the shapely boundary.
quad_segs int 16 Segments per quarter circle when buffering points and curves. Higher approximates a true circle more closely at the cost of vertices; below ~8 a wellhead ring becomes visibly polygonal and can under-cover near the arc.
simplify_tol_m float 0.0 Vertex-reduction tolerance in metres. Leave at 0.0 for regulatory geometry; if a task controller needs fewer vertices, use a small value with preserve_topology=True so the boundary never collapses inward.
CRS units requirement metres (UTM) buffer interprets its distance in the geometry’s own units. Buffering WGS84 degrees by a metre value is meaningless and silently wrong — reproject to a metre CRS first.
min setback validation > 0 A zero or negative setback is rejected rather than buffered, since a non-positive buffer would erode the feature instead of protecting around it.

Store each feature’s setback_m and its regulatory basis together in one record so the merge can emit them verbatim; never let a distance live only as a literal inside buffering code.

Runnable implementation

The module below merges any list of setback features into one exclusion geometry. It targets Python 3.10+, is fully typed, uses Decimal for the regulated distances, and depends only on shapely. Features are assumed to arrive in a projected, metre-unit CRS.

python
from __future__ import annotations

import logging
from dataclasses import dataclass
from decimal import Decimal
from enum import Enum

from shapely.geometry.base import BaseGeometry
from shapely.ops import unary_union
from shapely.validation import make_valid

logger = logging.getLogger("buffer.merge")


class FeatureKind(str, Enum):
    STREAM = "stream"
    POND = "pond"
    WELL = "well"
    DWELLING = "dwelling"
    WETLAND = "wetland"


@dataclass(frozen=True)
class SetbackFeature:
    feature_id: str
    kind: FeatureKind
    geometry: BaseGeometry  # projected CRS, metre units (e.g. a local UTM zone)
    setback_m: Decimal      # regulated distance; Decimal keeps the label value exact


@dataclass(frozen=True)
class ExclusionResult:
    geometry: BaseGeometry
    feature_count: int
    part_count: int
    max_setback_m: Decimal
    area_m2: Decimal


def _buffered(feature: SetbackFeature, quad_segs: int) -> BaseGeometry:
    """Buffer ONE feature by ITS OWN setback, before anything is merged."""
    if feature.setback_m <= 0:  # a non-positive buffer would erode, not protect
        raise ValueError(f"{feature.feature_id}: setback must be positive")
    geom = feature.geometry if feature.geometry.is_valid else make_valid(feature.geometry)
    # Decimal -> float only at the shapely boundary; the record keeps the exact value.
    return geom.buffer(float(feature.setback_m), quad_segs=quad_segs)


def _part_count(geom: BaseGeometry) -> int:
    return len(geom.geoms) if geom.geom_type == "MultiPolygon" else 1


def merge_exclusion_zones(
    features: list[SetbackFeature],
    quad_segs: int = 16,
    simplify_tol_m: float = 0.0,
) -> ExclusionResult:
    """Dissolve per-feature buffers into one exclusion geometry.

    Each feature is buffered by its own distance FIRST, so differing setbacks
    are preserved; unary_union then merges overlaps and absorbs nested buffers.
    """
    if not features:  # an empty exclusion would silently declare the whole field sprayable
        raise ValueError("no setback features supplied; refusing empty exclusion")

    buffers = [_buffered(f, quad_segs) for f in features]
    merged = unary_union(buffers)  # dissolves every overlap and nested containment

    if simplify_tol_m > 0:
        # preserve_topology keeps the boundary from collapsing inward past a setback
        merged = merged.simplify(simplify_tol_m, preserve_topology=True)
    if not merged.is_valid:
        merged = make_valid(merged)

    max_setback = max(f.setback_m for f in features)
    result = ExclusionResult(
        geometry=merged,
        feature_count=len(features),
        part_count=_part_count(merged),
        max_setback_m=max_setback,
        area_m2=Decimal(str(merged.area)).quantize(Decimal("0.01")),
    )
    logger.info(
        '{"event":"exclusion_merged","features":%d,"parts":%d,'
        '"area_m2":"%s","max_setback_m":"%s"}',
        result.feature_count, result.part_count, result.area_m2, max_setback,
    )
    return result


def is_point_sprayable(point: BaseGeometry, exclusion: ExclusionResult) -> bool:
    """A location is sprayable only if it lies strictly outside every buffer."""
    inside = exclusion.geometry.covers(point)  # covers() includes the boundary edge
    if inside:
        logger.warning('{"event":"point_in_exclusion","area_m2":"%s"}', exclusion.area_m2)
    return not inside

The load-bearing detail is the order of operations. Each feature is buffered by its own setback_m inside _buffered before unary_union ever runs, so a 30 m stream strip and an 8 m dwelling ring keep their true widths in the dissolved result. Union the raw features first and buffer once, and every feature would inherit a single distance — under-protecting the stream or over-restricting the dwelling. unary_union handles nested buffers for free: a dwelling buffer entirely inside the stream buffer is absorbed with no seam, and covers (rather than contains) is used in the sprayable check so a point sitting exactly on the setback edge counts as excluded, which is the conservative reading.

Log patterns and observable signals

Every merge emits structured JSON so a reviewer can tie a dissolved shape back to its inputs.

Normal merge of four features into one connected region:

json
{"event": "exclusion_merged", "features": 4, "parts": 1, "area_m2": "48210.75", "max_setback_m": "30"}

Merge that stayed disjoint (two clusters of features that do not touch):

json
{"event": "exclusion_merged", "features": 5, "parts": 2, "area_m2": "71640.20", "max_setback_m": "30"}

A queried application point fell inside the exclusion (application there must be blocked):

json
{"event": "point_in_exclusion", "area_m2": "48210.75"}

When triaging, compare parts against what you expect on the ground. A field where every feature is clustered should merge to parts: 1; a sudden jump in parts can mean a feature was supplied in the wrong CRS and buffered into empty space. Watch area_m2 over time for a given field — a large unexplained swing usually means a setback distance changed upstream or a feature’s geometry was re-imported, and it should reconcile against the values the buffer-zone enforcement engine subtracts from the field.

Safe override protocol

Occasionally an operator must merge features whose recorded setback differs from the label default — a state rule that mandates a wider stream buffer than the federal minimum, applied for one field. The override must never lower a distance below the regulatory floor and must never bypass the merge; it only substitutes a validated, larger distance before buffering.

Guard conditions, all mandatory:

  1. Never below the floor. The override distance must be greater than or equal to the label-mandated minimum for that feature kind; a smaller value is rejected outright.
  2. Explicit per-feature, never blanket. The override names the feature and its new distance; there is no single multiplier silently applied to every buffer.
  3. Recorded basis. The regulatory citation justifying the wider buffer, the approver, and the original distance are written to the append-only ledger.
  4. Dry-run reconciliation. The override merge runs without persisting and its area_m2 is compared to the standard merge so an unexpected shrink is caught before it is stored.
python
from decimal import Decimal


def apply_setback_override(
    feature: SetbackFeature,
    override_m: Decimal,
    floor_m: Decimal,  # label-mandated minimum for this feature kind
    approver: str,
    basis: str,        # e.g. a state rule citation widening the federal minimum
) -> SetbackFeature:
    if override_m < floor_m:  # an override may only widen, never erode, protection
        raise ValueError(
            f"{feature.feature_id}: override {override_m} below floor {floor_m}"
        )
    logger.warning(
        '{"event":"setback_override","feature_id":"%s","from_m":"%s",'
        '"to_m":"%s","approver":"%s","basis":"%s"}',
        feature.feature_id, feature.setback_m, override_m, approver, basis,
    )
    # frozen dataclass: return a new record so the original stays intact for audit
    return SetbackFeature(
        feature_id=feature.feature_id,
        kind=feature.kind,
        geometry=feature.geometry,
        setback_m=override_m,
    )

Requiring floor_m and basis as arguments with no defaults keeps the override one-directional: it can only widen an exclusion with a recorded justification, never quietly narrow one, matching the intent of EPA pesticide setback requirements.

Troubleshooting

  • The merged exclusion is far larger or smaller than the field. Root cause: features were supplied in WGS84 degrees, so buffer applied the distance in degrees rather than metres. Remediation: reproject every feature to a local metre-unit CRS (UTM) before calling merge_exclusion_zones; buffering degrees is meaningless.
  • A TopologyException surfaces downstream when the exclusion is subtracted. Root cause: two coincident features produced a self-touching seam that slipped through. Remediation: keep the make_valid step after unary_union, and confirm no feature carries an invalid input geometry — _buffered repairs each one before buffering.
  • A small buffer seems to have vanished from the result. Not a bug: a buffer fully inside a larger one is nested and absorbed by the union. Confirm via the audit record that its setback_m is still listed among the merged features; the geometry no longer shows it separately by design.
  • The sprayable check passes for a point right on the boundary. Root cause: contains was used instead of covers, so the exact edge was treated as outside. Remediation: use covers for the conservative reading, which counts a point on the setback line as excluded.
  • area_m2 jumps between runs for an unchanged field. Root cause: a setback distance or a feature geometry changed upstream, or simplify_tol_m was raised too high and shaved the boundary. Remediation: diff the per-feature setbacks in the ledger, and keep simplify_tol_m at 0.0 for regulatory output.

Frequently asked questions

Why buffer each feature before merging? Because setback distances differ. Buffering each feature by its own distance first preserves those widths; unioning raw features and buffering once would force a single distance onto all of them, under-protecting the wide setbacks and over-restricting the tight ones.

How are nested buffers handled? unary_union absorbs a smaller buffer that sits entirely inside a larger one into the single dissolved region with no seam. The nested feature still appears in the audit ledger, so its inclusion stays verifiable even though the geometry no longer shows it separately.

What CRS should features use? A projected metre-unit CRS such as UTM. buffer interprets its distance in the geometry’s own units, so buffering WGS84 degrees by a metre value is silently wrong — reproject first.

Up: Buffer Zone Calculations · Crop Application Timing & Agronomic Validation