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.

Intermix and interface at the same housing density Two panels with the same number of buildings. On the left, an intermix pattern: buildings are scattered individually through continuous tree cover, so vegetation surrounds every structure. On the right, an interface pattern: the same buildings are packed into a compact settlement with little vegetation between them, adjacent to a solid block of forest with a sharp edge. Annotations note that the intermix hazard is direct flame contact while the interface hazard is ember cast across the edge. Intermix Interface vegetation surrounds every structure hazard: direct flame contact sharp edge, embers cross it hazard: ember cast

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.

Structure survival against distance to wildland vegetation A curve of structure survival rate against distance from the nearest wildland vegetation. Survival rises steeply from about thirty percent at zero metres to about seventy five percent at thirty metres, then flattens, reaching only about ninety percent by one hundred metres. A vertical line marks the thirty metre defensible space standard at the point where the curve begins to flatten. 0% 25% 50% 75% 100% 0 30 60 100 distance to wildland vegetation (m) survival 30 m defensible space steep here flat here Most of the achievable gain sits in the first thirty metres, which is why the standard is where it is. Defensible space as three concentric zones Three concentric bands surround a building footprint. The immediate zone, from the wall out to one and a half metres, is non-combustible: no mulch, no shrubs, no stored fuel. The intermediate zone out to ten metres is kept lean, with spaced shrubs and irrigated ground cover. The extended zone out to thirty metres is a reduced-fuel area with thinned trees, pruned lower branches and surface fuels removed. Each band lists its prescription. 0–1.5 m 1.5–10 m 10–30 m immediate — non-combustible gravel or bare soil, no mulch, no stored fuel intermediate — lean and clean spaced shrubs, irrigated ground cover, no ladder extended — reduced fuel thinned trees, pruned to 3 m, surface fuel removed The exposure score ranks structures; these zones are what a ranked structure is actually offered.

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.

Up: Wildfire Risk Modeling in Python · Fire Risk & Fuel Assessment