Generating a Hexagonal Plot Grid with GeoPandas
A systematic sampling lattice has to answer one question well: where do I put n plots so that no part of the ownership is over- or under-represented? A square grid is the obvious answer and a slightly worse one, because a square’s corners are much further from its centre than its edges are, so coverage is uneven in a way that shows up in variance estimates. A hexagonal lattice fixes that with the same amount of code. This guide builds one, as a concrete design inside Spatial Plot Sampling Design, part of Ecological GIS Data Foundations in Python.
When to use this approach
| Layout | Coverage evenness | Bias risk | Use when |
|---|---|---|---|
| Hexagonal lattice | Best — every point is closest to a near-equidistant centre | Aliasing against periodic features | Systematic inventory, monitoring networks, habitat surveys |
| Square grid | Good, but corner-biased | Aliasing against roads, rows, and drainage | Compatibility with an existing grid or a national frame |
| Simple random | Even in expectation, clumpy in practice | None, but high variance | Small samples where design-based inference is essential |
| Stratified random | Even within strata | Depends on the strata | Strata that genuinely differ, as in Stratified Random Sampling for Forest Plots |
| Hexagonal with random start | Best, plus randomisation | Low | The practical default for a repeatable inventory |
The hexagonal lattice with a random origin is the design most national inventories converge on, because it combines even coverage with a randomisation that supports design-based estimation.
Spacing from a target plot count
The geometry is fixed. A regular hexagon of circumradius — centre to vertex — has area
and hexagons tile without gaps, so for a target of plots over an area the spacing follows directly:
For 200 plots over a 12,000 ha ownership that gives m and a centre-to-centre spacing of about 263 m. Working from the target count rather than picking a round spacing is what keeps the sample size predictable when the ownership boundary is irregular.
Minimal reproducible example
Build the lattice in the ownership’s own projected CRS, generate centres row by row with alternate rows offset, and clip to the boundary.
import math
import geopandas as gpd
import numpy as np
from shapely.geometry import Point, Polygon
from shapely.ops import unary_union
def hex_spacing_for_count(area_m2: float, n_plots: int) -> tuple[float, float]:
"""Circumradius R and centre spacing for n hexagons tiling an area."""
R = math.sqrt(2.0 * area_m2 / (3.0 * math.sqrt(3.0) * n_plots))
return R, math.sqrt(3.0) * R
def hex_centres(bounds, R: float, rng: np.random.Generator) -> list[Point]:
"""Hexagon centres over a bounding box, with a random origin offset."""
minx, miny, maxx, maxy = bounds
dx = math.sqrt(3.0) * R # horizontal centre spacing
dy = 1.5 * R # vertical spacing between rows
# Random start so the lattice is not aligned to the bounding box corner.
x0 = minx - dx + rng.uniform(0, dx)
y0 = miny - dy + rng.uniform(0, dy)
pts, row = [], 0
y = y0
while y <= maxy + dy:
offset = (dx / 2.0) if row % 2 else 0.0
x = x0 + offset
while x <= maxx + dx:
pts.append(Point(x, y))
x += dx
y += dy
row += 1
return pts
def hexagon(centre: Point, R: float) -> Polygon:
"""Pointy-top regular hexagon of circumradius R around a centre."""
return Polygon([
(centre.x + R * math.sin(math.pi / 3 * k),
centre.y + R * math.cos(math.pi / 3 * k))
for k in range(6)
])
Putting them together, clipping to the ownership and keeping only cells whose centre lands on forest:
def hex_plot_grid(boundary_path: str, n_plots: int, seed: int = 20260809,
min_overlap: float = 0.5) -> gpd.GeoDataFrame:
"""A hexagonal plot lattice clipped to an ownership boundary."""
boundary = gpd.read_file(boundary_path)
if boundary.crs is None or boundary.crs.is_geographic:
raise ValueError("reproject to a projected, metre-based CRS first")
area_geom = unary_union(boundary.geometry.values)
R, spacing = hex_spacing_for_count(area_geom.area, n_plots)
rng = np.random.default_rng(seed)
rows = []
for i, c in enumerate(hex_centres(area_geom.bounds, R, rng)):
cell = hexagon(c, R)
inter = cell.intersection(area_geom)
if inter.is_empty or inter.area < min_overlap * cell.area:
continue
rows.append({
"plot_id": len(rows) + 1,
"centre_x": c.x,
"centre_y": c.y,
"cell_area_ha": cell.area / 10_000.0,
"inside_frac": inter.area / cell.area,
"on_boundary": inter.area < 0.999 * cell.area,
"geometry": cell,
})
gdf = gpd.GeoDataFrame(rows, crs=boundary.crs)
gdf.attrs["circumradius_m"] = R
gdf.attrs["centre_spacing_m"] = spacing
gdf.attrs["seed"] = seed
return gdf
The min_overlap rule is the edge policy, and it needs to be stated rather than defaulted. Keeping every cell that touches the boundary over-samples the edge; keeping only fully interior cells under-samples it and can lose a thin ownership entirely. Half coverage is a defensible middle, and recording inside_frac lets an estimator weight edge plots properly afterwards.
Parameter reference
| Parameter | Type | Default | Range | Rationale |
|---|---|---|---|---|
n_plots |
int | — | 30–1000 | Drives spacing; set it from a variance target, not from habit |
seed |
int | fixed | any | A recorded seed makes the layout reproducible and auditable |
min_overlap |
float | 0.5 | 0.3–0.7 | Edge policy; below 0.3 the sample is dominated by slivers |
| orientation | — | pointy-top | pointy or flat | Rotate the lattice if the ownership has a strong directional grain |
R |
float (m) | derived | — | Circumradius; centre spacing is √3 R |
| plot radius | float (m) | 11.3 | 8–20 | The measurement plot inside each cell, unrelated to cell size |
Expected output and verification
Three properties define a correct lattice: the plot count is close to target, the nearest-neighbour distances are tight around the intended spacing, and the plots cover the ownership without a systematic hole.
import numpy as np
from scipy.spatial import cKDTree
def audit_grid(gdf, target_n: int) -> dict:
"""Check count, spacing regularity, and coverage of a plot lattice."""
xy = np.column_stack([gdf["centre_x"], gdf["centre_y"]])
tree = cKDTree(xy)
d, _ = tree.query(xy, k=2) # k=1 is the point itself
nn = d[:, 1]
return {
"n": len(gdf),
"target": target_n,
"count_error_pct": round(100.0 * (len(gdf) - target_n) / target_n, 1),
"nn_mean_m": round(float(nn.mean()), 1),
"nn_cv": round(float(nn.std() / nn.mean()), 3),
"edge_plots": int(gdf["on_boundary"].sum()),
}
report = audit_grid(grid, target_n=200)
assert abs(report["count_error_pct"]) < 15, f"plot count far from target: {report}"
assert report["nn_cv"] < 0.08, f"lattice is irregular — check the row offset: {report}"
The coefficient of variation of nearest-neighbour distance is the sharpest diagnostic. A correct hexagonal lattice has every interior centre at exactly the same distance from its six neighbours, so the CV is dominated by edge cells and stays below about 0.05. A CV above 0.2 means the alternate-row offset is missing and you have built a rectangular grid with extra steps.
Common pitfalls
- Building the lattice in degrees. Spacing in a geographic CRS is not a distance, and the cells become progressively wider towards the equator. Reproject first.
- Anchoring the origin to the bounding box. Without a random offset the lattice is deterministic given the extent, which forfeits the randomisation that design-based estimators rely on.
- No edge policy. Silently keeping every touched cell over-weights the perimeter, which in a fragmented ownership can be most of the sample.
- Confusing cell size with plot size. The hexagon is the sampling frame; the measurement plot is a small circle at its centre. Setting the plot radius to the hexagon radius means adjacent plots overlap.
- Regenerating the grid without the seed. A monitoring network that relocates between visits is no longer a time series.
Frequently Asked Questions
Does hexagonal really beat square in practice?
For coverage evenness, measurably: a hexagon’s furthest interior point is 1.15 times its edge distance against 1.41 for a square, so the worst-case gap is smaller. Whether that improves your specific estimator depends on the spatial structure of what you are measuring, but it never makes it worse and it costs nothing.
How do I add a second, denser phase later?
Keep the same origin and seed, halve the circumradius, and the new lattice nests inside the old one — every original centre remains a centre in the finer lattice. That preserves the earlier plots as a subsample and makes the two phases directly comparable.
Should the hexagons be pointy-top or flat-top?
Geometrically identical, just rotated. Choose the orientation whose rows run across the dominant grain of the landscape — perpendicular to contour, planting rows, or drainage — so the lattice does not alias against a periodic feature.
Related
- Spatial Plot Sampling Design — the wider treatment of layout and inference
- Systematic vs Random Plot Layouts in Python — the design trade-off this layout sits inside
- Stratified Random Sampling for Forest Plots — when strata should drive allocation instead
- Choosing an Equal-Area CRS for Forest Area Statistics — the frame the spacing calculation depends on
Up: Spatial Plot Sampling Design · Ecological GIS Data Foundations in Python