Mapping Wildland-Urban Interface Exposure with GeoPandas
Wildfire risk becomes a public matter where structures meet vegetation, and that boundary is not a line — it is a set of conditions that can be measured. This guide delineates the wildland-urban interface from building footprints and a vegetation layer, then scores each structure by how exposed it is, as an applied step inside Wildfire Risk Modeling in Python, part of Fire Risk & Fuel Assessment.
When to use each definition
| Class | Condition | Dominant hazard |
|---|---|---|
| Intermix | Housing density above threshold and vegetation cover above ~50% within the same area | Direct flame contact; fire burns through the settlement |
| Interface | Housing above threshold, vegetation below 50%, but within ~2.4 km of a large vegetated block | Ember cast from adjacent wildland |
| Non-WUI vegetated | Vegetation without housing | Not a structure exposure problem |
| Non-WUI urban | Housing without nearby vegetation | Not a wildland fire exposure problem |
The intermix/interface split matters because it implies different mitigation. Intermix hazard is reduced by fuel treatment between the houses; interface hazard is reduced by ember-resistant construction and by treating the wildland block upwind. Mapping them as one class produces one recommendation for two different problems.
Minimal reproducible example
Building footprints plus a vegetation raster are enough. Work in a projected CRS throughout — every threshold below is a distance or a density.
import geopandas as gpd
import numpy as np
import rasterio
from rasterio.features import rasterize
from scipy import ndimage as ndi
HOUSING_MIN_PER_KM2 = 6.17 # the conventional WUI housing density floor
VEG_FRACTION_MIN = 0.5
INTERFACE_DISTANCE_M = 2400.0
BLOCK_MIN_HA = 500.0
def wui_classes(buildings_path: str, veg_path: str,
cell_m: float = 100.0) -> tuple[np.ndarray, dict]:
"""Classify a landscape into intermix, interface and non-WUI."""
with rasterio.open(veg_path) as src:
veg = (src.read(1) > 0).astype("uint8") # 1 = wildland vegetation
transform, crs, shape = src.transform, src.crs, (src.height, src.width)
native_m = abs(src.transform.a)
b = gpd.read_file(buildings_path).to_crs(crs)
pts = b.geometry.representative_point()
houses = rasterize(((g, 1) for g in pts), out_shape=shape, transform=transform,
fill=0, dtype="uint8", merge_alg=rasterio.enums.MergeAlg.add)
# Aggregate to the analysis cell, then to housing density per square kilometre.
k = max(1, int(round(cell_m / native_m)))
house_count = ndi.uniform_filter(houses.astype("float32"), size=k) * k * k
area_km2 = (cell_m ** 2) / 1e6
density = house_count / area_km2
veg_frac = ndi.uniform_filter(veg.astype("float32"), size=k)
# A large vegetated block within reach defines the interface class.
labels, n = ndi.label(veg)
if n:
sizes = ndi.sum(np.ones_like(labels), labels, index=range(1, n + 1))
big_px = BLOCK_MIN_HA * 10_000.0 / (native_m ** 2)
big = np.isin(labels, [i + 1 for i, s in enumerate(sizes) if s >= big_px])
else:
big = np.zeros_like(veg, dtype=bool)
dist_px = ndi.distance_transform_edt(~big)
near_block = (dist_px * native_m) <= INTERFACE_DISTANCE_M
cls = np.zeros(shape, dtype="uint8") # 0 = non-WUI
populated = density >= HOUSING_MIN_PER_KM2
cls[populated & (veg_frac >= VEG_FRACTION_MIN)] = 1 # intermix
cls[populated & (veg_frac < VEG_FRACTION_MIN) & near_block] = 2 # interface
return cls, {"transform": transform, "crs": crs, "cell_m": cell_m}
The distance transform is doing the interface test, and it is worth understanding why it is a transform rather than a buffer: buffering a large vegetation polygon in vector space is expensive and produces slivers, while a raster distance transform gives the same answer in one pass and returns a continuous distance you can reuse for the exposure score.
Score exposure per structure
The class map says which regime a house is in; the exposure score says how badly. Three terms cover most of the variance and all three are measurable from data you already have.
import geopandas as gpd
import numpy as np
def structure_exposure(buildings: gpd.GeoDataFrame, veg_gdf: gpd.GeoDataFrame,
fuel_sample, slope_sample,
defensible_m: float = 30.0) -> gpd.GeoDataFrame:
"""Score each structure by wildland proximity, upslope position and fuel."""
b = buildings.copy()
veg_union = veg_gdf.geometry.union_all()
b["dist_veg_m"] = b.geometry.distance(veg_union)
b["fuel_t_ha"] = fuel_sample(b.geometry) # sampled from the fuel raster
b["slope_deg"] = slope_sample(b.geometry)
# Each term is 0-1; proximity dominates, which matches post-fire loss studies.
prox = np.clip(1.0 - b["dist_veg_m"] / (defensible_m * 4.0), 0.0, 1.0)
fuel = np.clip(b["fuel_t_ha"] / 20.0, 0.0, 1.0)
slope = np.clip(b["slope_deg"] / 30.0, 0.0, 1.0)
b["exposure"] = (0.5 * prox + 0.3 * fuel + 0.2 * slope).round(3)
b["defensible_space_ok"] = b["dist_veg_m"] >= defensible_m
b["priority"] = np.select(
[b["exposure"] >= 0.7, b["exposure"] >= 0.45],
["high", "moderate"], default="low")
return b
The 30 m defensible-space figure is the widely used minimum and is where most published loss studies find the sharpest break in survival probability. Treating it as a boolean alongside the continuous score is deliberate: the continuous value ranks structures, and the boolean maps onto the regulation people actually have to comply with.
Parameter reference
| Parameter | Type | Default | Range | Rationale |
|---|---|---|---|---|
HOUSING_MIN_PER_KM2 |
float | 6.17 | 6–20 | The conventional WUI density floor — about one house per 16 ha |
VEG_FRACTION_MIN |
float | 0.5 | 0.4–0.6 | Splits intermix from interface |
INTERFACE_DISTANCE_M |
float | 2400 | 1000–3000 | Plausible ember transport distance from a large block |
BLOCK_MIN_HA |
float | 500 | 200–1000 | Minimum vegetated block able to sustain a large fire |
cell_m |
float | 100 | 30–250 | Analysis cell; too coarse and small settlements vanish |
defensible_m |
float | 30 | 10–60 | Defensible-space standard; varies by jurisdiction |
| exposure weights | tuple | 0.5/0.3/0.2 | — | Proximity dominates in observed loss patterns; test the alternatives |
Expected output and verification
Two things need checking: that the class map is plausible against known settlements, and that the exposure score discriminates structures that were actually lost in a past fire.
import numpy as np
def wui_report(cls: np.ndarray, cell_m: float) -> dict:
"""Areas by class, with the sanity checks that catch a broken threshold."""
px_ha = (cell_m ** 2) / 10_000.0
counts = {name: int((cls == i).sum())
for i, name in enumerate(["non_wui", "intermix", "interface"])}
total = int(cls.size)
return {
"intermix_ha": round(counts["intermix"] * px_ha, 1),
"interface_ha": round(counts["interface"] * px_ha, 1),
"wui_fraction": round((counts["intermix"] + counts["interface"]) / total, 4),
}
rep = wui_report(cls, cell_m=100.0)
assert rep["wui_fraction"] < 0.6, \
f"most of the landscape classed as WUI — the density floor is too low: {rep}"
assert rep["intermix_ha"] + rep["interface_ha"] > 0, "no WUI found at all — check the CRS"
Where a past fire destroyed structures, the exposure score can be validated directly, and it is worth doing because it converts a plausible index into an evidenced one:
def validate_exposure(scored: gpd.GeoDataFrame, destroyed_col: str = "destroyed") -> dict:
"""Did destroyed structures score higher than surviving ones?"""
lost = scored.loc[scored[destroyed_col], "exposure"].to_numpy()
kept = scored.loc[~scored[destroyed_col], "exposure"].to_numpy()
if lost.size < 20 or kept.size < 20:
return {"status": "insufficient_sample"}
order = np.argsort(np.concatenate([lost, kept]))
ranks = np.empty(order.size)
ranks[order] = np.arange(1, order.size + 1)
auc = (ranks[: lost.size].sum() - lost.size * (lost.size + 1) / 2) / (lost.size * kept.size)
return {"n_lost": int(lost.size), "n_kept": int(kept.size),
"median_lost": round(float(np.median(lost)), 3),
"median_kept": round(float(np.median(kept)), 3),
"auc": round(float(auc), 3)}
An AUC of 0.65–0.75 is typical for a three-term index against structure loss. Below 0.6 the weights need refitting; above 0.85 check that no term is a proxy for the outcome — distance to vegetation computed after the fire, for instance, is not the same variable as distance before it.
Common pitfalls
- Working in a geographic CRS. Every threshold here is metres or per square kilometre. In degrees they are meaningless and the map still renders.
- Using building points where footprints exist. A point ignores structure size and shape; distance from the nearest wall is what matters, and for large buildings the two differ substantially.
- A vegetation layer that includes lawns and parks. Managed urban vegetation is not wildland fuel, and including it turns whole suburbs into intermix.
- Ignoring the vegetated block size test. Without it, a small copse creates an interface class around it, which dilutes the map with areas no large fire can reach.
- Presenting exposure as probability. It is a ranking. Structure loss probability needs fire likelihood, which comes from the risk model, not from this index.
Frequently Asked Questions
What if I have no building footprint data?
Address points or parcel centroids work, with a caveat: density is preserved but distance-to-vegetation is measured from a point rather than a wall, which under-states exposure for large structures. Open building footprint datasets derived from imagery now cover most of the world and are usually a better starting point than parcels.
Should roads and driveways count as vegetation breaks?
Yes for flame contact and no for embers. A road wide enough to stop a surface fire does nothing about a firebrand landing in a gutter. If the score is meant to inform construction standards, keep roads out of the vegetation break; if it is meant to inform fuel treatment, include them.
How often should the map be rebuilt?
Whenever the inputs move, which in a growing WUI is every two to three years. New construction changes the density term, and vegetation regrowth after a treatment or a fire changes both terms. A WUI map more than five years old usually under-states extent, because settlement expands into vegetation faster than vegetation retreats.
Related
- Wildfire Risk Modeling in Python — where exposure combines with likelihood to become risk
- Computing Fire Weather Index in Python — the weather term of the same risk framework
- Fuel Load Mapping from LiDAR — supplies the fuel term of the exposure score
- Masking Rasters to Stand Boundaries with rasterio.mask — the sampling mechanics behind the per-structure terms
Up: Wildfire Risk Modeling in Python · Fire Risk & Fuel Assessment