How to work with fire perimeter data in Python

On this page

Problem statement

Fire perimeters look like the simplest hazard layer there is: a polygon per fire. In practice one fire has many perimeters β€” a new one after each mapping flight β€” several fires share a name, the area attribute is in acres and disagrees with the geometry, and the dates are epoch milliseconds in a field whose name is not obvious. Query the US interagency service for a fire called Park and you get thirteen features, from California, New Mexico, Texas, Wyoming and elsewhere, spread over seven years.

This guide pulls perimeters from the US interagency WFIGS service, picks the right feature for one fire, and reconciles its reported acreage with the polygon's own area.

Quick answer

Query the service with a where clause, then filter on state, size and date:

import json, urllib.parse, urllib.request
import geopandas as gpd, pandas as pd

SERVICE = ("https://services3.arcgis.com/T4QMspbfLg3qTGWY/arcgis/rest/services/"
           "WFIGS_Interagency_Perimeters/FeatureServer/0/query")

def perimeters(where, out_sr=4326):
    url = SERVICE + "?" + urllib.parse.urlencode(
        {"where": where, "outFields": "*", "f": "geojson", "outSR": out_sr})
    req = urllib.request.Request(url, headers={"User-Agent": "example/1.0"})
    gj = json.loads(urllib.request.urlopen(req, timeout=120).read())
    gdf = gpd.GeoDataFrame.from_features(gj["features"], crs=out_sr)
    for col in ("attr_FireDiscoveryDateTime", "poly_DateCurrent"):
        if col in gdf:
            gdf[col] = pd.to_datetime(gdf[col], unit="ms", utc=True)
    return gdf

fires = perimeters("poly_IncidentName='PARK' OR poly_IncidentName='Park'")
print(f"{len(fires)} features named Park")
print(fires[["poly_IncidentName", "attr_POOState", "poly_GISAcres",
             "attr_FireDiscoveryDateTime"]].sort_values("poly_GISAcres", ascending=False)
      .head(5).to_string(index=False))
13 features named Park
poly_IncidentName attr_POOState  poly_GISAcres attr_FireDiscoveryDateTime
             PARK         US-CA  429602.850915  2024-07-24 21:52:43+00:00
             PARK         US-CA     318.320000  2020-07-05 06:00:17+00:00
             PARK         US-NM     300.000000  2023-05-03 19:15:00+00:00
             Park         US-TX      50.689652  2026-02-27 18:45:37+00:00
             Park         US-WY      12.100000  2024-10-24 20:15:06+00:00

Names are not identifiers. attr_POOState, the discovery date and the size are what separate the fire you mean from the others.

From an incident name to one trusted fire perimeter. name to perimeterquery by name13 featuresfilter bystateattr_POOStatefilter by sizeand date1 leftprojectequal areacheck acres vsgeometry429,603 vs 431,047A fire name is not an identifier; three attributes together are spatialworkflow.io
Four filters to get from a name to a fire, and one more to trust its area.

Query, deduplicate, parse dates, reconcile area

1. Query with where, not with a download

The service supports SQL-ish filters and returns GeoJSON. Pulling the whole layer to filter locally is slow and unnecessary.

2. Expect duplicates and near-duplicates

Incident names repeat between states and years. Filter on state and discovery date, and check how many features remain.

3. Parse the dates

Date fields arrive as epoch milliseconds. pd.to_datetime(..., unit="ms", utc=True) is the conversion; without it, a 13-digit integer sorts fine and reads as nonsense.

4. Reconcile the area

poly_GISAcres is the mapper's figure. The polygon's own area in an equal-area projection is a check on it; small differences are normal, large ones mean the feature is not what you think.

5. Project before measuring

Areas and distances need an equal-area or local projected CRS. For California, EPSG:3310; for a continental summary, an equal-area projection covering it.

6. Keep the perimeter's date

A perimeter is a snapshot from a flight or a satellite pass. Its date is part of its meaning, particularly for a fire that burned for weeks.

Park Fire area from the reported acreage and from the polygon in four coordinate reference systems. the same polygon, four measurementsreported poly_GISAcres429,603 acresCalifornia Albers431,047 acresUTM zone 10N430,815 acresWeb Mercator737,915 acres β€” 72% high0400k800kEqual-area projections agree with the reported figure to within 0.4% spatialworkflow.io
The same polygon measured four ways: Web Mercator overstates its area by 72%.

From a fire's name to its perimeter

Example 1 β€” from a name to one fire

park = fires[(fires.attr_POOState == "US-CA") & (fires.poly_GISAcres > 1000)]
print(f"California features over 1,000 acres: {len(park)}")
row = park.sort_values("poly_GISAcres", ascending=False).iloc[0]
print(f"{row.poly_IncidentName}: {row.poly_GISAcres:,.0f} acres, "
      f"discovered {row.attr_FireDiscoveryDateTime:%Y-%m-%d}, "
      f"perimeter current {row.poly_DateCurrent:%Y-%m-%d}")
California features over 1,000 acres: 1
PARK: 429,603 acres, discovered 2024-07-24, perimeter current 2024-08-24

Example 2 β€” acres against geometry

one = park.sort_values("poly_GISAcres", ascending=False).head(1)
for name, crs in [("WGS 84 (degrees)", 4326), ("California Albers", 3310),
                  ("UTM 10N", 32610), ("Web Mercator", 3857)]:
    area = one.to_crs(crs).area.iloc[0]
    acres = area * 0.000247105 if crs != 4326 else float("nan")
    print(f"{name:20} area {area:14,.0f} {'degΒ²' if crs == 4326 else 'mΒ²':4} "
          f"{'' if crs == 4326 else f'= {acres:9,.0f} acres'}")
print(f"reported poly_GISAcres: {one.poly_GISAcres.iloc[0]:,.0f}")
WGS 84 (degrees)     area              0 degΒ² 
California Albers    area  1,744,389,978 mΒ²   =   431,047 acres
UTM 10N              area  1,743,449,312 mΒ²   =   430,815 acres
Web Mercator         area  2,986,239,063 mΒ²   =   737,915 acres
reported poly_GISAcres: 429,603

Example 3 β€” several fires at once

big = perimeters("attr_POOState='US-CA' AND poly_GISAcres>50000 "
                 "AND attr_FireDiscoveryDateTime>TIMESTAMP '2024-01-01 00:00:00'")
big["km2"] = big.to_crs(3310).area / 1e6
print(big[["poly_IncidentName", "km2", "attr_FireDiscoveryDateTime"]]
      .sort_values("km2", ascending=False).head(6).to_string(index=False, float_format="%.0f"))
poly_IncidentName  km2 attr_FireDiscoveryDateTime
             PARK 1744  2024-07-24 21:52:43+00:00
          Gifford  533  2025-08-01 20:57:00+00:00
              Bug  379  2026-08-08 22:30:49+00:00
            Madre  327  2025-07-02 20:07:00+00:00
           Biscar  281  2026-07-18 18:06:09+00:00
           Garnet  249  2025-08-24 07:10:00+00:00
Map of California in an equal-area projection showing eight fire perimeters over 50,000 acres since 2024, labelled with name, area and year. The Park Fire in the northern Sacramento Valley is by far the largest, drawn in red. Map of California in an equal-area projection showing eight fire perimeters over 50,000 acres since 2024, labelled with name, area and year. The Park Fire in the northern Sacramento Valley is by far the largest, drawn in red.
The query above, drawn: every California fire over 50,000 acres since 2024 in the WFIGS perimeters, measured in California Albers (EPSG:3310). The Park Fire, at 1,744 kmΒ², is more than three times the size of the next, Gifford at 533 kmΒ².

Why one fire has many perimeters

Why one fire has many perimeters

Perimeters are mapped repeatedly while a fire burns β€” by infrared flights, by satellite, by hand. Some services keep every version; WFIGS keeps current and historic layers separately. A query that returns several polygons for one incident is usually returning its history.

Why acres and geometry disagree

The acreage attribute is recorded by the incident management team, often from a different mapping of the fire and rounded. The polygon's area is whatever the geometry says in whatever CRS you project it to. Web Mercator inflates area badly away from the equator, so a check that uses it will always disagree.

Why the date fields matter more than usual

A fire perimeter without a date cannot be matched to imagery. Burn severity needs a pre-fire scene before the discovery date and a post-fire scene after containment; a perimeter dated mid-fire will not contain everything the fire eventually burned.

The WFIGS perimeter fields that matter and the trap in each. six fields worth knowingfieldwhat it istrappoly_IncidentNamethe fire namereused across states and yearsattr_POOStatestate of originthe usable filterattr_FireDiscoveryDateTimeignition reportepoch millisecondspoly_DateCurrentperimeter dateepoch millisecondspoly_GISAcresreported sizenot the polygon areageometrythe polygonneeds an equal-area CRSTwo date fields in epoch milliseconds, one size that is not the geometry spatialworkflow.io
Six fields, and the two date fields are epoch milliseconds.

Other countries, MTBS and complexes

  • Other countries: EFFIS publishes European perimeters; each country has its own service too.
  • Historic layers hold fires from previous years; the current layer is only the active season.
  • MTBS in the US publishes mapped perimeters and severity for large fires, several years behind.
  • Complexes are several fires managed as one incident and may share a perimeter.
  • Holes inside a perimeter are unburned islands; keep them rather than filling the polygon.
  • Rate limits: resultRecordCount and resultOffset page a large query.

FAQ

Where do US fire perimeters come from?

The WFIGS interagency service, published as an ArcGIS feature service that returns GeoJSON.

Why does my query return several fires?

Incident names are reused across states and years. Filter on state, date and size.

Why does the acreage not match the polygon?

The attribute is the incident team's figure; the geometry is yours to project. Check in an equal-area CRS.

What date should I use to choose imagery?

Discovery date for the pre-fire scene; the perimeter's current date, or containment, for the post-fire one.

Are perimeters final?

Only after the season; during a fire they are updated repeatedly.

Can I get perimeters outside the US?

Yes β€” EFFIS covers Europe, and many countries publish their own.