Clipping Stockpile Boundaries from Vector Polygons

The volume came back 3.2 % higher than last month and nobody delivered any material. The surfaces are identical to within a centimetre on the hardstanding. The difference is that somebody re-traced the pile boundary, and their line sits forty centimetres further out than the previous one all the way round.

Boundary geometry is the second-largest discretionary input to a volume, after the base surface, and unlike the base it changes every time a human touches it. This page covers rasterizing a boundary so the result is reproducible, deciding what to do with the cells the polygon only partly covers, and the version discipline that stops the boundary being an unrecorded variable.

Why a boundary offset costs so much volume

A stockpile does not end at a line; it tapers. The material sits at its angle of repose, typically 32–38° for crushed aggregate, so the surface near the toe rises roughly 0.7 m for every metre inward. Moving the boundary outward by dd metres adds a wedge whose cross-section is about 12d2tanθ\tfrac{1}{2} d^2 \tan\theta, multiplied by the perimeter.

For a pile with a 160 m perimeter and a 34° repose angle, a 0.4 m outward shift adds around 8.6 m³ — small. But the same shift also includes ground that sits below the base surface plane near the toe, and on a pile where the boundary was previously drawn inside the toe, the recovered slope area is the dominant term. In practice, boundary disagreements of half a metre routinely move real piles by two to four percent, which is larger than most clients’ tolerance.

What a boundary shift adds to a stockpile volume A cross-section through the toe of a stockpile at its angle of repose. Two candidate boundary lines are drawn forty centimetres apart. The wedge of material between them is shaded, and an annotation gives its cross-sectional area as about zero point zero five square metres per metre of perimeter. A second annotation notes that the same shift also recovers slope area that the inner boundary excluded, and that this term is usually larger. A summary states that on a one hundred and sixty metre perimeter the total effect is two to four percent of a typical pile. ground / base surface pile surface at 34° boundary A boundary B (+0.4 m) wedge ≈ ½ d² tan θ per metre of perimeter ≈ 0.054 m² at d = 0.4 m, θ = 34° × 160 m perimeter ≈ 8.6 m³ from the wedge alone The larger term is the slope area an inner boundary excluded entirely — typically 2–4 % of the pile.

Figure 1 — Why “it’s only forty centimetres” is not a defence. The wedge is small; what the boundary excludes is not.

Minimal reproducible solution

Rasterize the polygon deterministically, and make the partial-cell decision explicit rather than inheriting a library default.

import numpy as np
import rasterio
from rasterio.features import rasterize
from shapely.geometry import mapping


def fractional_mask(polygon, transform, shape_hw: tuple[int, int],
                    subsample: int = 4) -> np.ndarray:
    """Per-cell coverage fraction of the polygon, in [0, 1].

    A binary mask forces every edge cell to be fully in or fully out, which
    on a coarse grid is the difference between two operators' volumes. A
    fractional mask computed by supersampling costs a few seconds and removes
    the discretisation from the answer entirely.
    """
    h, w = shape_hw
    fine = rasterize(
        [(mapping(polygon), 1)],
        out_shape=(h * subsample, w * subsample),
        transform=transform * transform.scale(1 / subsample, 1 / subsample),
        fill=0, all_touched=False, dtype="uint8")
    # Average each subsample block back down to the target grid.
    return (fine.reshape(h, subsample, w, subsample)
                .mean(axis=(1, 3))
                .astype("float32"))


def volume_with_fractional_mask(surface: np.ndarray, base: np.ndarray,
                                coverage: np.ndarray, cell: float) -> float:
    """Integrate with partial cells weighted by their coverage."""
    dz = surface - base
    valid = np.isfinite(dz) & (coverage > 0)
    return float(np.nansum(np.where(valid, dz * coverage, 0.0)) * cell ** 2)

Supersampling at 4× means each cell is resolved into sixteen sub-cells, which puts the residual discretisation error below a quarter of a percent of a cell — far below anything else in the budget. Going to 8× costs four times the memory and buys nothing measurable.

The alternative approaches both have a bias. all_touched=False includes only cells whose centre is inside, which systematically under-counts by about half a cell all the way round. all_touched=True includes any cell the polygon touches, which over-counts by a similar amount. On a 10 cm grid over a 160 m perimeter, either bias is worth roughly 8 m² of footprint — small on a tall pile, significant on a shallow one.

Where to draw the line

The polygon is the single largest lever on a stockpile volume, and the best place to draw it depends on what the number is for.

A boundary drawn at the toe compared with one drawn inside it Two columns. The toe boundary column notes that the polygon follows the visible base of the pile, that it includes the full volume, and that it is sensitive to where the operator judged the toe to be, with a metre of ambiguity on a shallow pile moving the volume by several per cent. The inside boundary column notes that the polygon is drawn deliberately inside the toe, that it systematically under-reports, and that it is reproducible between operators and between epochs, which is what matters when the deliverable is a change rather than an absolute. boundary at the toe follows the visible base includes the full volume sensitive to the operator's judgement a metre of toe ambiguity moves it per cent boundary drawn inside deliberately inside the toe systematically under-reports reproducible between operators and between epochs, which is what a change needs For an absolute volume, follow the toe and state the uncertainty. For a change, prefer reproducible.

Figure 3 — The right boundary depends on whether the deliverable is a volume or a difference.

Edge-case matrix

Boundary variant Naive behaviour Correct handling
Polygon in a different CRS Silently misplaced or empty mask Reproject and assert the CRS matches the raster
Multi-part polygon (two lobes) Only the first part rasterized Iterate parts, or pass a MultiPolygon
Polygon with a hole (access ramp) Hole filled in Honour interiors; rasterize does by default
Self-intersecting ring Undefined coverage Run make_valid and fail if the area changes materially
Boundary outside the raster extent Silent partial clip Compare polygon bounds against raster bounds and fail
Vertices in the wrong winding order Usually fine, sometimes inverted Normalise orientation before use
Boundary drawn on last month’s imagery Correct geometry, wrong epoch Version the polygon with a valid-from date
Coordinates in feet, raster in metres Volume off by ~10× Assert CRS units before any arithmetic
from shapely.validation import make_valid


def sanitise_boundary(polygon, raster_crs, polygon_crs, raster_bounds):
    """Every check that has to pass before a polygon may define a volume."""
    if str(polygon_crs) != str(raster_crs):
        raise ValueError(f"boundary is {polygon_crs}, raster is {raster_crs}")
    if not polygon.is_valid:
        fixed = make_valid(polygon)
        if abs(fixed.area - polygon.area) / max(polygon.area, 1e-9) > 0.001:
            raise ValueError("repairing the boundary changed its area by >0.1 %")
        polygon = fixed
    minx, miny, maxx, maxy = polygon.bounds
    rminx, rminy, rmaxx, rmaxy = raster_bounds
    if minx < rminx or miny < rminy or maxx > rmaxx or maxy > rmaxy:
        raise ValueError("boundary extends beyond the surface raster")
    return polygon

Verification snippet

The boundary’s contribution to the answer can be measured directly by perturbing it, which turns “is the boundary good enough” into a number in the same units as the deliverable.

import numpy as np


def boundary_sensitivity(polygon, surface, base, transform, shape_hw,
                         cell: float, offsets=(-0.5, -0.25, 0.0, 0.25, 0.5)) -> dict:
    """Volume as a function of buffering the boundary in and out."""
    out = {}
    for d in offsets:
        buffered = polygon.buffer(d)
        cov = fractional_mask(buffered, transform, shape_hw)
        out[d] = volume_with_fractional_mask(surface, base, cov, cell)

    v0 = out[0.0]
    per_metre = (out[0.5] - out[-0.5]) / 1.0
    return {"volumes_m3": out,
            "m3_per_metre_of_offset": float(per_metre),
            "percent_per_0_25m": float(abs(per_metre * 0.25 / v0) * 100)}

Reporting “each 25 cm of boundary uncertainty is worth 1.8 % of this volume” does two useful things. It tells the operator how carefully to trace, and it gives the client a concrete reason why the boundary is versioned rather than re-drawn each month.

Binary against fractional cell coverage at the boundary A grid of square cells with a curved boundary crossing it. In the binary treatment each cell is fully black or fully white depending on whether its centre falls inside, producing a stepped edge that departs from the true line by up to half a cell. In the fractional treatment each edge cell carries a coverage value between zero and one, shown as intermediate shading, and the aggregate area matches the polygon to within a fraction of a percent. A note gives the residual error of each approach on a ten centimetre grid. binary: centre-in test fractional: supersampled up to half a cell of error, all the way round area matches the polygon to under 0.25 % Four-times supersampling costs a few seconds and removes the grid from the answer. On a shallow pile the binary bias is worth more than the reconstruction error. Neither all_touched setting is unbiased; both are wrong by about half a cell, in opposite directions.

Figure 2 — The discretisation, and the cheapest way to stop caring about it.

Versioning the boundary so it stops being a variable

The technical fix above removes the grid from the answer. It does not remove the operator, and the operator is the larger source of variance. The organisational fix is to treat the boundary as a versioned asset with the same seriousness as the base surface.

Three conventions work. One polygon per pile, stored with the site, not per survey — a new survey reads the existing geometry rather than producing its own. A valid-from date on each version, so that when a pile genuinely changes footprint the change is a new version with a reason attached rather than an untracked edit. A diff in the run record: if the boundary used differs from the previous run’s, the volume record says so and reports the sensitivity figure alongside, so a reviewer sees immediately that the change is explained.

import hashlib
import json
from pathlib import Path


def boundary_version(path: str) -> dict:
    """Identify the exact boundary geometry a volume was computed against."""
    raw = Path(path).read_bytes()
    return {"path": path,
            "sha256_16": hashlib.sha256(raw).hexdigest()[:16],
            "feature_count": len(json.loads(raw)["features"])}


def compare_to_previous(current: dict, previous: dict | None) -> str | None:
    """Return a human-readable note when the boundary changed between runs."""
    if previous is None:
        return "first run for this pile — boundary established"
    if current["sha256_16"] != previous["sha256_16"]:
        return (f"boundary changed since the previous run "
                f"({previous['sha256_16']}{current['sha256_16']}): "
                "volume comparison is not like-for-like")
    return None

The note is what makes the discipline stick. A monitoring report that silently used a different boundary is indistinguishable from one that did not; a report carrying that sentence in bold is one where nobody has to reconstruct what happened.

When to escalate

  • The pile genuinely changed shape and the old boundary no longer contains it. Cut a new version with a reason, and expect the series to step. Do not quietly enlarge the old one.
  • Two piles have merged. The geometry question has become a commercial one — which material belongs to which stock — and no automated answer is appropriate. Escalate before processing.
  • The boundary sensitivity exceeds the client’s tolerance on its own. That is a shallow pile with a long perimeter, where a volume is a weak measurement. Say so, and consider whether an area-and-depth product answers the question better.

Computing Volumes and Stockpiles in Python