Routing Parcels Through Floodplain Overlay Checks
An overlay does not replace the base district; it adds requirements on top of it, and the floodplain overlay is the one that makes every design decision in overlay routing visible at once. It attaches by intersection rather than by majority, it carries a per-zone attribute — the base flood elevation — that the rule needs, it interacts with the height standard, and it is the overlay most likely to touch only part of a parcel. This guide routes parcels through it end to end. It is the worked example behind overlay zone conditional routing.
Prerequisites
Step-by-step
Step 1: Attach the overlay by intersection, and keep the share
Protective overlays attach to any parcel they touch — a parcel 30% within a special flood hazard area is in the floodplain for regulatory purposes. That makes intersects the correct predicate, and it makes the intersected share worth keeping, because the share is what decides whether the parcel can build outside the hazard.
import geopandas as gpd
SFHA = {"A", "AE", "AH", "AO", "AR", "A99", "V", "VE"}
def attach_floodplain(parcels, flood, zone_col="FLD_ZONE"):
"""Which parcels touch a special flood hazard area, and by how much."""
hazard = flood[flood[zone_col].isin(SFHA)]
parts = gpd.overlay(parcels[["parcel_id", "geometry"]],
hazard[[zone_col, "STATIC_BFE", "geometry"]],
how="intersection", keep_geom_type=True)
parts["hazard_area"] = parts.area
agg = parts.groupby(["parcel_id", zone_col]).agg(
hazard_area=("hazard_area", "sum"),
bfe=("STATIC_BFE", "max")).reset_index()
total = parcels.set_index("parcel_id").area
agg["hazard_fraction"] = agg["hazard_area"] / agg["parcel_id"].map(total)
return agg
A parcel can appear more than once here — flood layers routinely split a parcel across an AE zone and an X zone, and occasionally across two AE zones with different base flood elevations. That is not an error to be deduplicated away; it is the situation the routing has to handle.
Step 2: Resolve multiple zones to the governing one
Where a parcel touches more than one hazard zone, the governing zone is the most restrictive, and the ordering is a property of the ordinance rather than of the data. Encode it explicitly.
# Most restrictive first. V zones carry wave action; AO is shallow flooding.
ZONE_SEVERITY = ["V", "VE", "A", "AE", "AH", "AR", "A99", "AO", "X"]
RANK = {z: i for i, z in enumerate(ZONE_SEVERITY)}
def governing_zone(agg, zone_col="FLD_ZONE"):
agg = agg.assign(rank=agg[zone_col].map(RANK).fillna(len(RANK)))
idx = agg.groupby("parcel_id")["rank"].idxmin()
return agg.loc[idx].drop(columns="rank")
Taking the maximum base flood elevation in step 1 is the same principle applied to the attribute: where a parcel spans two elevations, the higher one is the one that has to be met. Both choices should be stated in the report, since a reviewer comparing against a flood map will see two zones and needs to know which one the pipeline used and why.
Step 3: Add the overlay’s requirements to the base district’s
The overlay is additive, so the parcel’s rule set is the union of its base district rules and the overlay’s — with the overlay winning where the two conflict.
def rules_for(parcel_row, base_rules, overlay_rules):
"""Base rules plus overlay rules; overlay displaces base on the same measure."""
applicable = {r["measure"]: r for r in base_rules}
for r in overlay_rules:
prior = applicable.get(r["measure"])
applicable[r["measure"]] = r # overlay governs the shared measure
if prior:
r.setdefault("displaces", []).append(prior["id"])
return list(applicable.values())
The floodplain overlay’s interaction with the height limit is the case that makes this concrete. The overlay requires the lowest floor to be at or above the base flood elevation plus freeboard, which pushes the whole building up; the base district caps overall height. Many ordinances resolve this by measuring height from the required flood elevation rather than from grade in the overlay, and if the pipeline does not implement that, every elevated building in the floodplain reads as a height violation.
Step 4: Evaluate the elevation requirement
The overlay’s central standard is a comparison between the structure’s lowest floor and a required elevation derived from the base flood elevation.
FREEBOARD_FT = 2.0 # from the local ordinance; the federal minimum is zero
def elevation_check(parcel, bfe, lowest_floor_ft, freeboard=FREEBOARD_FT):
if bfe is None or not (bfe == bfe): # NaN — an unnumbered A zone
return {"verdict": "indeterminate",
"reason": "no published base flood elevation for this zone"}
required = float(bfe) + freeboard
return {"verdict": "complies" if lowest_floor_ft >= required else "exceeds",
"required_elevation_ft": required,
"lowest_floor_ft": lowest_floor_ft,
"margin_ft": lowest_floor_ft - required}
Unnumbered A zones — approximate hazard areas with no published elevation — are common in rural jurisdictions and are the reason the indeterminate outcome has to exist. There is no elevation to compare against, so the pipeline’s correct answer is that a site-specific determination is required, not a pass. Where the lowest floor comes from lidar, the uncertainty treatment in computing building height from lidar-derived surfaces applies to this comparison too.
Step 5: Route the partly-affected parcels separately
A parcel with 15% of its area in the hazard and a buildable envelope entirely outside it is in a different position from one wholly within. Both are “in the floodplain”; only one is constrained by it.
def route(parcel, hazard_geom, envelope):
"""Three routes, decided by where the buildable area sits relative to the hazard."""
if envelope.disjoint(hazard_geom):
return "advisory" # overlay attaches, but nothing buildable is affected
if envelope.within(hazard_geom):
return "full_review" # every buildable square foot is in the hazard
return "partial" # buildable area straddles; siting decides
This routing is what keeps the overlay’s caseload proportionate. On a river-adjacent municipality, a large share of parcels touch the hazard area and only a fraction of those have a buildable envelope inside it — and it is the second number that determines how much work the overlay actually creates. The queueing of these routes is the pattern described in building async rule queues for batch zoning validation.
Step 6: Record the map version
Flood maps are revised, and a revision changes verdicts. The effective date of the panel that governed a determination is part of the determination.
overlay_record = {
"layer": "NFHL",
"panel": parcel_row["DFIRM_ID"],
"effective_date": parcel_row["EFF_DATE"],
"zone": governing["FLD_ZONE"],
"bfe_ft": governing["bfe"],
"bfe_datum": "NAVD88",
"freeboard_ft": FREEBOARD_FT,
"hazard_fraction": round(float(governing["hazard_fraction"]), 4),
"route": route_name,
}
The vertical datum on the base flood elevation is not optional. Older panels are published on NGVD29, current ones on NAVD88, and the difference between them runs to a foot or more depending on location — comfortably larger than a typical freeboard requirement.
Verification
Check that the attachment is complete, that the zone resolution is deterministic, and that the routing partitions the affected parcels exactly once.
assert governing.groupby("parcel_id").size().max() == 1, "a parcel resolved to two zones"
touching = set(parcels.sjoin(hazard, predicate="intersects")["parcel_id"])
assert set(governing["parcel_id"]) == touching, "attachment lost or gained parcels"
counts = routed["route"].value_counts()
assert counts.sum() == len(governing)
print(counts) # advisory / partial / full_review
# Nothing routed advisory may have a buildable area inside the hazard.
adv = routed[routed["route"] == "advisory"]
assert adv.apply(lambda r: r.envelope.disjoint(r.hazard), axis=1).all()
The final assertion is the one that protects against the expensive mistake. An advisory route means nobody looks at the parcel, so the condition that earns that route has to be asserted rather than assumed.
Common Pitfalls
- Attaching the overlay by majority. A parcel 40% in a flood hazard area is in it. Majority-governs is right for base districts and wrong for protective overlays.
- Deduplicating multi-zone parcels arbitrarily. Keeping the first row returned makes the verdict depend on row order. Rank the zones explicitly.
- Treating an unnumbered A zone as having no requirement. It has a requirement with no published number, which is an indeterminate outcome and a site-specific study, not a pass.
- Mixing NGVD29 and NAVD88 elevations. The offset exceeds most freeboard requirements, so it flips verdicts rather than merely perturbing them.
- Applying the base height limit from grade inside the overlay. Elevated buildings then read as height violations en masse, which is a definitional bug that looks like a data problem.
- Ignoring the panel effective date. Two runs months apart can differ legitimately, and without the date nobody can tell that from a regression.
Frequently Asked Questions
Should the overlay ever remove a base requirement?
Rarely, and only where the ordinance says so. The default is additive — both apply, and the stricter governs. Where an overlay genuinely relaxes a base standard, encode it as an explicit displacement so the relaxation is visible in the rule list rather than emerging from evaluation order.
How does this generalise to other overlays?
Directly. Historic districts, airport approach surfaces, watershed protection and steep-slope overlays all follow the same shape: attach by intersection, resolve to a governing instance, add requirements to the base, route by whether the buildable area is actually affected. What differs is the attribute the overlay carries and the standard it imposes; the routing is the same, and handling conditional logic for historic district overlays works one where the standard is qualitative.
What if the flood layer and the parcel fabric disagree along a boundary?
They will — they come from different agencies at different scales, and the hazard boundary is a modelled line rather than a surveyed one. Do not snap them together: the disagreement is real and the hazard boundary has no survey accuracy to snap to. Report the hazard fraction and let a small fraction route to review, which is exactly what the routing in step 5 does.
Can the base flood elevation be interpolated between zones?
No. Published static elevations apply to the zone as mapped, and interpolating across a boundary invents a number no map supports. Where a parcel spans two elevations, take the higher and say so.
How large is the caseload in practice?
It depends heavily on geography, but the useful figure is the ratio between parcels touching the hazard and parcels routed to full review — typically a small fraction, since most affected parcels have buildable area outside the mapped hazard. Reporting both numbers each run makes it obvious when a map revision has materially changed the workload.
Does the overlay affect density and FAR calculations?
Sometimes. Many ordinances exclude land within a floodway from the lot area used for density, which changes the denominator rather than adding a standard. That is an apportionment problem rather than a routing one — see apportioning split-zoned parcels by area — and it needs the hazard area computed in step 1, which is already available.
Related
Part of: Overlay zone conditional routing
- Building async rule queues for batch zoning validation — executing the routes this produces.
- Handling conditional logic for historic district overlays — the same routing with a qualitative standard.
- Deciding which parcels a rule applies to — the applicability predicates behind attachment.
- Jurisdictional boundary and precedence resolution — precedence when two authorities both regulate.
- Computing building height from lidar-derived surfaces — measuring the elevation the overlay tests.