Wildfire overlap: places that have burned more than once

This notebook was written by generative AI. It’s meant to show querying across sources/formats, not provide informative analysis. Take the results with a major grain of salt.

Every mapped fire perimeter since 1984 for one state, drawn as a translucent polygon on the same map. A place that has never burned stays white. A place that has burned once is a single faint wash of color. A place that has burned twice, or five times, gets darker each time a perimeter stacks on top of it – the overlap is the signal, and it comes for free from drawing every polygon at the same low opacity rather than computing anything extra.

Layer Source What it tells you
Fire perimeters WUMI, merged from MTBS/CalFire/USGS/WFIGS/IAFPH Where a fire actually burned, 1984 to the present, one polygon per fire

Why WUMI instead of the MTBS layer already in views.sql

The risk notebook uses MTBS alone, which has a size floor (~1,000 acres in the west) and a reporting lag of a year or more. WUMI merges MTBS with CalFire, USGS, WFIGS, and IAFPH, so it catches smaller and more recent fires MTBS hasn’t gotten to yet – more perimeters, which is exactly what matters for a map about how often the same ground reburns.

The tradeoff: getting at it needs a Dryad API account, since Dryad’s website (unlike every other source in views.sql) is behind bot detection that only its REST API sidesteps. wumi_perimeters.ipynb handles that and caches the result; run it first, for the same STATE configured below.

Unit of analysis

A fire perimeter, not a zone. There is no downscaling or areal interpolation here – either two mapped shapes overlap in space, or they don’t.

What this is and is not

Only fires with a real, agency-mapped perimeter are included. WUMI also ships a circular fallback – a fire’s perimeter assuming it was a disc centered on the ignition point – for fires nobody ever mapped; wumi_perimeters.ipynb drops those rather than draw a guess as if it were an observation. So this map understates total fire coverage, and understates it more for older and smaller fires, which were less likely to get mapped at all.

Configuration

Code
STATE = "WY"  # must match what wumi_perimeters.ipynb was run for

EQUAL_AREA = "EPSG:5070"  # CONUS Albers -- also WUMI's native projection, conveniently
Code
from datetime import UTC, datetime

RUN_AT = datetime.now(UTC)
print("Run time (UTC):", RUN_AT.isoformat(timespec="seconds"))
print("State         :", STATE)
Run time (UTC): 2026-09-24T06:21:20+00:00
State         : WY

Configure the import path:

Code
import sys
from pathlib import Path

sys.path.insert(0, str(Path.cwd().parents[1]))

Setup

Pandas

Code
import pandas as pd

pd.set_option("display.max_columns", 100)
pd.set_option("display.max_colwidth", 60)
pd.set_option("display.width", 160)

DuckDB

Code
import duckdb

%load_ext sql
conn = duckdb.connect()
%sql conn --alias duckdb
Loading configurations from /home/runner/work/ptps-wildfire-demo/ptps-wildfire-demo/pyproject.toml.
Settings changed:
Config value
autopandas True
displaycon False

setup.sql installs the extensions; views.sql defines the reproject macro this notebook uses below. Neither one touches the WUMI data – that lives in the parquet cache wumi_perimeters.ipynb wrote, not in a remote view, because Dryad’s downloads can’t be read directly (see that notebook for why).

Code
from helpers import run_script_in_db

run_script_in_db(conn, "setup.sql")
run_script_in_db(conn, "views.sql")

The study area

The state outline, for orienting the map – not for filtering, since the cached perimeters are already scoped to one state.

Code
%%sql
CREATE OR REPLACE TABLE study_area AS
SELECT stusps AS state,
    name,
    geom
FROM state_boundaries
WHERE stusps = '{{STATE}}';

SELECT * EXCLUDE geom FROM study_area;
state NAME
0 WY Wyoming

Fire perimeters

Read straight from the cache. poly_area_h/burn_area_h are hectares within the perimeter and hectares actually classified as burned inside it, respectively – distinct because a perimeter is not uniformly burned inside.

Code
%%sql
CREATE OR REPLACE TABLE fires AS
SELECT fireid,
    source,
    name,
    date::DATE AS ignition_date,
    year(date::DATE) AS ignition_year,
    poly_area_h AS poly_area_ha,
    burn_area_h AS burn_area_ha,
    cause_human,
    geometry AS geom
FROM read_parquet('data/wumi_perimeters/{{STATE}}.parquet');

SELECT count(*) AS fires,
    min(ignition_year) AS first_year,
    max(ignition_year) AS last_year,
    round(sum(burn_area_ha) / 1e6, 2) AS million_ha_burned
FROM fires;
fires first_year last_year million_ha_burned
0 1328 1984 2026 2.47

If fires is empty or missing, wumi_perimeters.ipynb has not been run for this STATE yet – do that first.

Overlap

For each fire, how many other fires’ perimeters it intersects. OVERLAPS is a reserved word in SQL (a temporal operator), which is why this table is not called that.

Code
%%sql
CREATE OR REPLACE TABLE overlap_counts AS
SELECT a.fireid AS fireid,
    count(*) AS n_overlapping_fires
FROM fires AS a
JOIN fires AS b ON a.fireid != b.fireid AND ST_Intersects(a.geom, b.geom)
GROUP BY a.fireid;

SELECT count(*) AS fires_with_overlap,
    (SELECT count(*) FROM fires) AS total_fires
FROM overlap_counts;
fires_with_overlap total_fires
0 855 1328

The most reburned ground, by how many other perimeters overlap each fire:

Code
%%sql
SELECT f.fireid,
    f.name,
    f.ignition_year,
    round(f.burn_area_ha) AS burn_area_ha,
    o.n_overlapping_fires
FROM fires AS f
JOIN overlap_counts AS o USING (fireid)
ORDER BY o.n_overlapping_fires DESC
LIMIT 10;
fireid name ignition_year burn_area_ha n_overlapping_fires
0 20240822_449420_1060710 REMINGTON 2024 73170.0 22
1 19880710_439951_1102962 MINK 1988 NaN 19
2 19880722_444307_1110990 NORTH FORK 1988 NaN 18
3 20110822_451700_1061830 DIAMOND COMPLEX 2011 12182.0 16
4 20110821_451715_1061812 LITTLE FORK 2011 NaN 16
5 19880709_447370_1099540 CLOVERMIST 1988 79132.0 15
6 20170831_449560_1064710 DEER CREEK 2017 33518.0 13
7 19880722_447080_1108210 NORTH FORK 1988 159392.0 12
8 20060713_448550_1063220 BUFFALO CREEK 2006 14377.0 11
9 20120627_422010_1054900 ARAPAHO 2012 34759.0 10

On the map

Every perimeter, at the same low fill opacity. No per-fire styling and no pre-computed overlap surface – Leaflet stacking semi-transparent fills is the overlap computation, visually.

Some of these perimeters (the 1988 Yellowstone complexes especially) carry thousands of vertices, which is more precision than a state-wide map can show or a browser wants to render. ST_SimplifyPreserveTopology trims that before drawing; the notebook’s numbers above are all computed from the unsimplified geometry.

Code
import folium
from folium.plugins import Fullscreen
from helpers import add_map_caption, read_geo

SIMPLIFY_TOLERANCE_M = 100  # in EQUAL_AREA's units; visually lossless at state scale

fire_shapes = read_geo(
    conn,
    "SELECT fireid, name, ignition_year, round(burn_area_ha) AS burn_area_ha, "
    f"ST_AsText(reproject(ST_SimplifyPreserveTopology(geom, {SIMPLIFY_TOLERANCE_M}), "
    f"'{EQUAL_AREA}', 'EPSG:4269')) AS geom FROM fires",
    crs="EPSG:4269",
)
state_outline = read_geo(
    conn, "SELECT ST_AsText(geom) AS geom FROM study_area", crs="EPSG:4269"
)

FIRE_FILL = "#b2182b"
FIRE_FILL_OPACITY = 0.18

bounds = state_outline.total_bounds
m = folium.Map()
m.fit_bounds([[bounds[1], bounds[0]], [bounds[3], bounds[2]]])
folium.GeoJson(
    state_outline,
    style_function=lambda _: {"color": "#999999", "weight": 1, "fillOpacity": 0},
).add_to(m)
folium.GeoJson(
    fire_shapes,
    style_function=lambda _: {
        "fillColor": FIRE_FILL,
        "color": "#67000d",
        "weight": 0.4,
        "fillOpacity": FIRE_FILL_OPACITY,
    },
    tooltip=folium.GeoJsonTooltip(
        fields=["name", "ignition_year", "burn_area_ha"],
        aliases=["fire", "year", "burned (ha)"],
    ),
).add_to(m)
# what a spot burned n times looks like, with n fills stacked on top of each other
add_map_caption(
    m,
    f"Wildfire perimeters, {STATE}",
    f"{fire_shapes['ignition_year'].min()}-{fire_shapes['ignition_year'].max()}",
    {
        f"burned {n}x": f"background: {FIRE_FILL}; opacity: {1 - (1 - FIRE_FILL_OPACITY) ** n:.2f}"
        for n in (1, 2, 4)
    },
)
Fullscreen().add_to(m)
m
Make this Notebook Trusted to load map: File -> Trust Notebook

What this does not tell you

  • Only agency-mapped perimeters are shown. wumi_perimeters.ipynb drops WUMI’s circular fallback – a guessed disc for fires nobody ever mapped – so this understates total fire coverage, more so for older and smaller fires.
  • No time dimension. A 1988 perimeter and a 2024 perimeter are drawn identically, so a spot that reburned decades apart looks the same as one that reburned in successive seasons, even though the second is the more urgent finding (return interval shorter than typical fuel recovery time).
  • Perimeter, not severity. Two overlapping perimeters can have burned at very different intensities underneath; this map only shows that they overlap in outline.
  • Mixed methodology. MTBS perimeters come from satellite-derived burn severity; CalFire, USGS, WFIGS, and IAFPH perimeters are digitized incident-report boundaries. They are not drawn to the same standard, which matters most right at the edges – exactly where overlap is being measured.
  • EPSG:5070 is CONUS-specific, same caveat as risk.ipynb.

Where to take it next

  • Reburn interval, not just countn_overlapping_fires treats a reburn 40 years later the same as one 4 years later. Joining on ignition_year gap would separate “resilient landscape” from “still recovering.”
  • Severity within overlap – MTBS’s severity classification (not just the outer perimeter) could show whether reburned ground burns hotter or cooler the second time.
  • The same layers as risk.ipynb – burn probability, Red Flag Warnings, and active detections all slot in as more DuckDB views in views.sql over the same fires table.