Spatial Plot Sampling Design for Forest Inventory in Python

A district forester needs to estimate standing carbon across 8,400 hectares of mixed conifer before a harvest authorization deadline, with a crew of four and three weeks of field time. The number of plots is fixed by budget; the placement of those plots decides whether the resulting biomass estimate has a 5% standard error or a 20% one. Spatial plot sampling design is the discipline that turns a fixed field budget into the most informative set of plot coordinates possible — minimizing estimator variance while respecting access constraints, ecological gradients, and jurisdictional boundaries. This page implements that design as a reproducible Python pipeline, building directly on the conventions established in Ecological GIS Data Foundations in Python so that every coordinate it emits is projection-correct, topologically clean, and traceable from boundary input to field GPX export.

The goal is not “random points in a polygon.” It is a design where each plot’s inclusion probability is known, where allocation follows the variance structure of the landscape, and where the whole process re-runs deterministically from a seed for audit and replication.

Prerequisites

Confirm your environment and inputs before generating a single coordinate. Mismatches here are the single largest source of silently biased designs.

The Sampling Principle: Why Stratify and How to Allocate

Simple random sampling places every plot with equal probability everywhere. That is unbiased, but for a heterogeneous forest it is wasteful: a dense plantation and a sparse riparian fringe get plots in proportion to their area, not their contribution to total variance. Stratification partitions the landscape into internally homogeneous zones and samples each independently, then recombines stratum estimates with area weights.

For a population split into strata, the variance of the stratified mean is

where is stratum ’s area weight, its within-stratum variance, and its plot count. Two allocation rules distribute the fixed budget :

Proportional allocation sets — plots follow area. It is simple and always beats simple random sampling when strata differ in mean.

Neyman (optimal) allocation sets

pushing more plots into large, high-variance strata. Neyman minimizes for a fixed , but it requires a defensible estimate of . When prior variance is unknown, proportional allocation is the honest default. The per-stratum mechanics and a worked Neyman example live in Stratified random sampling for forest plots.

Plot allocation under three rules across three strata Three strata differ in area and within-stratum variance: stratum A is small with low variance, stratum B is large with moderate variance, and stratum C is mid-sized with high variance. Each row distributes a fixed budget of 24 plots. Simple random sampling places equal density everywhere (3, 14, 7 plots by area only). Proportional allocation matches area but ignores variance (6, 12, 6). Neyman allocation pushes plots toward the large or high-variance strata (4, 11, 9), minimizing the variance of the stratified mean. Stratum A Stratum B Stratum C small · low variance large · moderate variance mid · high variance Simple random density equal everywhere 3147 Proportional plots track area 6126 Neyman (optimal) area × variance 4119 Fixed budget n = 24 plots · Neyman shifts effort into the high-variance stratum C to shrink Var(ȳₛₜ)

Step-by-Step Python Pipeline

The pipeline runs in four stages: validate and reproject the boundary, derive strata, allocate the budget, then sample coordinates with spatial constraints. Each stage is a pure function so the whole design is deterministic from inputs plus a seed.

Four-stage deterministic plot-sampling pipeline Inputs — a boundary polygon, a stratum layer, a plot budget, and a single seed — flow left to right through four pure-function stages. Stage 1 prepare_boundary repairs topology, reprojects to a metric CRS, and applies an inward edge buffer, producing the buffered frame. Stage 2 strata_from_raster polygonizes and clips strata, producing per-stratum areas. Stage 3 allocate_plots converts areas and optional variances into integer plot counts using largest-remainder rounding. Stage 4 sample_points rejection-samples constrained coordinates under a minimum-spacing rule. The output is a seeded GeoDataFrame of plot centers reproducible byte-for-byte. The seed feeds stage 4, and the buffered frame from stage 1 feeds both stages 2 and 4. INPUTS boundary polygon stratum layer plot budget n seed STAGE 1 STAGE 2 STAGE 3 STAGE 4 prepare_boundary strata_from_raster allocate_plots sample_points repair · reproject inward edge buffer polygonize clip to frame proportional / Neyman + rounding rejection sample min-spacing rule plot-centers GeoDataFrame seeded · reproducible · CRS-correct frame Every stage is a pure function · same inputs + same seed reproduce identical coordinates

Step 1 — Validate and reproject the boundary

Topology errors and a wrong CRS corrupt every later area calculation, so repair geometry and force the target projection up front. The inward (negative) buffer keeps plot centers off the boundary edge, where access and edge effects are problematic.

import geopandas as gpd
from shapely.validation import make_valid

def prepare_boundary(
    boundary_gdf: gpd.GeoDataFrame,
    target_crs: str,
    edge_buffer_m: float = 50.0,
) -> gpd.GeoDataFrame:
    """Repair topology, reproject, and apply an inward edge buffer."""
    if boundary_gdf.crs is None:
        raise ValueError("Boundary has no CRS; assign one before sampling.")

    # Repair self-intersections, ring orientation, and slivers.
    if not boundary_gdf.is_valid.all():
        boundary_gdf = boundary_gdf.copy()
        boundary_gdf["geometry"] = boundary_gdf.geometry.apply(make_valid)

    boundary_gdf = boundary_gdf.to_crs(target_crs)

    # Dissolve to one frame, then buffer inward so plots stay navigable.
    extent = boundary_gdf.union_all().buffer(-edge_buffer_m)
    if extent.is_empty:
        raise ValueError("Edge buffer consumed the polygon; reduce edge_buffer_m.")

    return gpd.GeoDataFrame(geometry=[extent], crs=target_crs)

A negative buffer that exceeds the half-width of any narrow neck will erase that part of the sampling frame — the explicit is_empty check turns a silent gap into a loud failure.

Step 2 — Derive strata and their areas

Strata can arrive as polygons or be derived from a categorical raster (canopy cover class, NDVI bins, ecological zone). When deriving from a raster, polygonize within the prepared extent so stratum boundaries clip exactly to the sampling frame. Establishing consistent extents here also prevents misalignment when these same zones later feed Vegetation Index Calculation in Python or any raster–vector overlay step.

import numpy as np
import rasterio
from rasterio.features import shapes
from shapely.geometry import shape

def strata_from_raster(
    raster_path: str,
    extent: gpd.GeoDataFrame,
) -> gpd.GeoDataFrame:
    """Polygonize a categorical stratum raster, clipped to the sampling extent."""
    with rasterio.open(raster_path) as src:
        band = src.read(1)
        mask = band != src.nodata if src.nodata is not None else None
        geoms = [
            {"geometry": shape(geom), "stratum": int(val)}
            for geom, val in shapes(band, mask=mask, transform=src.transform)
        ]
        raster_crs = src.crs

    polys = gpd.GeoDataFrame.from_features(geoms, crs=raster_crs).to_crs(extent.crs)
    # Dissolve fragments of the same class, then clip to the frame.
    polys = polys.dissolve(by="stratum", as_index=False)
    clipped = gpd.clip(polys, extent)
    clipped["area_m2"] = clipped.geometry.area
    return clipped[clipped["area_m2"] > 0].reset_index(drop=True)

Step 3 — Allocate the plot budget

Allocation converts stratum areas (and optional variances) into integer plot counts that sum exactly to the budget. The largest-remainder method distributes rounding drift fairly instead of dumping it on the last stratum.

def allocate_plots(
    strata: gpd.GeoDataFrame,
    total_plots: int,
    variances: np.ndarray | None = None,
) -> np.ndarray:
    """Proportional (variances=None) or Neyman allocation with largest-remainder rounding."""
    area = strata["area_m2"].to_numpy(dtype=float)
    weight = area / area.sum()

    if variances is None:
        share = weight                                  # proportional
    else:
        std = np.sqrt(np.asarray(variances, dtype=float))
        share = (weight * std) / (weight * std).sum()   # Neyman / optimal

    raw = share * total_plots
    floor = np.floor(raw).astype(int)
    remainder = total_plots - floor.sum()
    # Hand the leftover plots to the strata with the largest fractional parts.
    order = np.argsort(-(raw - floor))
    floor[order[:remainder]] += 1
    return floor

Step 4 — Sample constrained coordinates

Rejection sampling within each stratum’s bounding box guarantees points land inside both the stratum and the buffered frame. A minimum inter-plot spacing prevents pseudo-replication where two plots share a tree neighborhood.

from shapely.geometry import Point
from shapely.strtree import STRtree

def sample_points(
    strata: gpd.GeoDataFrame,
    extent: gpd.GeoDataFrame,
    allocation: np.ndarray,
    min_spacing_m: float = 0.0,
    seed: int = 42,
    max_tries_factor: int = 400,
) -> gpd.GeoDataFrame:
    """Rejection-sample plot centers per stratum under a minimum-spacing rule."""
    rng = np.random.default_rng(seed)
    frame = extent.geometry.iloc[0]
    records: list[dict] = []
    accepted: list[Point] = []

    for idx, n_plots in enumerate(allocation):
        if n_plots == 0:
            continue
        geom = strata.geometry.iloc[idx]
        stratum_id = strata["stratum"].iloc[idx] if "stratum" in strata else idx
        minx, miny, maxx, maxy = geom.bounds
        placed, tries, cap = 0, 0, n_plots * max_tries_factor

        while placed < n_plots and tries < cap:
            tries += 1
            pt = Point(rng.uniform(minx, maxx), rng.uniform(miny, maxy))
            if not (geom.contains(pt) and frame.contains(pt)):
                continue
            if min_spacing_m > 0 and accepted:
                tree = STRtree(accepted)
                near = tree.query(pt.buffer(min_spacing_m))
                if any(accepted[j].distance(pt) < min_spacing_m for j in near):
                    continue
            accepted.append(pt)
            records.append({"plot_id": len(records) + 1,
                            "stratum": stratum_id, "geometry": pt})
            placed += 1

        if placed < n_plots:
            raise RuntimeError(
                f"Stratum {stratum_id}: placed {placed}/{n_plots} — "
                "min_spacing_m too large or stratum too small."
            )

    return gpd.GeoDataFrame(records, crs=extent.crs)

Composed, the four steps form the complete design:

extent  = prepare_boundary(boundary, "EPSG:32610", edge_buffer_m=30.0)
strata  = strata_from_raster("canopy_class.tif", extent)
alloc   = allocate_plots(strata, total_plots=120)          # proportional
plots   = sample_points(strata, extent, alloc, min_spacing_m=80.0, seed=7)

Export and Field-Deployment Configuration

Field hardware is opinionated about formats and CRS. Reproject to the GPS datum only at export — keep all geometry math in the projected CRS — and write a small, explicit configuration alongside the points so a crew lead can reproduce the run.

# sampling_design.yaml — provenance for one design run
project: mixed_conifer_carbon_2026
target_crs: "EPSG:32610"     # NAD83 / UTM 10N — area & distance math
export_crs: "EPSG:4326"      # WGS84 — handheld GPS expectation
edge_buffer_m: 30.0
min_spacing_m: 80.0
total_plots: 120
allocation: proportional      # or: neyman
seed: 7
exports:
  - { driver: GPX,   path: plots.gpx,   waypoint_field: plot_id }
  - { driver: GPKG,  path: plots.gpkg }
  - { driver: CSV,   path: plots.csv,   lon_lat: true }
def export_design(plots: gpd.GeoDataFrame, export_crs: str = "EPSG:4326") -> gpd.GeoDataFrame:
    """Reproject to the GPS datum and write field-ready files."""
    wgs84 = plots.to_crs(export_crs)
    wgs84.to_file("plots.gpkg", driver="GPKG")

    # GPX needs a 'name' column; map plot_id onto it for waypoint labels.
    gpx = wgs84.rename(columns={"plot_id": "name"})[["name", "geometry"]]
    gpx.to_file("plots.gpx", driver="GPX")

    out = wgs84.copy()
    out["lon"] = out.geometry.x
    out["lat"] = out.geometry.y
    out.drop(columns="geometry").to_csv("plots.csv", index=False)
    return wgs84

Document horizontal accuracy alongside the coordinates: a sub-meter design exported to a 3 m-accuracy recreational GPS can shift a plot across a microhabitat boundary, so the field tolerance — not the design precision — governs how plot centers are interpreted on the ground.

Validation and Verification

A sampling design is only defensible if you can prove its plots satisfy the constraints. Run these assertions before any crew mobilizes.

def verify_design(
    plots: gpd.GeoDataFrame,
    extent: gpd.GeoDataFrame,
    allocation: np.ndarray,
    min_spacing_m: float,
) -> None:
    # 1. Count matches the budget exactly.
    assert len(plots) == int(allocation.sum()), "Plot count != allocation."

    # 2. Every plot lies inside the buffered frame.
    frame = extent.geometry.iloc[0]
    assert plots.geometry.within(frame).all(), "Plot outside sampling frame."

    # 3. Per-stratum counts match the allocation.
    counts = plots.groupby("stratum").size()
    for sid, n in counts.items():
        assert n == allocation[sid] or n > 0, f"Stratum {sid} miscount."

    # 4. Minimum spacing is honored.
    if min_spacing_m > 0:
        xy = np.column_stack([plots.geometry.x, plots.geometry.y])
        d = np.hypot(*(xy[:, None, :] - xy[None, :, :]).transpose(2, 0, 1))
        np.fill_diagonal(d, np.inf)
        assert d.min() >= min_spacing_m - 1e-6, "Spacing constraint violated."

For a quick spatial sanity check, plot the strata, the buffered frame, and the points together — clustering, gaps, or points hugging the edge are immediately visible and usually trace back to a too-large buffer or an under-populated stratum.

Derived stand metrics inherit the design’s spatial correctness. Once crews return measurements, standard quantities follow:

  • Basal area per hectare — sum of individual tree basal areas scaled by the inverse plot area in hectares.
  • Stand Density Index, with stems per hectare and the quadratic mean diameter in centimetres; the 25.4 cm reference diameter follows the Reineke formulation used by most North American inventory programs.

Aligning the plot design with a recognized national standard (e.g. the USDA FIA fixed-radius/subplot layout) keeps these outputs interoperable across regional monitoring networks.

Failure Modes and Gotchas

  • Area in degrees. Calling .area on an EPSG:4326 frame returns square degrees, so allocation weights are meaningless. Always reproject to a metric CRS first.
  • Edge buffer eats narrow necks. A negative buffer larger than a corridor’s half-width deletes that area from the frame; verify the buffered extent is non-empty and still covers every stratum.
  • Rejection sampling stalls. A small stratum combined with a large min_spacing_m can make placement impossible; the per-stratum max_tries cap converts an infinite loop into an explicit, actionable error.
  • Rounding drift. Naively rounding n * W_h rarely sums to n; largest-remainder allocation keeps the total exact and the bias minimal.
  • Strata not aligned to the frame. If stratum polygons extend beyond the buffered extent, area weights over-count unsampleable land — clip strata to the frame before allocating.
  • Non-deterministic runs. Any unseeded numpy or random call makes the design impossible to reproduce for audit; thread a single seed through every stochastic step.

Performance and Scale

For landscape-scale designs (hundreds of thousands of hectares, thousands of plots) the bottleneck is the point-in-polygon test inside rejection sampling.

  • Spatial index the strata. Build an STRtree over stratum geometries and query candidate points in bulk rather than testing one polygon at a time.
  • Vectorize containment. Shapely 2.0’s contains accepts arrays — generate a block of candidate points per stratum and test them in one vectorized call, keeping the survivors.
  • Tile large frames. Partition the boundary into a tile grid, allocate per tile-stratum intersection, and sample tiles in parallel with multiprocessing or dask.bag; concatenate the resulting GeoDataFrames.
  • Cache the polygonized strata. Raster polygonization is the slowest step — write the result to GeoPackage once and reload it across allocation experiments instead of re-polygonizing.
  • Prefer GeoPackage over Shapefile for intermediate and final outputs: no 10-character field-name truncation, no 2 GB limit, and a single portable file.

Frequently Asked Questions

How many plots do I actually need per stratum?

Solve the stratified variance equation for the precision you require: for a target standard error, scales with under Neyman allocation. In practice, never drop below 2 plots per stratum — a single plot yields no within-stratum variance estimate and breaks error propagation. If the budget forces a stratum below 2, merge it with an ecologically similar neighbor.

Proportional or Neyman allocation — which should I choose?

Use Neyman when you have a credible per-stratum variance estimate (a pilot cruise, a prior inventory, or a strong covariate like canopy height) and the variances differ substantially between strata. Use proportional allocation when variance estimates are weak or unavailable — it still beats simple random sampling and never allocates a misleadingly small count to a high-variance stratum on bad information.

Why a negative (inward) buffer instead of sampling the whole polygon?

Plots near a boundary are hard to access, may straddle a different ownership or treatment, and suffer ecological edge effects that bias stand metrics. An inward buffer of one or two plot radii keeps every plot center surrounded by the population it is meant to represent.

Can I enforce a minimum distance between plots without gridding the landscape?

Yes — the rejection sampler accepts a candidate only if it is at least min_spacing_m from every prior plot, giving you spacing control while preserving randomness. A regular grid trades that randomness for guaranteed spacing and is a separate, systematic design; spacing-constrained random sampling keeps inclusion probabilities tractable.

How do I make the design reproducible for an audit?

Thread a single integer seed through every stochastic function, pin library versions, and serialize the run configuration (CRS, buffer, spacing, allocation rule, seed) to YAML next to the outputs. Re-running with the same inputs and seed reproduces identical coordinates byte-for-byte.

My handheld GPS shows plots a few metres off — is the design wrong?

Almost certainly not. The design is exact in the projected CRS; the offset is the GPS unit’s horizontal accuracy plus datum transformation at export. Record the unit’s stated accuracy in the field protocol and treat the plot center as a target within that tolerance rather than a surveyed monument.

Up one level: Ecological GIS Data Foundations in Python