Post-Fire Vegetation Recovery Monitoring in Python

Burn severity tells you what the fire did; recovery monitoring tells you what happened next, and it is the part that determines management. A stand that regains 80% of its pre-fire greenness within three years needs nothing; one that is still bare after five is a reforestation liability, a reburn risk, and — where it is a legal obligation — a compliance problem. The concrete problem this page solves: you have a fire perimeter, a pre-fire baseline, and a decade of Sentinel-2 or Landsat imagery, and you need a per-pixel recovery trajectory plus a defensible recovery class for every stand. It belongs to Fire Risk & Fuel Assessment and picks up where Burn Severity Mapping with Python leaves off.

Recovery analysis is fundamentally a time-series problem rather than a two-date one, and that changes what can go wrong. A single cloudy summer, a change in the satellite’s processing baseline, or a drought year all look like recovery signals. The defences are a reference-based normalisation, a dense enough series that a median absorbs contamination, and a trajectory model whose parameters have physical meaning.

The post-fire recovery monitoring pipeline A left-to-right flow. A fire perimeter and a pre-fire baseline composite feed an annual composite series built from cloud-masked imagery. That series is normalised against unburned reference pixels of the same forest type, then fitted per pixel to a recovery trajectory whose parameters are recovery rate and asymptote. The fitted parameters produce a recovery class map, and a branch carries them into a reburn risk assessment. fire perimeter + severity classes pre-fire baseline 3-year median annual composites cloud-masked medians normalise vs unburned reference fit trajectory rate + asymptote recovery class per pixel and per stand reburn risk fuel rebuild reference pixels same type, unburned Normalisation against unburned reference is what separates recovery from a wet or dry year. Without it, every drought reads as a recovery reversal across the whole fire scar.

Prerequisites

The anniversary window is the item most often skipped and the one that most often ruins a series. Comparing a June composite in one year with an August composite in the next measures phenology, not recovery.

Concept: Which Indicator, and Why Normalise

Three indicators dominate, and they answer different questions.

NBR — the normalised burn ratio — is built from near-infrared and shortwave-infrared reflectance and is the standard for both severity and recovery, because SWIR responds strongly to the loss and return of canopy moisture:

NDVI tracks green biomass and recovers faster than NBR, because grasses and forbs green up long before canopy structure returns. That difference is informative rather than a nuisance: a stand where NDVI has recovered and NBR has not is a grass-dominated scar, which is a different management case from a regenerating conifer stand.

Relative recovery turns either into a proportion of what was lost:

where is the pre-fire baseline, the first post-fire measurement, and the value in year . An RRI of 1.0 means the indicator has returned to its pre-fire level; values above 1.0 are common in productive sites where fast-growing regeneration overshoots the mature canopy’s signal.

The normalisation step is separate and equally important. Climate variability moves every pixel in a region together, so subtracting the concurrent value of unburned reference pixels of the same forest type removes the shared signal and leaves the recovery.

Raw against reference-normalised recovery Two curves are plotted against years since fire. The raw indicator rises from the post-fire low but dips sharply in year four, which was a regional drought, giving the appearance of a recovery reversal. The unburned reference curve dips in the same year by a similar amount. The normalised curve, which is the difference between them, rises smoothly with no reversal, showing that the dip was climate rather than a change in the burned stand. 0 2 4 6 8 10 years since fire NBR burned, raw unburned reference normalised difference drought year The raw curve reverses; the reference curve reverses by the same amount; the difference never does.

Step-by-Step Python Pipeline

Step 1 — Build one composite per year in a fixed window

import numpy as np
import xarray as xr


def annual_composites(stack: xr.DataArray, month_start: int = 7,
                      month_end: int = 8) -> xr.DataArray:
    """Median composite per year over a fixed anniversary window."""
    sel = stack.sel(time=stack["time"].dt.month.isin(range(month_start, month_end + 1)))
    out = sel.groupby("time.year").median(dim="time", skipna=True)
    # Carry the clear-observation count so thin years can be flagged downstream.
    n = sel.notnull().groupby("time.year").sum(dim="time")
    return out.assign_coords(n_clear=("year", n.min(dim=("y", "x")).values))

Keeping the observation count as a coordinate is what lets you distinguish a genuine recovery plateau from a year where two cloudy scenes produced a noisy median.

Step 2 — Establish the pre-fire baseline and the immediate post-fire floor

def pre_post_levels(annual: xr.DataArray, fire_year: int, n_pre: int = 3):
    """Pre-fire baseline (median of n_pre years) and the first post-fire value."""
    pre_years = [fire_year - k for k in range(1, n_pre + 1)]
    pre = annual.sel(year=[y for y in pre_years if y in annual["year"]]).median(dim="year")
    post = annual.sel(year=fire_year + 1)
    return pre, post

Use the year after the fire as the floor, not the fire year itself: a mid-season fire leaves the fire-year composite as a mixture of pre- and post-fire observations, which biases the floor upward and every subsequent recovery ratio downward.

Step 3 — Normalise against unburned reference pixels

import numpy as np


def reference_normalise(annual: xr.DataArray, burned_mask: np.ndarray,
                        forest_type: np.ndarray, buffer_mask: np.ndarray) -> xr.DataArray:
    """Subtract the concurrent median of unburned pixels of the same forest type."""
    out = annual.copy()
    for t in np.unique(forest_type[burned_mask]):
        ref_sel = (~burned_mask) & buffer_mask & (forest_type == t)
        if ref_sel.sum() < 100:
            continue                       # too few reference pixels to trust
        ref_series = annual.where(xr.DataArray(ref_sel, dims=("y", "x"))).median(
            dim=("y", "x"), skipna=True)
        target = xr.DataArray(burned_mask & (forest_type == t), dims=("y", "x"))
        out = xr.where(target, annual - ref_series, out)
    return out

The buffer_mask restricts reference pixels to a ring around the fire — typically 1–5 km — so they share the same weather, soils and elevation band as the burned area. Reference pixels drawn from a hundred kilometres away introduce a climate gradient that looks exactly like differential recovery.

Step 4 — Fit a recovery trajectory per pixel

A saturating exponential is the standard form: it has two parameters, both of which mean something, and it cannot predict unbounded growth.

is the asymptote — how much of the lost signal will eventually return — and is the rate, whose reciprocal is the recovery time constant in years.

import numpy as np
from scipy.optimize import curve_fit


def _recovery_model(t, A, k, floor):
    return A * (1.0 - np.exp(-k * t)) + floor


def fit_recovery(years_since: np.ndarray, values: np.ndarray, floor: float) -> dict:
    """Fit the saturating recovery curve for one pixel's time series."""
    ok = np.isfinite(values)
    if ok.sum() < 4:
        return {"A": np.nan, "k": np.nan, "r2": np.nan, "n": int(ok.sum())}
    t, y = years_since[ok], values[ok]
    try:
        (A, k, _), _ = curve_fit(
            lambda tt, AA, kk: _recovery_model(tt, AA, kk, floor),
            t, y, p0=[max(y.max() - floor, 1e-3), 0.3],
            bounds=([0.0, 0.01], [np.inf, 3.0]), maxfev=2000)
    except (RuntimeError, ValueError):
        return {"A": np.nan, "k": np.nan, "r2": np.nan, "n": int(ok.sum())}
    pred = _recovery_model(t, A, k, floor)
    ss_res = float(((y - pred) ** 2).sum())
    ss_tot = float(((y - y.mean()) ** 2).sum())
    return {"A": float(A), "k": float(k),
            "half_life_yr": float(np.log(2) / k),
            "r2": 1.0 - ss_res / ss_tot if ss_tot else np.nan,
            "n": int(ok.sum())}

The bounds are doing real work. Without a lower bound on the optimiser will happily return a rate near zero with an enormous asymptote, which fits a straight line through five noisy points and predicts recovery in three centuries.

Step 5 — Classify and summarise per stand

import numpy as np


def classify_recovery(rri_year5: np.ndarray, k: np.ndarray) -> np.ndarray:
    """Recovery classes from year-5 relative recovery and the fitted rate."""
    cls = np.zeros(rri_year5.shape, dtype="uint8")     # 0 = no data
    cls[np.isfinite(rri_year5)] = 1                    # 1 = not recovering
    cls[(rri_year5 >= 0.25) & (k >= 0.10)] = 2         # 2 = slow
    cls[(rri_year5 >= 0.50) & (k >= 0.20)] = 3         # 3 = moderate
    cls[(rri_year5 >= 0.75) & (k >= 0.35)] = 4         # 4 = rapid
    return cls

Using both the level and the rate is deliberate. A pixel at 60% of pre-fire with a high rate is on a healthy trajectory; the same 60% with a rate near zero has stalled, and only the second needs intervention.

Why recovery class needs both a level and a rate A two by four matrix. The horizontal axis is relative recovery at year five, increasing from under a quarter to over three quarters. The vertical axis is the fitted recovery rate, split into low and high. Cells in the high-rate row are labelled as on-trajectory and need no action; cells in the low-rate row are labelled as stalled and flagged for intervention even where the recovery level is the same. Two cells at the same recovery level but different rates are highlighted to make the point. not recovering slow moderate rapid replant monitor no action no action not recovering stalled stalled plateaued replant intervene intervene re-survey rate high rate low k ≥ 0.2 k < 0.2 < 0.25 0.25–0.50 0.50–0.75 > 0.75 relative recovery at year 5 Both highlighted cells sit at the same recovery level. Only the rate separates a stand that will get there from one that has stopped.

Validation & Verification

Recovery maps are hard to validate directly because nobody measures “percent recovered” in the field. Three checks make the product defensible anyway:

import numpy as np


def recovery_diagnostics(rri, severity, k, r2) -> dict:
    """Relationships that must hold if the trajectory fits mean anything."""
    ok = np.isfinite(rri) & np.isfinite(severity) & np.isfinite(k)
    # High-severity pixels must recover more slowly than low-severity ones.
    hi = ok & (severity >= 0.66)
    lo = ok & (severity < 0.27)
    return {
        "median_rri_high_sev": float(np.median(rri[hi])),
        "median_rri_low_sev": float(np.median(rri[lo])),
        "median_k_high_sev": float(np.median(k[hi])),
        "median_k_low_sev": float(np.median(k[lo])),
        "median_r2": float(np.nanmedian(r2)),
        "unfitted_frac": float(np.mean(~np.isfinite(k))),
    }


d = recovery_diagnostics(rri5, dnbr, k_map, r2_map)
assert d["median_rri_high_sev"] < d["median_rri_low_sev"], \
    "high-severity pixels recovering faster than low — check the severity raster sign"
assert d["median_r2"] > 0.5, f"trajectory fits are poor: {d}"
assert d["unfitted_frac"] < 0.2, f"too many pixels failed to fit: {d}"

The severity-versus-recovery relationship is the strongest available internal check. It is a robust ecological expectation — hard-burned ground recovers more slowly — so a violation almost always means a sign error in dNBR or a mismatched fire year rather than a surprising forest.

Where field data exists, validate against regeneration survey plots: a Spearman correlation between fitted recovery rate and measured seedling density above about 0.5 is a good result, and the residuals are usually informative about site preparation and planting history rather than about the remote sensing.

Failure Modes & Gotchas

  • A fire-year composite used as the post-fire floor. Mid-season fires make that composite a blend of burned and unburned observations, biasing every ratio.
  • Reference pixels drawn from a different forest type. A conifer scar normalised against broadleaf reference recovers “too fast” because the reference greens up on a different schedule.
  • Phenology mistaken for recovery. A shifting seasonal window produces a trend that is entirely calendar. Fix the anniversary window and check the composite dates.
  • Sensor changes mid-series. Landsat 7 to 8, or a Sentinel-2 processing-baseline change, introduces a step. Harmonise with published coefficients or split the series at the boundary.
  • Fitting on fewer than four clear years. The two-parameter model is unidentifiable, and the optimiser returns confident nonsense rather than failing.
  • Ignoring reburn. A second fire inside the window is a discontinuity, not a recovery reversal. Screen for it explicitly rather than letting the fit absorb it.

Performance & Scale Notes

Per-pixel curve fitting is the expensive step: an 8,000 ha fire at 20 m is roughly 200,000 pixels, and curve_fit costs milliseconds each. Two changes make it tractable. First, fit only where it matters — mask to the burn perimeter and skip pixels with fewer than four clear years. Second, vectorise the easy cases: relative recovery at a fixed year needs no optimiser, so compute the RRI map for everything and fit trajectories only where the classification is ambiguous.

import concurrent.futures

import numpy as np


def fit_recovery_grid(cube: np.ndarray, years_since: np.ndarray,
                      floor: np.ndarray, mask: np.ndarray, workers: int = 6):
    """Fit trajectories over a masked grid. cube is (time, y, x)."""
    rows, cols = np.where(mask)
    def one(i):
        r, c = rows[i], cols[i]
        return fit_recovery(years_since, cube[:, r, c], float(floor[r, c]))
    with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as pool:
        results = list(pool.map(one, range(len(rows))))
    A = np.full(mask.shape, np.nan, dtype="float32")
    k = np.full(mask.shape, np.nan, dtype="float32")
    for i, res in enumerate(results):
        A[rows[i], cols[i]] = res["A"]
        k[rows[i], cols[i]] = res["k"]
    return A, k

For a regional multi-fire analysis, aggregate first: fitting one trajectory per stand, or per severity class within each stand, is usually more useful than a per-pixel map and is two orders of magnitude cheaper.

Frequently Asked Questions

How many years do I need before a recovery figure means anything?

Five post-fire growing seasons is the practical minimum for a two-parameter fit, and the asymptote stays poorly constrained until the curve has begun to flatten — often eight to ten years in slow-growing systems. Before that, report the relative recovery at a fixed year rather than a fitted asymptote, because the level is measured and the asymptote is extrapolated.

Should I use NBR or NDVI for recovery?

Both, and compare them. NBR tracks canopy structure and moisture and is the better proxy for whether forest is returning; NDVI tracks green cover and recovers faster because herbaceous vegetation comes first. The gap between them is itself the diagnosis: NDVI recovered with NBR flat means a grass or shrub scar, which is exactly the reburn-prone case.

What counts as “recovered”?

There is no single answer, which is why the threshold has to be stated. Common operational definitions are 80% of the pre-fire indicator, a return to within one standard deviation of unburned reference pixels of the same type, or a management target such as a stocking standard. Record the definition with the map — two agencies reporting different recovery percentages for the same fire are usually using different thresholds, not different data.

Can I run this on Landsat instead of Sentinel-2?

Yes, and for fires before 2015 you have no choice. Landsat’s 30 m resolution and 16-day revisit give sparser annual composites, so expect more years with too few clear observations, and harmonise reflectance across the Landsat 5, 7, 8 and 9 boundaries before fitting anything.

How do I handle a fire that reburns during the monitoring window?

Detect it and split. A second dNBR spike inside the series is a new event, so the correct treatment is to end the first trajectory at the reburn date and start a new one after it. Letting one saturating curve span both fires produces a low rate and a low asymptote everywhere, which reads as a failure to regenerate rather than as two fires.

Up: Fire Risk & Fuel Assessment