How to Find Where a Wildfire Reburned Earlier Fires in Python

On this page

Problem statement

Fires in fire-prone landscapes burn into the scars of earlier fires. Knowing how much of a new fire reburned old ones, and how long ago, matters for ecology, fuel management and recovery planning. Ground that burns twice in a few years can convert from forest to shrub. Ground that has not burned for a century carries heavy fuel. Answering it is an overlay of one perimeter with a century of others. That overlay goes wrong easily: older perimeters overlap one another, so their areas cannot simply be added, many records are invalid polygons, and the historical record gets thinner the further back it goes.

This page overlays the 2024 Park Fire with every earlier perimeter in CAL FIRE's historical archive. It measures how much of the fire had burned before, how many times, and how recently.

Quick answer

Take the earlier perimeters that intersect the fire, clip them to it, and count how many overlap each piece of ground with a union overlay:

earlier = hist[(hist.YEAR_ < 2024) & hist.intersects(PARK)].copy()
earlier["geometry"] = earlier.geometry.intersection(PARK)
pieces = gpd.GeoDataFrame(geometry=list(polygonize(unary_union([g.boundary for g in [PARK, *earlier.geometry]]))), crs=3310)
pieces = pieces[pieces.representative_point().within(PARK)]
pt = pieces.representative_point()
pieces["times"] = [int(earlier.geometry.contains(p).sum()) for p in pt]
pieces["last"] = [earlier.loc[earlier.geometry.contains(p), "YEAR_"].max() for p in pt]
reburned = pieces[pieces.times > 0].area.sum() / PARK.area
print(f"{len(earlier)} earlier perimeters overlap the Park Fire; {reburned:.0%} of its area had burned at least once before 2024")
133 earlier perimeters overlap the Park Fire; 76% of its area had burned at least once before 2024
Map of the 2024 Park Fire perimeter divided into pieces coloured by how many earlier recorded fires had burned them: a quarter pale (no earlier fire on record), with large orange and dark brown areas in the centre and north burned once, twice or three or more times before. Map of the 2024 Park Fire perimeter divided into pieces coloured by how many earlier recorded fires had burned them: a quarter pale (no earlier fire on record), with large orange and dark brown areas in the centre and north burned once, twice or three or more times before.
The Park Fire as a reburn map: 76% of it had burned at least once before 2024, and 22% three times or more.

History around the fire, no added overlaps, times burned

1. Get the history around the fire

Query the historical archive with the fire's bounding box rather than downloading all 23,000 perimeters. ArcGIS services take an envelope as geometry with spatialRel=esriSpatialRelIntersects:

print(f"{len(hist)} perimeters in the bounding box, {hist.YEAR_.min()}–{hist.YEAR_.max()}; "
      f"invalid as downloaded: {(~RAW.geometry.is_valid).sum()}")
print(hist[hist.YEAR_ < 2024].groupby((hist.YEAR_ // 10) * 10).size().tail(8).to_string())
242 perimeters in the bounding box, 1911–2025; invalid as downloaded: 7
YEAR_
1950    13
1960    18
1970    12
1980    16
1990    22
2000    19
2010    27
2020    14

2. Do not add overlapping areas

Earlier fires overlap each other, so the sum of their areas inside the Park Fire overstates the ground they cover. Compare the naive sum with the union:

naive = earlier.area.sum() / PARK.area
union = unary_union(earlier.geometry.tolist()).area / PARK.area
print(f"sum of earlier fire areas inside Park: {naive:.0%} of the fire; their union: {union:.0%}")
sum of earlier fire areas inside Park: 152% of the fire; their union: 76%

The union counts each piece of ground once. The sum counts ground that burned three times three times.

3. Count times burned and years since

The union overlay above split the fire into pieces, each covered by the same set of earlier fires. Summarise them:

area = pieces.area / PARK.area
print(pieces.groupby(pieces.times.clip(upper=3)).apply(lambda g: area[g.index].sum(), include_groups=False)
      .rename(index={3: "3+"}).map("{:.0%}".format).to_string())
since = 2024 - pieces.loc[pieces.times > 0, "last"]
w = area[pieces.times > 0]
print(f"previously burned ground: last burned a median of {np.median(np.repeat(since.values, (w * 1000).round().astype(int))):.0f} years before 2024")
print(f"burned within the 30 years before 2024 (since 1994): {area[(pieces.times > 0) & (pieces['last'] >= 1994)].sum():.0%} of the Park Fire")
times
0     24%
1     29%
2     25%
3+    22%
previously burned ground: last burned a median of 34 years before 2024
burned within the 30 years before 2024 (since 1994): 35% of the Park Fire
Map of the Park Fire with 133 earlier fire perimeters inside it, coloured by year from purple in the 1910s through green to yellow in the 2010s, overlapping each other heavily in the centre. Map of the Park Fire with 133 earlier fire perimeters inside it, coloured by year from purple in the 1910s through green to yellow in the 2010s, overlapping each other heavily in the centre.
The earlier fires, coloured by year. Their overlaps are why their areas cannot simply be added.

The earlier fires it burned into

The data used on this page

CAL FIRE California Historical Fire Perimeters (public ArcGIS feature service California_Historic_Fire_Perimeters, layer "California Fire Perimeters (all)", 1878–2025), queried for the Park Fire's bounding box and projected to California Albers (EPSG:3310).

from pathlib import Path

import geopandas as gpd
import numpy as np
import pandas as pd
import requests
from shapely.ops import polygonize, unary_union

Path("data").mkdir(exist_ok=True)
LAYER = "https://services1.arcgis.com/jUJYIo9tSA7EHvfZ/arcgis/rest/services/California_Historic_Fire_Perimeters/FeatureServer/0/query"
F = Path("data/park_area_history.geojson")
if not F.exists():
    F.write_bytes(requests.get(LAYER, params={"geometry": "-122.1,39.7,-121.5,40.4", "geometryType": "esriGeometryEnvelope", "inSR": 4326,
                                              "spatialRel": "esriSpatialRelIntersects", "outFields": "YEAR_,FIRE_NAME,GIS_ACRES,CAUSE",
                                              "outSR": 4326, "f": "geojson"}, timeout=300).content)
RAW = gpd.read_file(F)
hist = RAW.to_crs(3310)
hist["geometry"] = hist.geometry.make_valid().buffer(0)
PARK = hist[(hist.YEAR_ == 2024) & (hist.FIRE_NAME == "PARK")].geometry.union_all()
print(f"{len(hist)} perimeters; Park Fire {PARK.area / 4046.8564224:,.0f} acres")
242 perimeters; Park Fire 429,603 acres

Example 1: the fires it burned into

largest = earlier.assign(acres_inside=earlier.area / 4046.8564224).nlargest(6, "acres_inside")
print(largest[["YEAR_", "FIRE_NAME", "acres_inside"]].to_string(index=False, formatters={"acres_inside": "{:,.0f}".format}))
 YEAR_ FIRE_NAME acres_inside
  1990  CAMPBELL      126,520
  1999    GUN II       60,391
  1994   BARKLEY       43,905
  1931       NaN       41,972
  1990    FINLEY       30,976
  1918       NaN       21,907

From overlapping perimeters to one surface

A reburn analysis turns a stack of overlapping perimeters into a single surface: for every piece of ground, how many earlier fires covered it and when the last one was. Polygonising the boundaries of all perimeters produces exactly those pieces. Each one lies wholly inside or wholly outside each fire, so a point test is enough to count the fires covering it. The alternative, adding up areas, double counts every overlap. In a landscape that burns repeatedly, that error is large.

Share of the Park Fire covered by earlier fires: summing their areas against taking their union. add the areas, or union them?sum of earlier fire areas152%: impossibleunion of earlier fires76% burned beforeburned since 199435%080%160%133 earlier CAL FIRE perimeters inside the 2024 Park Fire spatialworkflow.io
Adding overlapping areas double counts; the union counts each piece of ground once.

The history has limits that belong in any conclusion. Perimeters before the 1950s are sparse and only larger fires were mapped. Small fires are missing in every decade, and older perimeters are coarse. "Never burned before" therefore means "no fire in the record", which for the early twentieth century says little. Recent decades are well recorded, and the share of a new fire that burned in the last twenty or thirty years is the most reliable figure this analysis gives: 35% of the Park Fire had burned since 1994.

Invalid geometry, prescribed fire and polygonising cost

Reburn analysis problems and fixes. reburn problemsSymptomFixover 100% of the fire reburnedunion or polygonize, never addareasoverlay crashes on a few firesmake_valid().buffer(0)“never burned” before 1950early record is sparse: say soslow on 23,000 perimetersquery by the fire’s bounding boxCAL FIRE historical perimeters, 1878–2025 spatialworkflow.io
Reburn analysis problems and fixes.
  • Invalid geometry: repair with make_valid() (and buffer(0) for leftovers) before any overlay.
  • Same fire twice: the archive can hold a fire as several parts or revisions; check for duplicate names and years.
  • Prescribed fire is in a separate CAL FIRE layer; include it if the question is about fuels.
  • Times Burned layer: CAL FIRE also publishes a precomputed "times burned" surface; this page computes it from the perimeters so the method is visible.
  • Polygonize cost grows quickly with the number of perimeters; clip to the area of interest first.

FAQ

How do I find where a wildfire burned over earlier fires?

Overlay its perimeter with earlier perimeters: clip them to the fire, polygonise all boundaries into non-overlapping pieces, and count the earlier fires containing each piece.

Why can't I just add up the areas of earlier fires?

Earlier fires overlap each other; adding their areas counts the overlapping ground more than once.

How far back do California fire perimeters go?

CAL FIRE's archive has records from 1878, but coverage before the 1950s is sparse and limited to larger fires.

What is a reburn?

A fire that burns ground already burned by an earlier fire, often within a few decades, which can change how the vegetation recovers.