Detecting Reburn Risk from Recovery and Fuel Rasters in Python

A burned stand is not automatically safe. Within three to eight years the fine surface fuel that a fire consumed has usually rebuilt as grass, shrub and standing dead material — often more continuous and more flammable than what was there before — while the canopy that would have shaded and moderated it has not returned. That window is when a scar reburns, and reburns are disproportionately severe because there is no mature overstory left to lose slowly. This guide builds a hazard surface that finds those stands, inside Post-Fire Vegetation Recovery Monitoring, part of Fire Risk & Fuel Assessment.

When to use this approach

Question Product This page
Where might a scar carry fire again? Reburn hazard surface Yes
How fast would it spread if it did? Rate-of-spread model Fire Behavior and Rate of Spread Modeling
What is the annual probability of ignition? Risk model with weather and ignition history Wildfire Risk Modeling in Python
How much fuel is actually there? Fuel load raster Fuel Load Mapping from LiDAR

Reburn hazard is a susceptibility surface, not a probability. It says which parts of a scar would carry fire well; turning that into an annual likelihood needs ignition and weather, which belong to the risk model.

The three components

The hazard is a conjunction, not a sum of independent effects. A stand is reburn-prone when all three hold: recovery has stalled, surface fuel has rebuilt, and topography or wind exposure would carry fire.

with the relative recovery, the normalised surface fuel index, the topographic exposure, and the exponents setting how strongly each term dominates. A multiplicative form is deliberate: a stand with abundant fuel but a closed recovering canopy should score low, and an additive index would let the fuel term carry it.

The three conditions that must coincide for reburn hazard Three overlapping circles. The first is stalled recovery, the second is rebuilt surface fuel, the third is topographic and wind exposure. Each pairwise overlap is annotated with why two conditions alone are not enough: stalled recovery with fuel but no exposure spreads slowly, fuel with exposure but a recovering canopy is shaded and moist, and stalled recovery with exposure but no fuel has nothing to carry fire. The central three-way intersection is labelled as the reburn hazard zone. stalled recovery rebuilt surface fuel exposure canopy gone, fuel present bare and exposed, nothing to burn fuel and wind, but shaded reburn hazard Two of three is not a hazard. A multiplicative index enforces that; an additive one does not.

Minimal reproducible example

Each component is normalised to 0–1 before combining, and each normalisation is a modelling decision worth stating.

import numpy as np
import rasterio


def normalise(arr: np.ndarray, lo: float, hi: float) -> np.ndarray:
    """Linear scale to 0-1 between stated breakpoints, clipped at both ends."""
    out = (arr - lo) / (hi - lo)
    return np.clip(out, 0.0, 1.0).astype("float32")


def reburn_hazard(rri: np.ndarray, surface_fuel_t_ha: np.ndarray,
                  slope_deg: np.ndarray, exposure: np.ndarray,
                  w_r: float = 1.0, w_f: float = 1.0, w_e: float = 0.6) -> np.ndarray:
    """Multiplicative reburn susceptibility in 0-1."""
    stalled = 1.0 - normalise(rri, 0.0, 0.8)              # 0 recovery -> 1.0
    fuel = normalise(surface_fuel_t_ha, 2.0, 15.0)        # t/ha of fine surface fuel
    slope_term = normalise(slope_deg, 0.0, 35.0)
    expose = np.clip(0.5 * slope_term + 0.5 * normalise(exposure, 0.0, 1.0), 0.0, 1.0)

    h = (stalled ** w_r) * (fuel ** w_f) * (expose ** w_e)
    h[~np.isfinite(rri) | ~np.isfinite(surface_fuel_t_ha)] = np.nan
    return h.astype("float32")

The fuel breakpoints carry most of the model’s behaviour. Two tonnes per hectare of fine surface fuel is around the threshold below which fire will not carry continuously in most temperate systems; 15 t/ha is a heavily loaded shrub or slash bed. Those numbers are system-specific and should be replaced with local fuel-model values rather than inherited.

The exposure input is whatever wind-exposure proxy you have — a topographic exposure index, a distance-to-ridge measure, or a modelled wind multiplier. Where none exists, slope alone is a reasonable stand-in, which is why it is weighted more lightly than the other two terms.

Parameter reference

Parameter Type Default Range Rationale
recovery breakpoints tuple (0.0, 0.8) Above 0.8 relative recovery, canopy shading has largely returned
fuel breakpoints t/ha (2, 15) system-specific Below the lower value fire will not carry; above the upper it is saturated
slope breakpoint degrees 35 25–45 Spread rate rises steeply with slope and then saturates
w_r float 1.0 0.8–1.5 Recovery weight; raise where canopy closure is the dominant control
w_f float 1.0 0.8–1.5 Fuel weight; raise in fuel-limited systems
w_e float 0.6 0.4–1.0 Exposure weight; keep it lower when using slope as a proxy
assessment window years 3–8 2–12 The period when fuel has rebuilt but canopy has not

Test the weights before trusting the map

A weighted index looks authoritative and is usually more sensitive to its weights than to its data. Vary each weight across its plausible range and measure how much the high-hazard area moves; if it swings by a factor of two, the weights are the model.

import itertools

import numpy as np


def weight_sensitivity(rri, fuel, slope, exposure, threshold: float = 0.5) -> list[dict]:
    """How much does the high-hazard area move as the weights vary?"""
    rows = []
    for w_r, w_f, w_e in itertools.product((0.8, 1.0, 1.4), repeat=3):
        h = reburn_hazard(rri, fuel, slope, exposure, w_r=w_r, w_f=w_f, w_e=w_e)
        rows.append({
            "w_r": w_r, "w_f": w_f, "w_e": w_e,
            "high_frac": round(float(np.nanmean(h >= threshold)), 4),
        })
    fr = [r["high_frac"] for r in rows]
    print(f"high-hazard fraction ranges {min(fr):.3f}-{max(fr):.3f} "
          f"({max(fr) / max(min(fr), 1e-6):.1f}x) across weight combinations")
    return rows

Report that range with the map. A hazard surface whose high class covers 8% of the scar under one weighting and 24% under another is not wrong, but it is a scenario rather than a measurement, and presenting it as the latter is how a planning process ends up defending an arbitrary exponent.

Sensitivity of the high-hazard area to the weight choice Two horizontal bands show the fraction of the fire scar classified as high hazard. The uncalibrated model spans from eight to twenty four percent across twenty seven weight combinations, a threefold range, with the default weighting marked near the middle. The calibrated model, whose weights were fitted against observed reburn locations, spans only fourteen to eighteen percent. 0% 10% 20% 30% 40% fire scar classified as high reburn hazard expert weights default 8–24% · 3.0× range calibrated weights 14–18% · 1.3× range Calibration does not make the map right; it makes the uncertainty small enough to plan against.

Expected output and verification

The honest validation is retrospective: build the hazard surface as it would have looked before a reburn that has since occurred, and check whether the area that actually reburned scored higher than the area that did not.

import numpy as np


def validate_against_reburn(hazard: np.ndarray, reburned: np.ndarray) -> dict:
    """Compare hazard scores inside and outside an observed reburn footprint."""
    ok = np.isfinite(hazard)
    inside = hazard[ok & reburned]
    outside = hazard[ok & ~reburned]
    if inside.size < 50 or outside.size < 50:
        return {"status": "insufficient_sample"}

    # AUC via the Mann-Whitney relationship: P(hazard_inside > hazard_outside).
    order = np.argsort(np.concatenate([inside, outside]))
    ranks = np.empty(order.size, dtype="float64")
    ranks[order] = np.arange(1, order.size + 1)
    r_in = ranks[: inside.size].sum()
    auc = (r_in - inside.size * (inside.size + 1) / 2) / (inside.size * outside.size)
    return {
        "median_inside": round(float(np.median(inside)), 3),
        "median_outside": round(float(np.median(outside)), 3),
        "auc": round(float(auc), 3),
        "n_inside": int(inside.size),
    }


res = validate_against_reburn(hazard_2019, reburn_2024_mask)
assert res["auc"] > 0.6, f"hazard surface does not discriminate reburned ground: {res}"

An AUC near 0.5 means the surface is no better than chance and the weights need calibrating against data rather than intuition. An AUC above about 0.75 is a strong result for a susceptibility index built from three coarse components.

Where no reburn has occurred, the surface can still be sanity-checked against its own logic — the machinery must reject inputs it should refuse:

n = 1000
rng = np.random.default_rng(20260809)

# Fully recovered canopy: hazard must be near zero regardless of fuel.
h = reburn_hazard(np.full(n, 0.95), np.full(n, 20.0),
                  np.full(n, 30.0), np.full(n, 0.9))
assert np.nanmax(h) < 0.1, "a fully recovered stand scored as high hazard"

# No fuel: hazard must be zero regardless of recovery and exposure.
h = reburn_hazard(np.zeros(n), np.full(n, 0.5),
                  np.full(n, 35.0), np.ones(n))
assert np.nanmax(h) < 0.05, "a fuel-free stand scored as hazardous"
The window between fuel returning and canopy closing Two curves are plotted against years since fire. Surface fine fuel load rises steeply from near zero in the first three years and plateaus by about year six. Canopy closure lags, staying near zero until year five and only approaching pre-fire levels after fifteen. The shaded region between year three and year twelve, where fuel is high and canopy is still open, is labelled as the reburn window. 0 3 6 12 18 years since fire relative level surface fine fuel canopy closure reburn window Assess inside the window; outside it the index has nothing to distinguish.

Common pitfalls

  • Adding the components instead of multiplying them. An additive index lets a single high term carry a pixel, which produces high hazard on bare mineral soil with no fuel at all.
  • Using a fuel layer from before the fire. The whole point is the fuel that has rebuilt since. A pre-fire fuel raster describes vegetation that no longer exists.
  • Applying the model outside the reburn window. Before roughly year three there is not enough fuel; after canopy closure the surface fuel is shaded and moist. Outside that band the index means little.
  • Treating susceptibility as probability. Without ignition and weather there is no annual likelihood, and presenting a 0–1 index as a probability invites exactly that misreading.
  • Never testing the weights. An unexamined weighted index is an opinion with a raster attached.

Frequently Asked Questions

Where do I get a post-fire surface fuel layer?

Three sources, in order of preference: a repeat LiDAR acquisition, which measures the rebuilt shrub layer directly; a fuel model raster updated from post-fire imagery, which is what most agencies maintain; or a proxy built from the recovery index itself, which is weakest because it makes the fuel and recovery terms partly the same variable. If you must use the proxy, drop the fuel weight and say why.

How does standing dead timber fit in?

It raises long-term hazard and lowers short-term spread — snags carry little fine fuel until they fall, and then they add a heavy woody load over five to fifteen years. If a snag density layer exists, treat it as a separate term with a delayed onset rather than folding it into surface fuel, because its timing is different.

Can this run before any recovery data exists?

Not usefully. In the first two post-fire years there is neither meaningful recovery signal nor rebuilt fuel, so every term is near its floor. The earliest defensible assessment is around year three, which is also roughly when the operational decision about replanting is made.

Up: Post-Fire Recovery Monitoring · Fire Risk & Fuel Assessment