Reconstruction using real landscape, fuel, and weather data
On December 30, 2021, the Marshall Fire ignited in Boulder County, Colorado, driven by sustained winds of 50–70+ mph from the west-southwest. Fueled primarily by dry grasslands, the fire burned roughly 6,000 acres and destroyed over 1,000 structures in the communities of Louisville and Superior, making it the most destructive wildfire in Colorado history.
This tutorial reconstructs the fire using real data from federal sources:
# Load Julia startup.jl if LANDFIRE_EMAIL not set (Quarto may skip startup)if !haskey(ENV, "LANDFIRE_EMAIL") startup =joinpath(first(DEPOT_PATH), "config", "startup.jl")isfile(startup) &&include(startup)endusingElmfireusingLandfireusingArchGDALusingGeoFormatTypesusingDownloadsusingJSON3usingCairoMakie, GeoMakieusingRandomCairoMakie.activate!(visible =false) # non-interactive backend — prevents GUI windows during renderRandom.seed!(2021)
TaskLocalRNG()
Download Real Data
Landscape: LANDFIRE Fuel Models and Elevation
We use Landfire.jl to download 30 m FBFM13 fuel models and elevation for the fire area.
dest_crs ="+proj=longlat +datum=WGS84"fig =Figure(size = (900, 700))# Panel 1: Elevationga1 =GeoAxis(fig[1, 1]; source = proj4_str, dest = dest_crs, title ="Elevation (m)")contourf!(ga1, xs, ys, elevation; colormap =:terrain, levels =12)hidedecorations!(ga1)# Panel 2: Fuel modelsga2 =GeoAxis(fig[1, 2]; source = proj4_str, dest = dest_crs, title ="LANDFIRE Fuel Models (FBFM13)")heatmap!(ga2, xs, ys, Float64.(fuel_ids); colormap = [:lightgreen, :darkgreen, :sienna, :gray])hidedecorations!(ga2)# Panel 3: Slopega3 =GeoAxis(fig[2, 1]; source = proj4_str, dest = dest_crs, title ="Slope (degrees)")contourf!(ga3, xs, ys, slope; colormap =:YlOrRd, levels =8)hidedecorations!(ga3)# Panel 4: NIFC perimeter + ignitionga4 =GeoAxis(fig[2, 2]; source = proj4_str, dest = dest_crs, title ="NIFC Fire Perimeter")lines!(ga4, perim_x_crs, perim_y_crs; color =:red, linewidth =2)scatter!(ga4, [ign_x], [ign_y]; marker =:star5, markersize =15, color =:orange)hidedecorations!(ga4)fig
Weather: KBDU ASOS Observations
Wind observations from Boulder Municipal Airport (KBDU) during the fire event on December 30, 2021. Data from the Iowa Environmental Mesonet.
The fire burned under extreme conditions: sustained winds of 35–50 mph from the WSW (250–270 degrees) with gusts to 75 mph. We compute an effective wind speed (average of sustained and gust) from the peak burning period (18:00–21:00 UTC), which better captures the influence of gusty conditions on fire spread.
KBDU ASOS wind observations (Dec 30, 2021)
# Real KBDU ASOS observations during peak fire period (18:00-22:00 UTC)# Source: Iowa Environmental Mesonet ASOS download# Columns: UTC hour, wind direction (deg), sustained speed (kt), gust (kt)kbdu_obs = [17.25290214417.58300284817.92300164218.25290315018.58270285018.92260395619.25250446119.58250356219.92240356320.25250395720.58250415520.92250405821.25250436522.58250376022.92250355523.25250364823.58250284723.922402656]# Average over peak burning period (18:00-22:00 UTC)peak = kbdu_obs[4:13, :] # rows where hour is 18.25-20.92avg_dir =round(sum(peak[:, 2]) /size(peak, 1), digits=0)avg_kt =round(sum(peak[:, 3]) /size(peak, 1), digits=1)avg_mph =round(avg_kt *1.151, digits=0)avg_gust_kt =round(sum(peak[:, 4]) /size(peak, 1), digits=1)avg_gust_mph =round(avg_gust_kt *1.151, digits=0)# Effective wind: average of sustained and gust (common in fire behavior modeling)eff_mph =round((avg_mph + avg_gust_mph) /2, digits=0)println("Peak burning period (18:00-21:00 UTC = 11AM-2PM MST):")println(" Wind direction: $(avg_dir) degrees (WSW)")println(" Sustained: $(avg_kt) kt = $(avg_mph) mph")println(" Gusts: $(avg_gust_kt) kt = $(avg_gust_mph) mph")println(" Effective: $(eff_mph) mph (mean of sustained and gust)")
Peak burning period (18:00-21:00 UTC = 11AM-2PM MST):
Wind direction: 256.0 degrees (WSW)
Sustained: 37.5 kt = 43.0 mph
Gusts: 57.7 kt = 66.0 mph
Effective: 54.0 mph (mean of sustained and gust)
Run Simulation
We simulate 360 minutes (6 hours) using the real landscape and observed weather conditions. The effective wind speed (average of sustained and gust) captures the influence of gusts on fire spread better than sustained speed alone.
Code
fuel_table =create_standard_fuel_table(Float64)# Weather from KBDU observations: effective wind speed during peak periodweather =ConstantWeather( wind_speed_mph = eff_mph, wind_direction = avg_dir, M1 =0.03, # 3% — extremely dry (December, chinook winds) M10 =0.05, M100 =0.08, MLH =0.30, # Low live moisture (dormant season) MLW =0.60)state =FireState(ncols, nrows, cellsize)# Ignite near the NIFC-reported origin (small circle to account for# positional uncertainty in the ignition coordinates)ignite_circle!(state, ign_col, ign_row, 3.0, 0.0)# Capture snapshots for animationn_frames =40dt_frame =360.0/ n_frames # 9 min per frameframes_burned = [copy(state.burned)]frames_toa = [copy(state.time_of_arrival)]frame_times = [0.0]for i in1:n_frames t_start = (i -1) * dt_frame t_stop = i * dt_framesimulate!(state, fuel_ids, fuel_table, weather, slope, aspect, t_start, t_stop; dt_initial =0.5)push!(frames_burned, copy(state.burned))push!(frames_toa, copy(state.time_of_arrival))push!(frame_times, t_stop)endprintln("Simulation complete: $(length(frames_burned)) frames captured")println("Burned area: $(round(get_burned_area_acres(state), digits=0)) acres")println("NIFC reported: $(round(feat.properties.poly_GISAcres, digits=0)) acres")
Five visual layers per frame: terrain contours, burned area (time-of-arrival), simulated fire perimeter, the NIFC real fire perimeter (cyan dashed), and wind particles.
Code
dest_crs ="+proj=longlat +datum=WGS84"fig =Figure(size = (800, 500))ga =GeoAxis(fig[1, 1]; source = proj4_str, dest = dest_crs, title ="Marshall Fire — t = 0 min")hidedecorations!(ga)# Layer 1: terrain contours (static base)contour!(ga, xs, ys, elevation; color =:gray70, linewidth =0.4, levels =10)# Layer 2: time-of-arrival heatmap (dynamic)toa_obs =Observable(fill(NaN, ncols, nrows))hm =heatmap!(ga, xs, ys, toa_obs; colormap =:YlOrRd, colorrange = (0, 360), nan_color =:transparent)Colorbar(fig[1, 2], hm; label ="Time (min)")# Layer 3: simulated fire perimeter (dynamic)perim_obs =Observable(Point2f[])scatter!(ga, perim_obs; color =:orangered, markersize =3, strokewidth =0)# Layer 4: NIFC real fire perimeter (static)lines!(ga, perim_x_crs, perim_y_crs; color =:cyan, linestyle =:dash, linewidth =1.5)# Layer 5: wind particles (dynamic)n_particles =80x_min, x_max = xs[1], xs[end]y_lo, y_hi =min(ys[1], ys[end]), max(ys[1], ys[end])wx_crs = [x_min +rand() * (x_max - x_min) for _ in1:n_particles]wy_crs = [y_lo +rand() * (y_hi - y_lo) for _ in1:n_particles]wind_obs =Observable([Point2f(wx_crs[k], wy_crs[k]) for k ineachindex(wx_crs)])scatter!(ga, wind_obs; color = (:white, 0.6), markersize =3, strokewidth =0)# Wind vector components (from WSW = 250 deg → blows ENE)wind_rad = avg_dir *pi/180wind_dx =-sin(wind_rad)wind_dy =-cos(wind_rad)record(fig, "marshall_fire.gif", eachindex(frame_times); framerate =4) do i ga.title ="Marshall Fire — t = $(round(Int, frame_times[i])) min" burned = frames_burned[i] toa = frames_toa[i]# Update TOA display (NaN = transparent for unburned cells) toa_mat =fill(NaN, ncols, nrows)for ix in1:ncols, iy in1:nrowsif burned[ix, iy] && toa[ix, iy] >=0 toa_mat[ix, iy] = toa[ix, iy]endend toa_obs[] = toa_mat# Update fire perimeter (edge cells in CRS coordinates) pts = Point2f[]for ix in1:ncols, iy in1:nrowsif burned[ix, iy]for (ddx, ddy) in ((1,0), (-1,0), (0,1), (0,-1)) nx, ny = ix + ddx, iy + ddyif nx <1|| nx > ncols || ny <1|| ny > nrows || !burned[nx, ny]push!(pts, Point2f(xs[ix], ys[iy]))breakendendendend perim_obs[] = pts# Update wind particlesfor k ineachindex(wx_crs) wx_crs[k] +=rand(3:6) * cellsize_m * wind_dx wy_crs[k] +=rand(3:6) * cellsize_m * wind_dyif wx_crs[k] > x_max || wx_crs[k] < x_min wx_crs[k] = x_min +rand() * cellsize_m *5 wy_crs[k] = y_lo +rand() * (y_hi - y_lo)end wy_crs[k] =clamp(wy_crs[k], y_lo, y_hi)end wind_obs[] = [Point2f(wx_crs[k], wy_crs[k]) for k ineachindex(wx_crs)]end
Summary
Code
burned_acres =round(get_burned_area_acres(state), digits=0)real_acres =round(feat.properties.poly_GISAcres, digits=0)println("="^50)println("Marshall Fire Simulation Summary")println("="^50)println(" Grid: $ncols x $nrows cells ($(round(ncols * cellsize_m /1000, digits=1)) km x $(round(nrows * cellsize_m /1000, digits=1)) km)")println(" Cell size: $(round(cellsize_m, digits=0)) m ($(round(cellsize, digits=1)) ft)")println(" Duration: 360 min (6 hours)")println(" Wind: $(round(Int, eff_mph)) mph from $(round(Int, avg_dir)) deg (KBDU ASOS effective)")println(" Simulated area: $burned_acres acres")println(" NIFC reported: $real_acres acres")
==================================================
Marshall Fire Simulation Summary
==================================================
Grid: 383 x 253 cells (11.5 km x 7.6 km)
Cell size: 30.0 m (98.4 ft)
Duration: 360 min (6 hours)
Wind: 54 mph from 256 deg (KBDU ASOS effective)
Simulated area: 9391.0 acres
NIFC reported: 6080.0 acres
Final State
Two-panel comparison of the simulated burned area vs the NIFC-reported perimeter and the time-of-arrival map.
Code
dest_crs ="+proj=longlat +datum=WGS84"fig =Figure(size = (900, 450))toa_final =copy(state.time_of_arrival)toa_final[toa_final .<0] .=NaN# Panel 1: burned area with NIFC perimeter overlayga1 =GeoAxis(fig[1, 1]; source = proj4_str, dest = dest_crs, title ="Simulated vs Real Perimeter")burned_float =Float64.(state.burned)burned_float[burned_float .==0] .=NaNheatmap!(ga1, xs, ys, burned_float; colormap =:YlOrRd, nan_color =:transparent, colorrange = (0, 1))lines!(ga1, perim_x_crs, perim_y_crs; color =:cyan, linestyle =:dash, linewidth =2)scatter!(ga1, [ign_x], [ign_y]; marker =:star5, markersize =15, color =:white)hidedecorations!(ga1)# Panel 2: time-of-arrivalga2 =GeoAxis(fig[1, 2]; source = proj4_str, dest = dest_crs, title ="Time of Arrival (min)")hm =heatmap!(ga2, xs, ys, toa_final; colormap =:viridis, nan_color =:transparent)Colorbar(fig[1, 3], hm; label ="min")lines!(ga2, perim_x_crs, perim_y_crs; color =:cyan, linestyle =:dash, linewidth =2)hidedecorations!(ga2)fig
Quantitative Validation
Comparing the simulated burned area against the NIFC perimeter cell-by-cell. We rasterize the observed perimeter polygon using a ray-casting point-in-polygon test, then compute standard binary classification metrics.
The primary metric is the Sørensen coefficient (also called the Dice score), defined as:
\[
\text{SC} = \frac{2 |A \cap B|}{|A| + |B|}
\]
where \(A\) is the set of simulated burned cells and \(B\) is the set of observed burned cells. SC ranges from 0 (no overlap) to 1 (perfect agreement). It is the standard metric for fire spread model validation (Duff et al. 2018; Filippi et al. 2014) because it penalizes both commission errors (cells the model burned but the fire did not) and omission errors (cells the fire burned but the model missed), while being insensitive to the large number of true-negative (unburned) cells that dominate the domain.
We also report the Jaccard index (\(|A \cap B| / |A \cup B|\)), which is a monotonic transformation of SC and is sometimes preferred in spatial analysis.
Rasterize NIFC perimeter and compute overlap metrics
Green = true positive (both agree the cell burned), red = commission error (simulated but not observed), blue = omission error (observed but not simulated).
Code
# Encode agreement: 0=TN, 1=TP (green), 2=FP/commission (red), 3=FN/omission (blue)agreement =fill(NaN, ncols, nrows)for ix in1:ncols, iy in1:nrows s, o = sim_burned[ix, iy], observed_burned[ix, iy]if s && o agreement[ix, iy] =1.0# TPelseif s && !o agreement[ix, iy] =2.0# FP (commission)elseif !s && o agreement[ix, iy] =3.0# FN (omission)endenddest_crs ="+proj=longlat +datum=WGS84"fig =Figure(size = (700, 500))ga =GeoAxis(fig[1, 1]; source = proj4_str, dest = dest_crs, title ="Spatial Agreement (Sørensen = $(round(sorensen, digits=3)))")hidedecorations!(ga)heatmap!(ga, xs, ys, agreement; colormap = [:green, :red, :royalblue], colorrange = (1, 3), nan_color =:transparent)lines!(ga, perim_x_crs, perim_y_crs; color =:black, linestyle =:dash, linewidth =1)# LegendLegend(fig[1, 2], [MarkerElement(marker =:rect, color =:green, markersize =15),MarkerElement(marker =:rect, color =:red, markersize =15),MarkerElement(marker =:rect, color =:royalblue, markersize =15)], ["True Positive", "Commission", "Omission"])fig