LANDFIRE/3DEP preprocessing pipeline producing ELMFIRE-ready raster outputs.
Given a protected-land polygon and a simulation config, the pipeline produces an aligned, validated raster directory that ELMFIRE can consume directly.
The pipeline (Stages 1-7) is fully implemented and tested against synthetic data:
- 91 unit + integration tests pass
- mypy strict + ruff strict are clean across
src/,tests/,main.py, andweb/server.py - The orchestrator (
run_pipeline) wires every stage in order and produces the full output manifest - All rasters share an identical canonical grid, enforced by
reproject_matchas the single chokepoint - Firebreak optimization (
wildfire-preproc optimize-firebreaks) ranks candidate layouts and, with--rerun-elmfire, re-runs the 8-bearing ensemble against the recommended layout's modified fbfm40 to populateoptimized_resultinfirebreak_optimization.json - ELMFIRE runners validate the binary path at construction (clear
ElmfireExecutableMissingErrorinstead of a confusing subprocess failure) and--runner auto|wsl|nativeis consistent acrossmain.pyand the CLI - Mask validation now fails fast if
protected_maskorcandidate_zoneare entirely zero (a sign that the source polygon never intersected the canonical grid) - LFPS cache keys include
landfire_versionso LF2022 and LF2023 fetches cannot collide
Live-API status:
- 3DEP DEM fetcher: endpoint verified live; should work end-to-end
- LFPS fetcher: job endpoint surface verified as
/api/job/submitand/api/job/status; live runs still require a realLANDFIRE_EMAIL(set in.env—web/server.pyandmain.pyshare the same loader)
# Install
uv sync --all-groups
# Run the offline test suite (90 tests, all passing; live test deselected by default)
uv run pytest -m "not live"
# Type-check and lint (everything we cover by default)
uv run mypy src
uv run ruff check src tests main.py web/server.pyThe project includes a no-build browser UI in web/ for project setup, polygon drawing,
scenario settings, baseline/firebreak visualization, comparison, and JSON handoff.
Run the browser UI with the local backend bridge:
uv run python web/server.pyOpen:
http://127.0.0.1:4173/
The local bridge serves the UI and lets the Save backend job button write
validated backend job files under jobs/ui/<timestamp>/.
For a static UI only:
python3 -m http.server 4173 --bind 127.0.0.1 --directory webOpen:
http://127.0.0.1:4173/
The UI can export the backend job payload and import firebreak_optimization.json
from the optimization step.
Generate firebreak layouts for an existing preprocessed job:
uv run wildfire-preproc optimize-firebreaks jobs/YOUR_RUN/preprocessed \
--baseline-dir jobs/YOUR_RUN/preprocessed/elmfire_no_firebreakThe bundled sample AOI and the wildfire-preproc sample command are wired up for live LFPS+3DEP runs. Set LANDFIRE_EMAIL before running against LFPS.
main.py connects the full workflow:
- Accept a protected location (farm/field boundary).
- Run LANDFIRE/3DEP preprocessing for that location.
- Prepare ELMFIRE input decks for 8 ignition points around the protected polygon.
- Run 8 no-firebreak ELMFIRE simulations with wind directed toward the polygon center.
- Write
simulation_summary.jsonplus per-run fire outputs.
The function entry point is:
from main import run_location_fire_simulations
result = run_location_fire_simulations(
protected_polygon={...}, # GeoJSON Polygon or MultiPolygon
out_dir="jobs/my-farm-run",
)The command-line entry point is:
.\.venv\Scripts\python.exe main.py --location-geojson .\data\my_farm.geojson --out .\jobs\my-farm-runThe repo includes a built-in example protected location: an 800 m x 800 m farm-sized rectangle near Ramona / San Pasqual Valley in San Diego County, centered at approximately:
longitude: -116.945
latitude: 33.035
Run it with:
.\.venv\Scripts\python.exe main.py --example-san-diego --out .\jobs\san-diego-farm-exampleOutputs:
jobs/san-diego-farm-example/
job.json
simulation_summary.json
preprocessed/
inputs/
elmfire_no_firebreak/
bearing_000/
bearing_045/
bearing_090/
bearing_135/
bearing_180/
bearing_225/
bearing_270/
bearing_315/
Each bearing_* folder contains the ELMFIRE inputs/elmfire.data, per-run rasters, run_manifest.json, and ELMFIRE output rasters such as time of arrival, fireline intensity, and spread rate.
You can also create a rectangular location directly:
.\.venv\Scripts\python.exe main.py --center-lon -116.945 --center-lat 33.035 --width-m 800 --height-m 800 --out .\jobs\custom-locationOr use your own farm boundary GeoJSON:
.\.venv\Scripts\python.exe main.py --location-geojson .\data\my_farm.geojson --out .\jobs\my-farm-runPrerequisites:
- ELMFIRE must be built at
elmfire/build/linux/bin/elmfire_2025.0212. The setup in this workspace uses WSL Ubuntu and the officialelmfire/build/linux/make_gnu.shbuild. Theelmfire/directory is intentionally a peer-install location, not a git submodule — seeelmfire/README.mdfor the build procedure. The runners (wildfire_preproc.elmfire.SubprocessElmfireRunner/WslElmfireRunner/resolve_elmfire_runner) all validate the executable exists at construction and raiseElmfireExecutableMissingErrorwith a clear message rather than failing partway through a subprocess. --runner auto|wsl|nativeis consistent acrosspython main.pyandwildfire-preproc elmfire.autopicks WSL on Windows and the native subprocess on macOS/Linux.- Live preprocessing still depends on the LFPS fetch endpoint. See "Live integration notes" below if LFPS layer fetching fails before the ELMFIRE stage.
Before live LFPS preprocessing, set your LANDFIRE email in .env:
LANDFIRE_EMAIL=your.email@example.com
LANDFIRE_VERSION=LF2023
main.py loads .env automatically. Replace the placeholder email with your real email before running live LFPS requests. LANDFIRE_VERSION controls the requested layer prefix, so the default end-to-end app requests layers such as LF2023_FBFM40, LF2023_CC, and LF2023_CBD.
You can also override the version per run:
.\.venv\Scripts\python.exe main.py --example-san-diego --landfire-version LF2023 --out .\jobs\san-diego-farm-examplePer-job, inputs/ contains:
| File | Description |
|---|---|
fbfm40.tif |
LANDFIRE Scott & Burgan 40 fuel models, uint8, nodata=255 |
dem.tif |
DEM resampled to target cell size, float32, nodata=-9999 |
slp.tif |
Slope (degrees, 0-90), Horn 3x3 derived, float32, nodata=-9999 |
asp.tif |
Aspect (compass degrees from N, flat=-1), float32, nodata=-9999 |
cc.tif, ch.tif, cbh.tif, cbd.tif |
LANDFIRE canopy cover, height, base height, bulk density (float32) |
protected_mask.tif |
1 inside protected polygon, 0 outside |
candidate_zone.tif |
1 where firebreaks may be placed (annulus between safety-buffered protected polygon and ignition ring) |
non_burnable_mask.tif |
1 where fire cannot spread (FBFM40 codes 91/92/93/98/99 by default) |
ignition_points.geojson |
8 ignition points on N/NE/E/SE/S/SW/W/NW bearings |
metadata.json |
Grid, CRS, bounds, layer source provenance |
validation_report.txt |
Stage 6 invariant check report |
All rasters share identical CRS, extent, transform, width, and height. ELMFIRE will reject misaligned grids.
{
"protected_polygon": { "type": "Polygon", "coordinates": [[...]] },
"simulation_radius_m": 5000,
"ignition_distance_m": 4500,
"cell_size_m": 30,
"crs": "EPSG:5070"
}Optional fields: safety_buffer_m (default 100), non_burnable_sources (default ["fbfm40"]), landfire_version (default "LF2022"), cache_dir.
uv run wildfire-preproc run path/to/job.json --out ./jobs/my-runBy default the input polygon is interpreted as EPSG:4326 (lon/lat). Override with --protected-polygon-crs.
7 stages, run in order:
- Domain — buffer the protected polygon to produce simulation domain, ignition ring, candidate zone, and 8 ignition points (all in the projected target CRS, polygon-boundary based).
- Acquire — fetch each layer from its configured source (LFPS for fuels/canopy, 3DEP for DEM). Cached on disk by sha256(bbox+layer+crs). Atomic writes (.tmp + rename), smart retry (Connection/Timeout/5xx/429 only).
- Align — build a single canonical
GridSpecsnapped to the global cell grid, then reproject every raster onto it (nearest for FBFM40, bilinear for continuous).reproject_matchis the only path that produces an aligned raster. - Terrain — derive slope and aspect from the aligned DEM via Horn 3x3 (slope in degrees, aspect compass-cw-from-N with flat=-1).
- Masks — rasterize
protected_mask,candidate_zone, buildnon_burnable_maskvia pluggable source registry (FBFM40 reclass default). - Validate — verify CRS, transform, dimensions, nodata, value ranges per layer; build a human-readable report. Fails the run on the first violation.
- Export — write
metadata.json; clean up_intermediate/(preserved with--keep-intermediate).
src/wildfire_preproc/
cli.py # `wildfire-preproc run|validate|sample|elmfire|optimize-firebreaks`
config.py # LayerKey, LayerKind, JobConfig (pydantic v2)
pipeline.py # 7-stage preprocessing orchestrator
domain/ # Stage 1
sources/ # Stage 2: base Protocol + LFPS + 3DEP + local + registry
align/ # Stage 3: GridSpec + reproject_match
terrain/ # Stage 4: Horn 3x3 slope/aspect
masks/ # Stage 5: protected, candidate, non-burnable
validation/ # Stage 6: per-raster checks + report formatter + FBFM40 codes
export/ # Stage 7: metadata.json
elmfire.py # ELMFIRE 8-bearing ensemble: SubprocessElmfireRunner,
# WslElmfireRunner, resolve_elmfire_runner, validation
optimization/ # firebreak layout generation + ranking + optional
# `--rerun-elmfire` populates `optimized_result`
utils/ # geometry, raster I/O, .env loader
wildfire-preproc run <job.json> # Stage 1-7 preprocessing
wildfire-preproc validate <job_dir> # re-run Stage 6 against on-disk rasters
wildfire-preproc sample # bundled Santa Monica AOI
wildfire-preproc elmfire <job.json> <job_dir> # 8-bearing ELMFIRE ensemble
[--runner auto|wsl|native] [--executable PATH]
wildfire-preproc optimize-firebreaks <job_dir> # rank firebreak layouts
[--baseline-dir DIR] # uses ELMFIRE summaries to weight risk
[--rerun-elmfire] # re-run ELMFIRE for the recommended
[--executable PATH] # layout's modified fbfm40 and populate
[--runner auto|wsl|native] # optimized_result in the JSON output
[--job-json PATH] # required for --rerun-elmfire
The LFPS fetcher (src/wildfire_preproc/sources/lfps.py) uses the LANDFIRE Product Service REST workflow:
- Submit a job at
https://lfps.usgs.gov/api/job/submit - Poll
https://lfps.usgs.gov/api/job/status?JobId=<job id> - Download the output zip and extract the GeoTIFF
The submit endpoint was verified live on 2026-05-09: a request without required
parameters returns 400 Bad Request, confirming the endpoint exists. The code
still requires a real LANDFIRE_EMAIL for live job submission.
uv run pytest -m "not live" # 91 tests, ~2s
uv run pytest -m live # requires live LFPS/3DEP access and LANDFIRE_EMAIL
uv run ruff check src tests main.py web/server.py
uv run ruff format src tests
uv run mypy srcUseful ad-hoc:
# Probe LFPS products endpoint (this works)
curl https://lfps.usgs.gov/api/products | jq '.products[] | select(.version=="LF2022")'
# Probe LFPS submit endpoint shape; 400 means the endpoint exists but needs parameters
curl -I https://lfps.usgs.gov/api/job/submit
# Probe 3DEP (this also works)
curl 'https://elevation.nationalmap.gov/arcgis/rest/services/3DEPElevation/ImageServer?f=json'