Screening Wetland & Floodplain Constraints from NWI and FEMA Data
The scenario: a screening run treats every National Wetlands Inventory polygon and every FEMA flood zone as an exclusion, reports that 38 percent of the study area is unbuildable, and the project is dropped. A wetland scientist then points out that most of the NWI polygons in that county are seasonally flooded agricultural depressions, that Zone X is not a regulated floodplain at all, and that the real constrained fraction is closer to 12 percent. This page is about reading the attribute codes before excluding on them, and it sits under environmental constraint and exclusion screening.
Root-cause analysis
Three assumptions cause the over-exclusion, and each has an under-exclusion twin.
- All NWI polygons are jurisdictional wetlands. They are not. NWI is a remote-sensing derived inventory with a Cowardin classification code on every polygon, and the code distinguishes permanently flooded open water from temporarily flooded farmed depressions. Jurisdiction under Section 404 is a legal determination that NWI does not make. Treating every polygon as a hard exclusion over-states the constraint; treating none of them as one under-states it badly.
- All FEMA flood zones are equivalent. Zone A and AE are the 100-year floodplain and carry real development restrictions; Zone X is outside it and carries none; the floodway within Zone AE is far more restrictive than the rest of it. A single “in a flood zone” filter merges three different regulatory realities.
- The two layers are independent. They overlap heavily by construction — wetlands are where water sits — so summing their areas over-states the combined constraint by the overlap, which is the arithmetic covered in building multi-layer exclusion masks.
Pre-flight validation
The pre-flight step is a code inventory rather than a geometry check: list the distinct Cowardin codes and FEMA zone codes present in the study area, with their areas, before deciding anything.
import geopandas as gpd
def inventory_constraint_codes(
nwi: gpd.GeoDataFrame,
fema: gpd.GeoDataFrame,
study_geom,
*,
working_epsg: int,
) -> dict[str, dict[str, float]]:
"""Area by attribute code — the table the exclusion decision should be made from."""
out: dict[str, dict[str, float]] = {}
for name, gdf, field in (("nwi", nwi, "ATTRIBUTE"), ("fema", fema, "FLD_ZONE")):
layer = gdf.to_crs(working_epsg).clip(study_geom)
if layer.empty:
out[name] = {}
continue
layer["_ha"] = layer.geometry.area / 10_000.0
# Cowardin codes are hierarchical: the first two characters carry the system
# and subsystem, which is the level a screening decision is made at.
key = layer[field].astype(str).str[:2] if name == "nwi" else layer[field].astype(str)
out[name] = layer.groupby(key)["_ha"].sum().sort_values(ascending=False).to_dict()
return out
Running that inventory first is what turns “38 percent is wetland” into “PEM covers 9 percent, PFO covers 2 percent, PUB covers 0.4 percent, and the rest is PSS and farmed depressions” — a statement a wetland scientist can act on.
Fix implementation
The classification below is deliberately explicit and deliberately conservative in the right places: open water and permanently flooded systems are hard exclusions, forested and emergent wetlands are permittable, and the floodway is separated from the wider 100-year zone.
import geopandas as gpd
# Cowardin system/subsystem prefixes → constraint class.
NWI_CLASS = {
"L1": "hard", # lacustrine, limnetic — open water
"L2": "hard", # lacustrine, littoral
"R2": "hard", # riverine, lower perennial
"R3": "hard", # riverine, upper perennial
"PUB": "hard", # palustrine unconsolidated bottom — open water
"PAB": "hard", # palustrine aquatic bed
"PFO": "permittable", # palustrine forested
"PSS": "permittable", # palustrine scrub-shrub
"PEM": "permittable", # palustrine emergent
}
FEMA_CLASS = {
"AE": "permittable", # 100-year with base flood elevation
"A": "permittable", # 100-year, no BFE determined
"AO": "permittable",
"AH": "permittable",
"VE": "hard", # coastal high hazard
"X": "advisory", # outside the 100-year zone
"0.2 PCT ANNUAL CHANCE FLOOD HAZARD": "advisory",
}
def classify_wetland_flood_constraints(
nwi: gpd.GeoDataFrame,
fema: gpd.GeoDataFrame,
*,
working_epsg: int,
floodway_field: str = "ZONE_SUBTY",
) -> dict[str, gpd.GeoDataFrame]:
"""Split both sources into hard, permittable and advisory layers."""
nwi_p = nwi.to_crs(working_epsg).copy()
nwi_p["_key"] = nwi_p["ATTRIBUTE"].astype(str).str[:3]
nwi_p["constraint_class"] = (
nwi_p["_key"].map(NWI_CLASS).fillna(nwi_p["_key"].str[:2].map(NWI_CLASS)).fillna("advisory")
)
fema_p = fema.to_crs(working_epsg).copy()
fema_p["constraint_class"] = fema_p["FLD_ZONE"].astype(str).map(FEMA_CLASS).fillna("advisory")
# The regulatory floodway is materially more restrictive than the rest of Zone AE.
if floodway_field in fema_p.columns:
is_floodway = fema_p[floodway_field].astype(str).str.contains("FLOODWAY", case=False, na=False)
fema_p.loc[is_floodway, "constraint_class"] = "hard"
combined = gpd.GeoDataFrame(
gpd.pd.concat([nwi_p[["constraint_class", "geometry"]],
fema_p[["constraint_class", "geometry"]]], ignore_index=True),
crs=nwi_p.crs,
)
return {cls: combined[combined["constraint_class"] == cls].copy()
for cls in ("hard", "permittable", "advisory")}
Fallback routing and performance tuning
- Download by county, not by state. Both NWI and the FEMA National Flood Hazard Layer publish county extracts; a state download is an order of magnitude larger and is clipped away immediately.
- Cache the raw extract with its vintage. FEMA revises flood maps continuously through Letters of Map Revision, and NWI is updated on an irregular state-by-state schedule. The vintage is part of the answer.
- Dissolve by class before unioning. Both layers carry many small polygons; dissolving each class first reduces the union input by an order of magnitude with no change to the result.
- Expect unmapped areas. Parts of the country have no detailed FEMA study and appear as an absence rather than as Zone X. Treat unmapped as unknown and flag it, never as unconstrained.
- Keep the codes on the output. A downstream reviewer will ask which code produced a given exclusion, and re-deriving it costs a rerun.
Downstream validation
def assert_constraint_split(layers: dict[str, gpd.GeoDataFrame], study_ha: float) -> None:
"""CI gate: the split has to be exhaustive, bounded and non-empty where it matters."""
total = sum(float(g.geometry.area.sum()) / 10_000.0 for g in layers.values())
assert total <= study_ha * 3, "classified area exceeds three times the study area — layers unclipped"
assert not layers["hard"].empty or not layers["permittable"].empty, (
"no constrained area at all — check the attribute field names against the source vintage"
)
for cls, gdf in layers.items():
assert gdf.geometry.is_valid.all(), f"{cls}: invalid geometry survived classification"
assert gdf["constraint_class"].eq(cls).all(), f"{cls}: mislabelled rows in the split"
Frequently asked questions
Does an NWI polygon mean the Corps will assert jurisdiction?
No. NWI is an inventory, not a determination, and the two disagree in both directions: NWI misses small features below its mapping threshold and includes features that are not jurisdictional. A screening model should treat NWI as evidence of likely constraint and a delineation as the answer, which is exactly why these polygons belong in the permittable class rather than the hard one.
Should Zone X be excluded at all?
Not as a constraint. Zone X is outside the 100-year floodplain and carries no federal development restriction, so excluding it removes buildable land for no legal reason. It can reasonably carry a small advisory weight in a scoring model, because insurers and lenders sometimes ask about the 0.2-percent-annual-chance zone, but that is a cost signal rather than an exclusion.
How do I handle a study area with no FEMA data?
Flag it and stop, rather than assuming absence means safety. Unmapped areas are common in rural counties and are the case where the screening model is most likely to be wrong, because the flood risk is unknown rather than absent. A field survey or a state-level hazard layer is the substitute, and the output should say which was used.
Why do the wetland and floodplain layers disagree along rivers?
Because they map different things: NWI maps vegetation and hydrology as observed, and FEMA maps modelled flood extent at a given recurrence interval. Along a river the two follow the same valley and diverge in detail, which is exactly why they must be unioned rather than summed — and why the overlap between them is one of the numbers worth publishing.
Are state wetland programmes stricter than the federal one?
Often, and the difference is what a screening model most easily misses. Several states regulate isolated wetlands that fall outside federal jurisdiction, and a few regulate buffers around wetlands rather than only the wetland itself. The consequence for the pipeline is that the classification table belongs in per-state configuration rather than in code, because the same Cowardin code can be permittable in one state and effectively hard in its neighbour.
How much does a wetland delineation change the screening figure?
Enough to be worth commissioning early on a shortlisted site. Field delineation typically moves the regulated wetland area by 20 to 40 percent relative to NWI in either direction, because NWI misses small features and includes some that are not jurisdictional. The screening figure is for ranking sites; the delineation is for designing on one.
Should the floodway be treated as buildable for solar?
No, and it is the one flood-zone answer that is nearly unambiguous. The regulatory floodway is the channel that must convey the base flood without increasing flood height, so development there is restricted far more tightly than in the wider Zone AE — which is exactly why it belongs in the hard class while the rest of AE sits in the permittable one.
Related
- Environmental Constraint & Exclusion Screening — the parent workflow and its three constraint classes
- Building Multi-Layer Exclusion Masks with GeoPandas Overlay — unioning these layers with the others
- Open Energy Data Portals — fetching and caching the source extracts
- Regulatory Boundary Mapping — the statutory setbacks these constraints compose with