Skip to content

Repository files navigation

wildfire-preproc

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.

Status

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, and web/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_match as 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 populate optimized_result in firebreak_optimization.json
  • ELMFIRE runners validate the binary path at construction (clear ElmfireExecutableMissingError instead of a confusing subprocess failure) and --runner auto|wsl|native is consistent across main.py and the CLI
  • Mask validation now fails fast if protected_mask or candidate_zone are entirely zero (a sign that the source polygon never intersected the canonical grid)
  • LFPS cache keys include landfire_version so 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/submit and /api/job/status; live runs still require a real LANDFIRE_EMAIL (set in .env - web/server.py and main.py share the same loader)

How a job flows

One job.json produces one canonical grid, and everything downstream is defined against it. The grid is built from the buffered simulation polygon before any layer is fetched, so the fetch bbox, every raster, both masks, and the ELMFIRE decks are all the same pixels.

flowchart TB
    Job["job.json<br/>protected polygon, radii,<br/>cell size, target CRS"]

    subgraph Pre["wildfire-preproc run"]
        direction TB
        S1["1 domain<br/>simulation polygon, ignition ring,<br/>candidate annulus, 8 ignition points"]
        Grid["3 GridSpec<br/>snapped to the global cell grid<br/>built before any fetch"]
        S2["2 acquire<br/>LFPS: fbfm40, cc, ch, cbh, cbd<br/>3DEP: dem<br/>disk cache keyed by layer, bbox<br/>and CRS; LFPS adds landfire_version"]
        S3["3 reproject_match<br/>nearest for fbfm40,<br/>bilinear for continuous"]
        S4["4 terrain<br/>Horn 3x3 slp and asp<br/>from the aligned dem"]
        S5["5 masks<br/>protected, candidate_zone,<br/>non_burnable from FBFM40 reclass"]
        S6{"6 validate<br/>CRS, transform, size,<br/>nodata, value range"}
        S7["7 metadata.json,<br/>drop _intermediate/"]

        S1 --> Grid --> S2 --> S3 --> S4 --> S5 --> S6
        S6 -- "all pass" --> S7
        S6 -- "any fail" --> Stop["validation_report.txt written,<br/>then RuntimeError"]
    end

    Job --> S1
    Grid -. "bbox for every fetch,<br/>target for every reproject,<br/>shape for every mask" .-> S2 & S3 & S4 & S5 & S6
    S7 --> Inputs["inputs/<br/>11 rasters sharing one CRS,<br/>transform, width and height"]

    Inputs --> Ens["wildfire-preproc elmfire<br/>8 ignition bearings,<br/>wind aimed at the polygon centre"]
    Ens --> Base["elmfire_no_firebreak/bearing_*<br/>time of arrival, fireline intensity,<br/>spread rate, run_manifest.json"]

    subgraph Opt["optimize-firebreaks"]
        direction TB
        Risk["risk surface<br/>each scenario deposits weight along<br/>its own bearing, scaled by whether<br/>the patch burned and how much;<br/>zeroed outside the candidate zone"]
        Cand["arc candidates<br/>bearings ranked by risk,<br/>3 radial offsets each,<br/>clipped to the candidate zone"]
        Score["score = fixed baseline damage<br/>+ cost + length<br/>- risk covered"]
        Rank["ranked layouts, each written as<br/>firebreak_mask.tif, segments.geojson,<br/>and an fbfm40 with the break<br/>burned in as non-burnable code 99"]
        Risk --> Cand --> Score --> Rank
    end

    Base --> Risk
    Inputs --> Risk
    Rank --> Json["firebreak_optimization.json"]
    Rank -- "--rerun-elmfire" --> Rerun["rank-1 modified fbfm40 staged into<br/>a fresh inputs/ and pushed back<br/>through the 8-bearing ensemble"]
    Rerun --> Json
Loading

The baseline terms in the score are identical for every candidate, so what actually orders the layouts is cost plus length against risk covered. The optimized_result block in the JSON is the only place where a layout's effect is measured rather than estimated, and it only exists when --rerun-elmfire was passed.

Quick start

# 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.py

UI workspace

The 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.py

Open:

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 web

Open:

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_firebreak

The bundled sample AOI and the wildfire-preproc sample command are wired up for live LFPS+3DEP runs. Set LANDFIRE_EMAIL before running against LFPS.

End-to-end fire simulation app

main.py connects the full workflow:

  1. Accept a protected location (farm/field boundary).
  2. Run LANDFIRE/3DEP preprocessing for that location.
  3. Prepare ELMFIRE input decks for 8 ignition points around the protected polygon.
  4. Run 8 no-firebreak ELMFIRE simulations with wind directed toward the polygon center.
  5. Write simulation_summary.json plus 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-run

Example: farm near San Diego

The 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-example

Outputs:

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-location

Or use your own farm boundary GeoJSON:

.\.venv\Scripts\python.exe main.py --location-geojson .\data\my_farm.geojson --out .\jobs\my-farm-run

Prerequisites:

  • ELMFIRE must be built at elmfire/build/linux/bin/elmfire_2025.0212. The setup in this workspace uses WSL Ubuntu and the official elmfire/build/linux/make_gnu.sh build. The elmfire/ directory is intentionally a peer-install location, not a git submodule - see elmfire/README.md for the build procedure. The runners (wildfire_preproc.elmfire.SubprocessElmfireRunner / WslElmfireRunner / resolve_elmfire_runner) all validate the executable exists at construction and raise ElmfireExecutableMissingError with a clear message rather than failing partway through a subprocess.
  • --runner auto|wsl|native is consistent across python main.py and wildfire-preproc elmfire. auto picks 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-example

Outputs

Per-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.

Config (job.json)

{
  "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-run

By default the input polygon is interpreted as EPSG:4326 (lon/lat). Override with --protected-polygon-crs.

Architecture

7 stages, run in order:

  1. 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).
  2. 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).
  3. Align - build a single canonical GridSpec snapped to the global cell grid, then reproject every raster onto it (nearest for FBFM40, bilinear for continuous). reproject_match is the only path that produces an aligned raster.
  4. Terrain - derive slope and aspect from the aligned DEM via Horn 3x3 (slope in degrees, aspect compass-cw-from-N with flat=-1).
  5. Masks - rasterize protected_mask, candidate_zone, build non_burnable_mask via pluggable source registry (FBFM40 reclass default).
  6. Validate - verify CRS, transform, dimensions, nodata, value ranges per layer; build a human-readable report. Fails the run on the first violation.
  7. Export - write metadata.json; clean up _intermediate/ (preserved with --keep-intermediate).

Module layout

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

CLI commands

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

Live integration notes

The LFPS fetcher (src/wildfire_preproc/sources/lfps.py) uses the LANDFIRE Product Service REST workflow:

  1. Submit a job at https://lfps.usgs.gov/api/job/submit
  2. Poll https://lfps.usgs.gov/api/job/status?JobId=<job id>
  3. 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.

Development

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 src

Useful 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'

About

LANDFIRE/3DEP preprocessing pipeline that turns a protected-land polygon into ELMFIRE-ready rasters on one canonical grid, plus a firebreak optimizer that ranks candidate layouts against an 8-bearing ensemble. 91 tests, mypy strict and ruff strict clean.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages