Why Are My Fire Perimeter Areas Wrong or Raising TopologyException?
On this page
Problem statement
Summed as published, CAL FIRE's 94 invalid fire perimeters from 2021β2025 come to 3,654,952 acres; the agency's own figure for the same fires is 3,564,641. The Dixie Fire alone measures 993,599 acres against an official 963,405, and the 2022 Mill Fire 16.5% too large, because unburned islands inside the outline were stored as extra shells on top of it instead of holes.
They are 94 of the 746 perimeters in CAL FIRE's last-five-years view, among them the Dixie, Park, Palisades and Eaton fires. This page finds them, measures what they do to areas and overlays, and shows why the common buffer(0) trick gives the wrong answer for these fires while make_valid() gives the right one.
Quick answer
Check validity, repair with make_valid(), and confirm against the published acreage:
bad = P[~P.is_valid]
fixed = bad.geometry.make_valid()
print(f"{len(bad)} of {len(P)} perimeters invalid; raw area {bad.area.sum() / ACRE_M2:,.0f} acres, "
f"after make_valid {fixed.area.sum() / ACRE_M2:,.0f}, GIS_ACRES {bad.GIS_ACRES.sum():,.0f}")
94 of 746 perimeters invalid; raw area 3,654,952 acres, after make_valid 3,564,660, GIS_ACRES 3,564,641
Find the invalid polygons, measure the error, repair properly
1. Find the invalid polygons and why
shapely.validation.explain_validity names the first problem in each geometry:
reason = bad.geometry.apply(lambda g: explain_validity(g).split("[")[0])
print(reason.value_counts().to_string())
geometry
Ring Self-intersection 52
Nested shells 42
A ring self-intersection is an outline that touches or crosses itself, usually where two mapped edges were joined. Nested shells are polygons stored inside other polygons as separate parts, when they should be holes. In fire perimeters they are almost always unburned islands.
2. Measure the area error
A nested shell adds its area instead of subtracting it, so the raw area includes every unburned island twice over: once in the outer shell and once as an extra part. Compare with make_valid() and with CAL FIRE's own GIS_ACRES:
cmp = bad.assign(raw=bad.area / ACRE_M2, valid=fixed.area / ACRE_M2)
cmp["over"] = cmp.raw / cmp.valid - 1
print(cmp.nlargest(6, "over")[["FIRE_NAME", "YEAR_", "GIS_ACRES", "raw", "valid", "over"]]
.to_string(index=False, formatters={"GIS_ACRES": "{:,.0f}".format, "raw": "{:,.0f}".format, "valid": "{:,.0f}".format, "over": "{:+.1%}".format}))
FIRE_NAME YEAR_ GIS_ACRES raw valid over
MILL 2022 3,939 4,591 3,939 +16.5%
PEARCH 2023 12,152 13,626 12,152 +12.1%
LAKE 2024 38,610 42,592 38,610 +10.3%
MOSQUITO 2023 34,188 37,498 34,188 +9.7%
POINT 2024 462 496 462 +7.4%
RIVER COMPLEX 2021 199,354 213,686 199,354 +7.2%
3. Do not use buffer(0) here
buffer(0) is the old way to repair a polygon. It rebuilds the geometry as the union of its parts, so a nested shell disappears into the shell around it. The island is filled in, counted as burned:
for name, year in (("DIXIE", 2021), ("MILL", 2022), ("PALISADES", 2025)):
r = P[(P.FIRE_NAME == name) & (P.YEAR_ == year)]
structure = shapely.make_valid(r.geometry.values, method="structure")
print(f"{name.title():10s} raw {r.area.sum() / ACRE_M2:>9,.0f} buffer(0) {r.buffer(0).area.sum() / ACRE_M2:>9,.0f} "
f"structure {shapely.area(structure).sum() / ACRE_M2:>9,.0f} make_valid {r.make_valid().area.sum() / ACRE_M2:>9,.0f} "
f"GIS_ACRES {r.GIS_ACRES.sum():>9,.0f}")
Dixie raw 993,599 buffer(0) 978,467 structure 978,467 make_valid 963,406 GIS_ACRES 963,405
Mill raw 4,591 buffer(0) 4,265 structure 4,265 make_valid 3,939 GIS_ACRES 3,939
Palisades raw 24,054 buffer(0) 23,751 structure 23,751 make_valid 23,449 GIS_ACRES 23,449
Shapely 2.1's make_valid(..., method="structure") does the same. The default make_valid() keeps every part and resolves the overlap, so a part inside another becomes a hole. That matches how the fire was mapped.
4. See which operations fail and which hide it
Some operations raise on invalid input, some repair it, and some carry the error through silently:
dixie = P[(P.FIRE_NAME == "DIXIE") & (P.YEAR_ == 2021)]
try:
gpd.clip(dixie, COUNTIES)
except Exception as e:
print("clip:", type(e).__name__, str(e).split(" at ")[0])
print(f"overlay: {gpd.overlay(dixie, COUNTIES, how='intersection').area.sum() / ACRE_M2:,.0f} acres (validates first by default)")
mill = P[(P.FIRE_NAME == "MILL") & (P.YEAR_ == 2022)]
print(f"clip, Mill Fire: {gpd.clip(mill, COUNTIES).area.sum() / ACRE_M2:,.0f} acres, no error (GIS_ACRES {mill.GIS_ACRES.sum():,.0f})")
clip: GEOSException TopologyException: side location conflict
overlay: 963,406 acres (validates first by default)
clip, Mill Fire: 4,591 acres, no error (GIS_ACRES 3,939)
GeoPandas' overlay repairs its inputs (make_valid=True is the default), which is why it gets the area right. clip does not. Repair once, when the data is loaded, and every later step can rely on it.
Repairing on load and recording what changed
The data used on this page
CAL FIRE California Fire Perimeters, last-five-years public view (California_Fire_Perimeters_last_5_years_View, fires over 50 acres), projected to California Albers (EPSG:3310), and the 2023 county outlines from the Census Bureau's cartographic boundary files.
from pathlib import Path
import geopandas as gpd
import requests
import shapely
from shapely.validation import explain_validity
Path("data").mkdir(exist_ok=True)
LAYER = "https://services1.arcgis.com/jUJYIo9tSA7EHvfZ/arcgis/rest/services/California_Fire_Perimeters_last_5_years_View/FeatureServer/0"
GEOJSON = Path("data/calfire_last5.geojson")
if not GEOJSON.exists():
r = requests.get(LAYER + "/query", params={"where": "1=1", "outFields": "*", "outSR": 4326, "f": "geojson"}, timeout=300)
GEOJSON.write_bytes(r.content)
if not Path("data/cb_2023_us_county_20m.zip").exists():
Path("data/cb_2023_us_county_20m.zip").write_bytes(requests.get("https://www2.census.gov/geo/tiger/GENZ2023/shp/cb_2023_us_county_20m.zip", timeout=300).content)
P = gpd.read_file(GEOJSON).to_crs(3310)
COUNTIES = gpd.read_file("data/cb_2023_us_county_20m.zip").query("STATEFP == '06'").to_crs(3310)[["NAME", "geometry"]]
ACRE_M2 = 4046.8564224
print(f"{len(P)} perimeters, {len(COUNTIES)} counties")
746 perimeters, 58 counties
Example 1: the whole dataset
all_raw = P.area.sum() / ACRE_M2
all_valid = P.make_valid().area.sum() / ACRE_M2
print(f"fires over 50 acres, 2021β2025: raw {all_raw:,.0f} acres, valid {all_valid:,.0f}, GIS_ACRES {P.GIS_ACRES.sum():,.0f}; "
f"raw overstates by {all_raw - all_valid:,.0f} acres ({all_raw / all_valid - 1:.1%})")
fires over 50 acres, 2021β2025: raw 4,769,922 acres, valid 4,679,630, GIS_ACRES 4,678,953; raw overstates by 90,292 acres (1.9%)
Example 2: repair on load and record what changed
clean = P.assign(was_valid=P.is_valid, geometry=P.make_valid())
clean = clean[clean.geom_type.isin(["Polygon", "MultiPolygon"])]
clean.to_file("data/calfire_last5_valid.gpkg", driver="GPKG")
print(f"{(~clean.was_valid).sum()} repaired, {clean.is_valid.all() and 'all valid now'}; types: {clean.geom_type.value_counts().to_dict()}")
94 repaired, all valid now; types: {'Polygon': 500, 'MultiPolygon': 246}
What the Simple Features rules require
The OGC Simple Features rules say a polygon is one outer ring with optional holes. The rings must not cross, and holes must lie inside the shell. A multipolygon's parts must not overlap. GEOS, the engine under Shapely and GeoPandas, assumes these rules hold and gives no guarantee when they do not. Some functions raise, some return nonsense, and .area simply adds up the areas of the parts it is given, overlapping or not.
Fire perimeters break the rules for understandable reasons. An unburned island mapped as its own polygon and then merged with the fire becomes a nested shell. Two edges mapped on different days and joined at a point become a self-intersection. make_valid() interprets the geometry the way it was meant to be read: overlapping parts cancel, so an island becomes a hole. buffer(0) interprets it as a union, so an island becomes burned land. For fires the first reading is correct, as GIS_ACRES confirms.
Collections, reprojection and overlay
make_validcan return collections: a polygon that collapses in part can come back as aGeometryCollectionwith lines or points; keep only polygonal parts.- Validate after reprojecting: a valid polygon in one CRS can become invalid in another when vertices very close together snap.
overlayvalidates by default;sjoin,clip,union_allanddissolvedo not.method="structure"in Shapely 2.1 unions overlapping parts, likebuffer(0), and fills the same islands (step 3). Keep the default"linework"method for fire perimeters.- Report it: if a published perimeter is invalid, the agency's own area field is the best check of your repair.
Internal links
- Why is my wildfire area too large? β the other cause of a wrong fire area
- How to download CAL FIRE wildfire perimeters β the data used here
FAQ
What does TopologyException: side location conflict mean?
GEOS found a geometry whose rings cross or overlap where they should not. Repair the input with make_valid() before the operation.
Should I use buffer(0) or make_valid()?
make_valid(). buffer(0) unions the parts of a polygon, which fills in holes stored as nested shells; for fire perimeters that counts unburned islands as burned.
How do I find invalid geometries in GeoPandas?
gdf[~gdf.is_valid], and shapely.validation.explain_validity(geom) for the reason.
Why is the raw area larger than the official area?
A nested shell adds its area to the total instead of being subtracted as a hole, so unburned islands are counted as burned.