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.
Two questions about the same places, stacked on one map:
Where does it burn? Modelled burn probability and forty years of burn perimeters. These barely change year to year.
What is happening now? Red Flag Warnings and active fire detections, both of which change by the hour.
Neither alone orders attention very well. A zone with high burn probability is not interesting on a wet day in November; a Red Flag Warning over bare rock is not interesting either. This notebook combines them into a per-zone ranking.
The NWS fire weather zone. That is the unit a Red Flag Warning is actually issued for, so nothing has to be downscaled or areally interpolated on the way in.
How it is built
Everything that can be a query is a query. The layers are DuckDB views over remote Parquet, GeoJSON, CSV, and zipped shapefiles (see views.sql), the joins and the geometry work happen in SQL through the spatial extension, and Python is left with the parts it is actually better at: turning raw quantities into weighted scores, drawing, and asserting.
The state is a parameter. One state keeps every layer small enough to work interactively – the whole pipeline below runs in seconds once the HTTP cache is warm.
What the score is and is not
A triage ranking, not a calibrated probability. The weights are a defensible starting point, not a fitted model, and nothing here has been validated against subsequent ignitions. Change WEIGHTS to match what you care about.
Configuration
Everything tunable is here. Nothing below reads a value that is not defined in this cell.
Code
# Wyoming: the most fire zones under a Red Flag Warning in the country at the time of# writing, with active detections and a real burn history to go with them. Any two-letter# state works -- see the caveats at the bottom about EPSG:5070 outside CONUS.STATE ="WY"# CONUS Albers, for anything that measures an area or a distanceEQUAL_AREA ="EPSG:5070"# fire just outside a zone boundary still mattersNEARBY_KM =25.0# MODIS confidence runs 0-100MIN_CONFIDENCE =30# MTBS also contains prescribed fire, which is not the same signalWILDFIRE_ONLY =True# how far past the state line to look for burn perimeters, in degreesBBOX_PAD_DEG =0.5WEIGHTS = {"burn_probability": 0.25, # modelled long-run hazard"warning": 0.20, # under a Red Flag Warning right now"detections": 0.20, # fire already burning inside the zone"intensity": 0.10, # summed FRP, a crude intensity proxy"nearby_detections": 0.10, # fire just outside the boundary"burned_share": 0.08, # share of the zone burned since 1984"fire_frequency": 0.07, # count of distinct historical fires}assertabs(sum(WEIGHTS.values()) -1) <1e-9, "weights should sum to 1"
Code
from datetime import UTC, datetimeRUN_AT = datetime.now(UTC)print("Run time (UTC):", RUN_AT.isoformat(timespec="seconds"))print("State :", STATE)
Run time (UTC): 2026-09-24T06:20:50+00:00
State : WY
/home/runner/work/ptps-wildfire-demo/ptps-wildfire-demo/.venv/lib/python3.14/site-packages/sql/parse.py:339: SyntaxWarning: "\:" is an invalid escape sequence. Such sequences will not work in the future. Did you mean "\\:"? A raw string is also an option.
Given a query, replaces all occurrences of ':variable' with '\:variable' and
/home/runner/work/ptps-wildfire-demo/ptps-wildfire-demo/.venv/lib/python3.14/site-packages/sql/parse.py:369: SyntaxWarning: "\:" is an invalid escape sequence. Such sequences will not work in the future. Did you mean "\\:"? A raw string is also an option.
Given a query, replaces all occurrences of 'example'[x:y] with 'example'[x\:y].
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 and configures the Source Cooperative credentials; views.sql defines one view per upstream dataset. Binding the views touches every endpoint, so the first run is slow and later ones are served from the cache_httpfs cache.
Code
from helpers import run_script_in_dbrun_script_in_db(conn, "setup.sql")run_script_in_db(conn, "views.sql")
The study area
One state, materialised once. Its bounding box is what keeps every subsequent read small: the burn-probability glob, the FIRMS CSV, and the remote MTBS Parquet are all filtered against it rather than being pulled down whole.
Code
%%sqlCREATE OR REPLACE TABLE study_area ASSELECT stusps AS state, name, geom, ST_XMin(geom) AS min_lon, ST_YMin(geom) AS min_lat, ST_XMax(geom) AS max_lon, ST_YMax(geom) AS max_latFROM state_boundariesWHERE stusps ='{{STATE}}';SELECT * EXCLUDE geom FROM study_area;
state
NAME
min_lon
min_lat
max_lon
max_lat
0
WY
Wyoming
-111.056888
40.996346
-104.052287
45.005904
The fire weather zones are the rows everything else will be attached to. They are stored twice: once in lon/lat for drawing, once in an equal-area projection for measuring.
Code
%%sqlCREATE OR REPLACE TABLE zones ASSELECT state ||'Z'|| zone AS zone_id, name AS zone_name, state, cwa AS forecast_office, geom, reproject(geom, 'EPSG:4269', '{{EQUAL_AREA}}') AS geom_ea, ST_Area(reproject(geom, 'EPSG:4269', '{{EQUAL_AREA}}')) AS zone_area_m2FROM fire_zonesWHERE state ='{{STATE}}';SELECT count(*) AS zones,round(sum(zone_area_m2) /1e6) AS total_km2,round(median(zone_area_m2) /1e6) AS median_km2FROM zones;
zones
total_km2
median_km2
0
43
252079.0
4953.0
Layer 1 - Burn probability
CarbonPlan’s Open Climate Risk burn probability, on a 1 km grid, downloaded by burn_prob.ipynb. bp_2011 is the modelled annual probability that a given pixel burns. The values are small – a 1% annual chance is a lot of fire over a thirty-year mortgage.
Code
%%sqlCREATE OR REPLACE TABLE burn_prob ASSELECT bp.longitude, bp.latitude, bp.bp_2011, bp.point, reproject(ST_Point(bp.longitude, bp.latitude), 'EPSG:4326', '{{EQUAL_AREA}}') AS point_eaFROM burn_prob_1km AS bp, study_area AS sWHERE bp.bp_2011 IS NOT NULL AND NOT isnan(bp.bp_2011)-- the raster is CONUS-wide; the bounding box is what makes this cheap AND bp.longitude BETWEEN s.min_lon AND s.max_lon AND bp.latitude BETWEEN s.min_lat AND s.max_lat;SELECT count(*) AS cells,round(avg(bp_2011), 5) AS mean_bp,round(max(bp_2011), 4) AS max_bpFROM burn_prob;
import foliumfrom branca.colormap import LinearColormapfrom folium.plugins import Fullscreenimport matplotlib.pyplot as pltimport numpy as npfrom helpers import add_map_caption, read_geostate_outline = read_geo( conn, "SELECT ST_AsText(geom) AS geom FROM study_area", crs="EPSG:4269")burn_prob_points = conn.execute("SELECT longitude, latitude, bp_2011 FROM burn_prob").df()# interactive: 270k+ points is too many for individual Folium markers, so they're# rasterised to a transparent image and draped over a pannable basemap at their own# extent, rather than reinterpreted as a density heatmapoverlay_bounds = ( burn_prob_points["longitude"].min(), burn_prob_points["latitude"].min(), burn_prob_points["longitude"].max(), burn_prob_points["latitude"].max(),)BURN_PROB_CMAP ="RdYlGn_r"overlay_fig, overlay_ax = plt.subplots(figsize=(8, 8), dpi=150)overlay_ax.scatter( burn_prob_points["longitude"], burn_prob_points["latitude"], c=burn_prob_points["bp_2011"], cmap=BURN_PROB_CMAP, s=8, linewidths=0,)overlay_ax.set_xlim(overlay_bounds[0], overlay_bounds[2])overlay_ax.set_ylim(overlay_bounds[1], overlay_bounds[3])overlay_ax.axis("off")overlay_ax.set_position((0, 0, 1, 1))overlay_fig.canvas.draw()overlay_image = np.asarray(overlay_fig.canvas.buffer_rgba())plt.close(overlay_fig)m = folium.Map()m.fit_bounds( [[overlay_bounds[1], overlay_bounds[0]], [overlay_bounds[3], overlay_bounds[2]]])folium.raster_layers.ImageOverlay( overlay_image, bounds=[ [overlay_bounds[1], overlay_bounds[0]], [overlay_bounds[3], overlay_bounds[2]], ], opacity=0.75,).add_to(m)folium.GeoJson( state_outline, style_function=lambda _: {"color": "black", "weight": 1, "fillOpacity": 0},).add_to(m)# scatter() stretches the colormap over the data's range by default, so the legend does tooburn_prob_colors = LinearColormap( [plt.get_cmap(BURN_PROB_CMAP)(i /10) for i inrange(11)], vmin=burn_prob_points["bp_2011"].min(), vmax=burn_prob_points["bp_2011"].max(), caption="Annual burn probability",)burn_prob_colors.add_to(m)add_map_caption(m, f"Burn probability, {STATE}", "CarbonPlan Open Climate Risk, 1 km")Fullscreen().add_to(m)m
Make this Notebook Trusted to load map: File -> Trust Notebook
Layer 2 - Burn history
MTBS perimeters, 1984-2024, read straight off Source Cooperative. The file covers the whole country and is far too big to download, so the query prunes to nine columns and filters on the perimeter centroid before any geometry is decoded.
DuckDB reads the GeoParquet metadata itself, so geom arrives as a typed GEOMETRY('OGC:CRS84') – no WKB decoding and no guessing at the projection.
Code
%%sqlCREATE OR REPLACE TABLE burn_history ASSELECT m.event_id, m.incident, m.incid_type, m.acres, m.ig_date, m.ig_year, m.geom, reproject(m.geom, 'EPSG:4326', '{{EQUAL_AREA}}') AS geom_eaFROM mtbs_perimeters AS m, study_area AS sWHERE m.centroid_lon BETWEEN s.min_lon - {{BBOX_PAD_DEG}} AND s.max_lon + {{BBOX_PAD_DEG}} AND m.centroid_lat BETWEEN s.min_lat - {{BBOX_PAD_DEG}} AND s.max_lat + {{BBOX_PAD_DEG}} AND ( NOT {{WILDFIRE_ONLY}} OR m.incid_type NOT ILIKE '%prescribed%' );SELECT count(*) AS perimeters,min(ig_year) AS first_year,max(ig_year) AS last_year,round(sum(acres) /1e6, 2) AS million_acresFROM burn_history;
perimeters
first_year
last_year
million_acres
0
688
1984
2024
7.45
Code
%%sqlSELECT ig_year, count(*) AS fires, round(sum(acres)) AS acresFROM burn_historyGROUP BY ig_yearORDER BY acres DESCLIMIT 10;
ig_year
fires
acres
0
1988
49
1738595.0
1
2012
52
689121.0
2
2020
21
627929.0
3
2024
27
579914.0
4
2000
48
539722.0
5
2016
32
336263.0
6
2006
37
333274.0
7
1996
35
313778.0
8
2018
23
278395.0
9
2002
30
219960.0
Layer 3 - Red Flag Warnings
The NWS alerts endpoint returns CAP alerts wrapped in GeoJSON, and most Red Flag Warnings carry geometry: null – they are issued for a zone, not for an ad-hoc polygon. Code that reads the feature’s own geometry silently drops most of the warnings. The red_flag_zones view in views.sql explodes affectedZones and joins each one to its shape from the fire zone shapefile.
The same zone can be covered by more than one alert, so they are collapsed back to one row per zone here.
Code
%%sqlCREATE OR REPLACE TABLE warnings ASSELECT zone_id, count(DISTINCT id) AS n_alerts, string_agg(DISTINCT event, ', ') AS events,max(severity) AS severity,max(expires) AS expires,max(headline) AS headline, string_agg(DISTINCT senderName, ', ') AS officesFROM red_flag_zonesWHERE zone_state ='{{STATE}}'GROUP BY zone_id;SELECT count(*) AS warned_zones, (SELECT count(*) FROM zones) AS zones_in_state,min(expires) AS first_expiry,max(expires) AS last_expiryFROM warnings;
warned_zones
zones_in_state
first_expiry
last_expiry
0
0
43
NaT
NaT
If that came back empty, it is a real answer rather than a failure: Red Flag Warnings are seasonal, and often absent overnight. Every step below still runs – the warning component just scores zero for every zone, and the ranking falls back to standing hazard. Try again during afternoon peak wind, or point STATE somewhere drier.
Code
import foliumfrom folium.plugins import Fullscreenfrom helpers import add_map_captionzone_outlines = read_geo( conn, "SELECT ST_AsText(geom) AS geom FROM zones", crs="EPSG:4269")warned = read_geo( conn,"SELECT z.zone_id, ST_AsText(z.geom) AS geom ""FROM zones AS z JOIN warnings AS w USING (zone_id)", crs="EPSG:4269",)print(f"{len(warned)} of {len(zone_outlines)} zones warned")bounds = zone_outlines.total_boundsm = folium.Map()m.fit_bounds([[bounds[1], bounds[0]], [bounds[3], bounds[2]]])folium.GeoJson( zone_outlines, style_function=lambda _: {"color": "#999999", "weight": 0.5, "fillOpacity": 0},).add_to(m)ifnot warned.empty: warning_areas = read_geo( conn, "SELECT ST_AsText(geom) AS geom FROM red_flag_warnings", crs="EPSG:4269" ) folium.GeoJson( warning_areas, style_function=lambda _: {"color": "darkred","weight": 1,"fillColor": "red","fillOpacity": 0.35, }, ).add_to(m)add_map_caption( m,f"Red Flag Warnings, {STATE}",f"{RUN_AT:%Y-%m-%d %H:%M} UTC", {"Red Flag Warning": "background: rgba(255, 0, 0, 0.35); border: 1px solid darkred","fire weather zone": "border: 1px solid #999999", },)Fullscreen().add_to(m)m
0 of 43 zones warned
Make this Notebook Trusted to load map: File -> Trust Notebook
Layer 4 - Active fire detections
Each MODIS active fire/thermal hotspot location represents the center of a 1km pixel that is flagged by the algorithm as containing one or more fires within the pixel.
A detection is a thermal anomaly, not a confirmed wildfire. Gas flares, industrial heat, and agricultural burning all show up. The bounding box is padded by a degree, because fire on the other side of the state line is still worth counting as nearby.
Code
%%sqlCREATE OR REPLACE TABLE detections ASSELECT f.latitude, f.longitude, f.acq_date, f.acq_time, f.confidence, f.frp, f.daynight, ST_Point(f.longitude, f.latitude) AS point, reproject(ST_Point(f.longitude, f.latitude), 'EPSG:4326', '{{EQUAL_AREA}}') AS point_eaFROM active_fires AS f, study_area AS sWHERE f.longitude BETWEEN s.min_lon -1 AND s.max_lon +1 AND f.latitude BETWEEN s.min_lat -1 AND s.max_lat +1 AND f.confidence >= {{MIN_CONFIDENCE}};SELECT count(*) AS detections,round(sum(frp)) AS total_frp_mw,min(acq_date) AS earliest,max(acq_date) AS latestFROM detections;
detections
total_frp_mw
earliest
latest
0
14
446.0
2026-09-23
2026-09-24
Combining the layers
One query attaches all four layers to each zone. It is written as a function of its input table names so the smoke test at the bottom can run the exact same SQL against fixtures.
Three details worth pointing at:
Areas and distances are computed in EQUAL_AREA, never in degrees.
ST_Union_Agg dissolves the burned pieces before measuring them, so a reburn of the same footprint counts as two fires but is only measured once.
Every count is zero-filled rather than left NULL, so a zone with no fire and no warning is still a row.
Code
def zone_metrics_sql( zones="zones", detections="detections", burn_history="burn_history", burn_prob="burn_prob", warnings="warnings", nearby_m=None,) ->str:"""SQL attaching every layer to each zone. Parameterised so fixtures can be swapped in.""" nearby_m = NEARBY_KM *1000if nearby_m isNoneelse nearby_mreturnf"""WITH inside AS ( SELECT z.zone_id, count(*) AS n_detections, sum(d.frp) AS frp_sum FROM {zones} AS z JOIN {detections} AS d ON ST_Contains(z.geom_ea, d.point_ea) GROUP BY z.zone_id),nearby AS ( SELECT z.zone_id, count(*) AS n_detections_nearby FROM {zones} AS z JOIN {detections} AS d ON ST_DWithin(z.geom_ea, d.point_ea, {nearby_m}) GROUP BY z.zone_id),burn_overlaps AS ( SELECT z.zone_id, h.event_id, h.ig_year, ST_Intersection(z.geom_ea, h.geom_ea) AS piece FROM {zones} AS z JOIN {burn_history} AS h ON ST_Intersects(z.geom_ea, h.geom_ea)),history AS ( SELECT zone_id, count(DISTINCT event_id) AS n_hist_fires, max(ig_year) AS last_burn_year, -- dissolve first, so overlapping reburns aren't double counted ST_Area(ST_Union_Agg(piece)) AS burned_m2 FROM burn_overlaps GROUP BY zone_id),hazard AS ( SELECT z.zone_id, avg(b.bp_2011) AS mean_burn_prob, max(b.bp_2011) AS max_burn_prob, count(*) AS n_bp_cells FROM {zones} AS z JOIN {burn_prob} AS b ON ST_Contains(z.geom_ea, b.point_ea) GROUP BY z.zone_id)SELECT z.zone_id, z.zone_name, z.state, z.forecast_office, z.zone_area_m2, w.zone_id IS NOT NULL AS under_warning, coalesce(w.n_alerts, 0) AS n_alerts, w.events, w.severity, w.expires, w.headline, w.offices, coalesce(i.n_detections, 0) AS n_detections, coalesce(i.frp_sum, 0) AS frp_sum, coalesce(n.n_detections_nearby, 0) AS n_detections_nearby, coalesce(h.n_hist_fires, 0) AS n_hist_fires, h.last_burn_year, least(coalesce(h.burned_m2, 0) / z.zone_area_m2, 1.0) AS burned_frac, coalesce(hz.mean_burn_prob, 0) AS mean_burn_prob, coalesce(hz.max_burn_prob, 0) AS max_burn_prob, coalesce(hz.n_bp_cells, 0) AS n_bp_cells, z.geomFROM {zones} AS zLEFT JOIN {warnings} AS w USING (zone_id)LEFT JOIN inside AS i USING (zone_id)LEFT JOIN nearby AS n USING (zone_id)LEFT JOIN history AS h USING (zone_id)LEFT JOIN hazard AS hz USING (zone_id)"""conn.execute(f"CREATE OR REPLACE TABLE zone_metrics AS {zone_metrics_sql()}")print(f"{conn.execute('SELECT count(*) FROM zone_metrics').fetchone()[0]} zones measured")
43 zones measured
Code
%%sqlSELECT zone_id, zone_name, under_warning, n_detections,round(frp_sum) AS frp, n_detections_nearby, n_hist_fires,round(burned_frac *100, 1) AS burned_pct,round(mean_burn_prob *1000, 2) AS burn_prob_per_1k, last_burn_yearFROM zone_metricsORDER BY under_warning DESC, n_detections DESC, mean_burn_prob DESCLIMIT 15;
zone_id
zone_name
under_warning
n_detections
frp
n_detections_nearby
n_hist_fires
burned_pct
burn_prob_per_1k
last_burn_year
0
WYZ285
South Bighorn Mountains
False
2
137.0
3
12
4.0
1.76
2021
1
WYZ274
Sheridan County/Casper BLM
False
1
11.0
1
27
13.1
7.68
2024
2
WYZ284
Bighorn National Forest
False
1
15.0
4
15
5.0
4.54
2021
3
WYZ140
Yellowstone National Park
False
0
0.0
0
53
54.8
6.25
2020
4
WYZ415
North Zone Bridger Teton NF and Grand Teton NP
False
0
0.0
0
35
18.9
5.04
2016
5
WYZ314
Northern Campbell
False
0
0.0
0
34
9.0
4.97
2024
6
WYZ419
North Laramie Range and Adjacent High Plains
False
0
0.0
0
19
14.7
4.84
2018
7
WYZ416
West Wind River Mountains/East Zone Bridger Teton NF
False
0
0.0
0
15
8.1
3.95
2019
8
WYZ424
Sierra Madre Range/Medicine Bow NF
False
0
0.0
0
6
7.1
3.63
2018
9
WYZ300
Casper Mountain
False
0
0.0
0
3
10.2
3.47
2012
10
WYZ286
Absaroka Mountains/North Shoshone NF
False
0
0.0
0
29
14.9
3.36
2019
11
WYZ414
Salt and Wyoming Ranges/West Zone Bridger Teton NF
False
0
0.0
0
16
12.0
3.23
2018
12
WYZ418
Middle North Platte River Basin/Niobrara and Converse Hi...
False
0
0.0
0
35
8.7
3.14
2024
13
WYZ288
East Wind River Mountains/South Shoshone NF
False
0
0.0
0
14
8.1
2.94
2022
14
WYZ429
Southern Laramie Range
False
0
0.0
0
0
0.0
2.75
<NA>
Scoring
Two steps kept deliberately apart: score_components turns raw quantities into comparable 0-1 numbers, and rank_zones applies the weights. So you can inspect the components, reweight without recomputing anything spatial, or drop a component by editing WEIGHTS.
Counts are scaled on a log axis because they are heavily tailed – one zone with forty detections should not flatten every other zone to nothing.
Code
import numpy as npdef scale01(values, log: bool=False) -> pd.Series:"""Min-max to [0,1]; log1p first for heavy-tailed counts. All-equal -> all zeros.""" s = pd.to_numeric(pd.Series(values), errors="coerce").fillna(0.0).astype(float)if log: s = np.log1p(s.clip(lower=0)) lo, hi = s.min(), s.max()ifnot np.isfinite(lo) ornot np.isfinite(hi) or hi <= lo:return pd.Series(0.0, index=s.index)return (s - lo) / (hi - lo)def score_components(zones: pd.DataFrame) -> pd.DataFrame:"""Raw quantities -> comparable 0-1 components."""def col(name):return ( zones[name] if name in zones.columns else pd.Series(0.0, index=zones.index) )return pd.DataFrame( {"burn_probability": scale01(col("mean_burn_prob")),# already 0/1, and scale01 would blow it up to the full range whenever any# zone is warned"warning": col("under_warning").astype(float),"detections": scale01(col("n_detections"), log=True),"intensity": scale01(col("frp_sum"), log=True),"nearby_detections": scale01(col("n_detections_nearby"), log=True),"burned_share": scale01(col("burned_frac")),"fire_frequency": scale01(col("n_hist_fires"), log=True), }, index=zones.index, )def rank_zones(zones: pd.DataFrame, weights: dict) -> pd.DataFrame:"""Weighted composite on a 0-100 scale, with each contribution kept as `c_*`."""if zones.empty:return zones out = zones.copy() components = score_components(out)for name, weight in weights.items(): out[f"c_{name}"] = components[name] * weight out["score"] = (components * pd.Series(weights)).sum(axis=1) *100 out["years_since_burn"] = RUN_AT.year - out["last_burn_year"] out = out.sort_values("score", ascending=False).reset_index(drop=True) out["rank"] = np.arange(1, len(out) +1)return out
Code
metrics = conn.execute("SELECT * EXCLUDE geom FROM zone_metrics").df()ranked = rank_zones(metrics, WEIGHTS)# hand the scores back to DuckDB so the geometry can be joined to them in SQLconn.register("ranked_df", ranked)conn.execute("""CREATE OR REPLACE TABLE zone_ranks ASSELECT r.*, z.geomFROM ranked_df AS rJOIN zones AS z USING (zone_id)""")print(f"Scored {len(ranked)} zones. Range {ranked['score'].min():.1f}-{ranked['score'].max():.1f}.")print("Weights:", ", ".join(f"{k}{v:.0%}"for k, v in WEIGHTS.items()))
Read the score as a queue order, not a probability.
One deliberate asymmetry worth naming: years_since_burn is reported but not scored. A landscape that burned two years ago is often less dangerous in the short term, because the fuel is gone. Frequency and extent of past fire indicate a fire-prone regime; recency cuts the other way. Folding both into one number would hide that tension, so recency stays a column you read alongside the rank.
Code
%%sqlSELECT rank, zone_id, zone_name,round(score, 1) AS score, under_warning, n_detections,round(frp_sum) AS frp, n_detections_nearby,round(mean_burn_prob *1000, 2) AS burn_prob_per_1k,round(burned_frac *100, 1) AS burned_pct, n_hist_fires, years_since_burnFROM zone_ranksORDER BY rankLIMIT 20;
rank
zone_id
zone_name
score
under_warning
n_detections
frp
n_detections_nearby
burn_prob_per_1k
burned_pct
n_hist_fires
years_since_burn
0
1
WYZ274
Sheridan County/Casper BLM
54.7
False
1
11.0
1
7.68
13.1
27
2
1
2
WYZ285
South Bighorn Mountains
48.6
False
2
137.0
3
1.76
4.0
12
5
2
3
WYZ284
Bighorn National Forest
48.1
False
1
15.0
4
4.54
5.0
15
5
3
4
WYZ140
Yellowstone National Park
35.2
False
0
0.0
0
6.25
54.8
53
6
4
5
WYZ415
North Zone Bridger Teton NF and Grand Teton NP
25.1
False
0
0.0
0
5.04
18.9
35
10
5
6
WYZ314
Northern Campbell
23.4
False
0
0.0
0
4.97
9.0
34
2
6
7
WYZ419
North Laramie Range and Adjacent High Plains
22.8
False
0
0.0
0
4.84
14.7
19
8
7
8
WYZ286
Absaroka Mountains/North Shoshone NF
18.5
False
0
0.0
0
3.36
14.9
29
7
8
9
WYZ416
West Wind River Mountains/East Zone Bridger Teton NF
18.4
False
0
0.0
0
3.95
8.1
15
7
9
10
WYZ281
Johnson County/Casper BLM
17.9
False
0
0.0
1
2.31
11.1
19
2
10
11
WYZ282
South Bighorn Basin/Worland BLM
17.7
False
0
0.0
3
0.84
9.4
29
3
11
12
WYZ418
Middle North Platte River Basin/Niobrara and Converse Hi...
17.1
False
0
0.0
0
3.14
8.7
35
2
12
13
WYZ414
Salt and Wyoming Ranges/West Zone Bridger Teton NF
16.6
False
0
0.0
0
3.23
12.0
16
8
13
14
WYZ424
Sierra Madre Range/Medicine Bow NF
15.7
False
0
0.0
0
3.63
7.1
6
8
14
15
WYZ288
East Wind River Mountains/South Shoshone NF
14.8
False
0
0.0
0
2.94
8.1
14
4
15
16
WYZ300
Casper Mountain
14.6
False
0
0.0
0
3.47
10.2
3
14
16
17
WYZ317
Weston County Plains
13.3
False
0
0.0
0
2.44
5.3
19
2
17
18
WYZ426
Snowy Range/Medicine Bow NF
12.4
False
0
0.0
0
1.93
25.6
5
6
18
19
WYZ417
Niobrara/Lower Elevations of Converse/Thunder Basin Nati...
12.2
False
0
0.0
0
2.18
3.4
21
4
19
20
WYZ275
North Bighorn Basin/Worland BLM
12.2
False
0
0.0
3
0.38
1.3
5
8
Code
def describe_hot_zones(ranked: pd.DataFrame, n: int=5) ->list[str]:"""One plain-language line per zone that is both warned and actively burning.""" hot = ranked[(ranked["n_detections"] >0) & ranked["under_warning"]].head(n)return [f"{r.zone_name} ({r.zone_id}): {int(r.n_detections)} detections, "f"{r.frp_sum:.0f} MW, {r.burned_frac:.0%} of the zone burned since 1984, "f"warning expires {r.expires:%b %d %H:%M} UTC"for r in hot.itertuples() ]hot = describe_hot_zones(ranked)print(f"{int((ranked['n_detections'] >0).sum())} zones have detections inside them.")print(f"{int(ranked['under_warning'].sum())} zones are under a Red Flag Warning.")print(f"{len(hot)} are both:\n")for line in hot:print(f" - {line}")
3 zones have detections inside them.
0 zones are under a Red Flag Warning.
0 are both:
What put each zone where it is
The stacked bars are the weighted contributions, so the segments add up to the score itself. A tall bar made entirely of blue is a standing-hazard zone; one with a lot of red is burning right now.
Code
import matplotlib.pyplot as pltCOMPONENT_COLORS = {"burn_probability": "#2166ac","warning": "#f4a582","detections": "#b2182b","intensity": "#d6604d","nearby_detections": "#fddbc7","burned_share": "#4393c3","fire_frequency": "#92c5de",}def plot_score_breakdown(ranked, weights, run_at, state, n=15, ax=None):if ranked.empty:returnNone top = ranked.head(min(n, len(ranked))).iloc[::-1] labels = [f"{r.zone_name[:34]} ({r.zone_id})"for r in top.itertuples()]if ax isNone: _, ax = plt.subplots(figsize=(11, 0.42*len(top) +2)) left = np.zeros(len(top))for name in weights: values = (top[f"c_{name}"] *100).values ax.barh( labels, values, left=left, label=name.replace("_", " "), color=COMPONENT_COLORS[name], edgecolor="white", linewidth=0.5, ) left += values ax.set_xlabel("composite score (0-100)") ax.set_title(f"Highest-priority fire weather zones, {state}\n"f"{run_at.strftime('%Y-%m-%d %H:%M')} UTC", loc="left", fontsize=12, ) ax.legend(loc="lower right", fontsize=8, frameon=False) ax.spines[["top", "right"]].set_visible(False)return axplot_score_breakdown(ranked, WEIGHTS, RUN_AT, STATE)plt.tight_layout()plt.show()
On the map
Zones shaded by score, with the active detections on top.
Code
import foliumfrom branca.colormap import linearfrom folium.plugins import Fullscreenfrom helpers import add_map_captionranked_zones = read_geo( conn,"SELECT zone_id, zone_name, score, under_warning, n_detections, n_hist_fires, ""ST_AsText(geom) AS geom FROM zone_ranks", crs="EPSG:4269",)detection_points = conn.execute("SELECT latitude, longitude, frp, acq_date, confidence FROM detections").df()# interactive: zones coloured by score, with the active detections on top. The same# colormap fills the zones and draws the legend, so the two can't disagree.score_colors = linear.YlOrRd_09.scale( ranked_zones["score"].min(), ranked_zones["score"].max())score_colors.caption ="Risk score (higher = look first) · ● active fire detection"bounds = ranked_zones.total_boundsm = folium.Map()m.fit_bounds([[bounds[1], bounds[0]], [bounds[3], bounds[2]]])folium.GeoJson( ranked_zones, style_function=lambda f: {"fillColor": score_colors(f["properties"]["score"]),"color": "#787878","weight": 1,"fillOpacity": 0.7, }, tooltip=folium.GeoJsonTooltip( fields=["zone_name", "score", "n_detections", "n_hist_fires"], aliases=["zone", "score", "detections", "historical fires"], ),).add_to(m)for row in detection_points.itertuples(): folium.CircleMarker( location=[row.latitude, row.longitude], radius=3, color="#141414", fill=True, fill_opacity=1, tooltip=f"{row.frp:.0f} MW, {row.acq_date}, confidence {row.confidence}", ).add_to(m)score_colors.add_to(m)add_map_caption( m,f"Wildfire risk by fire weather zone, {STATE}",f"{RUN_AT:%Y-%m-%d %H:%M} UTC",)Fullscreen().add_to(m)m
Make this Notebook Trusted to load map: File -> Trust Notebook
Smoke test
The metrics query is the part most likely to break silently – an empty overlay from a projection mismatch, or a dissolve that stops dissolving, both look like a plausible ranking rather than an error. This builds fixture tables with known answers and runs the same SQL over them.
It needs no network and takes about a second. Run it after editing anything above.
Code
def make_fixtures(conn) ->None:"""Four zones in a row, with a known number of detections and a known burned area.""" conn.execute(f""" CREATE OR REPLACE TABLE t_zones AS SELECT zone_id, zone_name, 'CA' AS state, 'TST' AS forecast_office, geom, reproject(geom, 'EPSG:4326', '{EQUAL_AREA}') AS geom_ea, ST_Area(reproject(geom, 'EPSG:4326', '{EQUAL_AREA}')) AS zone_area_m2 FROM ( SELECT 'TSTZ' || k AS zone_id, 'Test zone ' || k AS zone_name, ST_GeomFromText( 'POLYGON((' || (-122 + k) || ' 36, ' || (-122 + k + 0.6) || ' 36, ' || (-122 + k + 0.6) || ' 36.6, ' || (-122 + k) || ' 36.6, ' || (-122 + k) || ' 36))' ) AS geom FROM range(4) AS t(k) ); -- counts[k] detections inside zone k: 0, 1, 3, 5 CREATE OR REPLACE TABLE t_detections AS SELECT 10.0 * (i + 1) AS frp, reproject(ST_Point(-122 + c.k + 0.1 + 0.05 * i, 36.3), 'EPSG:4326', '{EQUAL_AREA}') AS point_ea FROM (VALUES (0, 0), (1, 1), (2, 3), (3, 5)) AS c(k, n), range(5) AS t(i) WHERE i < c.n; -- one fire covering a quarter of zone 3, plus a reburn of the same footprint CREATE OR REPLACE TABLE t_history AS SELECT event_id, ig_year, reproject( ST_GeomFromText('POLYGON((-119 36, -118.7 36, -118.7 36.3, -119 36.3, -119 36))'), 'EPSG:4326', '{EQUAL_AREA}' ) AS geom_ea FROM (VALUES ('A', 1995), ('B', 2015)) AS f(event_id, ig_year); -- a constant field, except the eastern end (zone 3) which is hotter CREATE OR REPLACE TABLE t_burn_prob AS SELECT CASE WHEN lon > -119.4 THEN 0.02 ELSE 0.001 END AS bp_2011, reproject(ST_Point(lon, 36.3), 'EPSG:4326', '{EQUAL_AREA}') AS point_ea FROM (SELECT -121.9 + 0.1 * i AS lon FROM range(40) AS t(i)); CREATE OR REPLACE TABLE t_warnings AS SELECT 'TSTZ3' AS zone_id, 1 AS n_alerts, 'Red Flag Warning' AS events, 'Severe' AS severity, TIMESTAMPTZ '2030-01-01 00:00:00Z' AS expires, 'test' AS headline, 'NWS Test' AS offices; """)def run_smoke_test(conn, weights) ->None: make_fixtures(conn) sql = zone_metrics_sql( zones="t_zones", detections="t_detections", burn_history="t_history", burn_prob="t_burn_prob", warnings="t_warnings", ) m = conn.execute(f"{sql} ORDER BY zone_id").df()# scale01assert scale01([0, 0, 0]).tolist() == [0.0, 0.0, 0.0], ("constant input must give zeros" )assert scale01([0, 5, 10]).tolist() == [0.0, 0.5, 1.0]# the detection joinassert m["n_detections"].tolist() == [0, 1, 3, 5], m["n_detections"].tolist()assert m.loc[m.zone_id =="TSTZ0", "frp_sum"].iloc[0] ==0assert m.loc[m.zone_id =="TSTZ2", "frp_sum"].iloc[0] ==60.0# 10 + 20 + 30# the burn overlay: two overlapping fires, one footprint hit = m[m.zone_id =="TSTZ3"].iloc[0]assert hit.n_hist_fires ==2, "distinct fires should be counted separately"assert0.2< hit.burned_frac <0.3, f"dissolve failed, got {hit.burned_frac:.3f}"assert hit.last_burn_year ==2015assert m[m.zone_id =="TSTZ0"].iloc[0].burned_frac ==0# warnings and burn probability land on the right zonesassert m["under_warning"].tolist() == [False, False, False, True]assert ( m.loc[m.zone_id =="TSTZ3", "mean_burn_prob"].iloc[0]> (m.loc[m.zone_id =="TSTZ0", "mean_burn_prob"].iloc[0]) )# empty inputs must not raise conn.execute("CREATE OR REPLACE TABLE t_none AS SELECT * FROM t_detections LIMIT 0") empty = conn.execute( zone_metrics_sql( zones="t_zones", detections="t_none", burn_history="t_history", burn_prob="t_burn_prob", warnings="t_warnings", ) ).df()assert (empty["n_detections"] ==0).all()assert rank_zones(pd.DataFrame(columns=["zone_id"]), weights).empty# ranking: more fire, more history and more hazard should sort higher r = rank_zones(m, weights)assert r.iloc[0].zone_id =="TSTZ3", r[["zone_id", "score"]].to_dict("records")assert r.iloc[-1].zone_id =="TSTZ0"assertabs(r["score"].max() -100) <1e-6, "top zone maxes every component"print(f"smoke test passed - {len(r)} fixture zones scored "f"{r['score'].min():.1f}-{r['score'].max():.1f}" )run_smoke_test(conn, WEIGHTS)
smoke test passed - 4 fixture zones scored 0.0-100.0
What this does not tell you
A detection is not a fire. MODIS picks up gas flares, industrial heat, and agricultural burning. High-ranking zones deserve a look at the FIRMS attributes and, in California, the AlertCalifornia camera feeds in the catalog.
The score is unvalidated. The weights are a starting point, not a fitted model. Nothing here has been checked against subsequent ignitions.
Burn history cuts both ways. Frequent past fire signals a fire-prone regime; a recent burn usually means depleted fuel. Hence years_since_burn being reported rather than scored.
MTBS has a size floor and a reporting lag – roughly 1,000 acres in the west, 500 in the east, and it runs a year or more behind. Small and very recent fires are simply absent, so burned_frac is a floor.
Burn probability is modelled, on a 1 km grid, and averaging it over a zone the size of a county throws away most of what it knows.
Zones are coarse. A fire weather zone can span a hundred kilometres of very different terrain. Zone-level ranking says “look here”, not “this ridge”.
Geography.EPSG:5070 is CONUS-appropriate; areas in Alaska, Hawaii, and Puerto Rico will be distorted, and the no-auth FIRMS file covers only CONUS + Hawaii.
No exposure layer. This ranks hazard, not consequence. Two zones with identical scores can differ enormously in who lives there.
Nothing is saved. Two of the four layers change by the hour, so re-running this notebook will not reproduce an earlier ranking. Capture anything you need to keep yourself.
Fuel condition – MODIS or Sentinel-2 NDVI as a live dryness proxy; LANDFIRE seasonal fuels for fuel model context. This is the biggest missing driver: the warning tells you the weather, not what is there to burn.