Deciding Which Parcels a Rule Applies To
Before a rule can be evaluated, something has to decide whether it applies at all — and that decision is where most false positives are made. A parcel that is 96% in R-1 and 4% in C-2 is subject to which setback? A parcel whose centroid falls outside the historic district but whose frontage is inside it? This guide implements the applicability layer: attribute predicates, spatial predicates, and the tie-breaking policy for parcels that straddle whatever decides. It is the scoping half of scoping rule frameworks.
Prerequisites
Step-by-step
Step 1: Separate the two kinds of applicability test
Applicability predicates come in two forms that behave completely differently, and mixing them in one evaluator is the source of most of the confusion. An attribute test — structure class is principal, lot width is under sixty feet — reads a column and returns a boolean per parcel. A spatial test — in the R-1 district, within the floodplain overlay — is a relationship between two geometries and has no single correct answer when they partially overlap.
def is_attribute_test(clause: dict) -> bool:
"""Attribute tests read a column; spatial tests read a layer."""
return "attr" in clause
Evaluate the attribute tests first. They are cheap, they are unambiguous, and on a real rule pack they eliminate most parcels before any geometry is touched.
Step 2: Evaluate attribute predicates vectorized
The predicate structure from the rule pack maps directly onto pandas operations. Keep it vectorized — a per-row Python loop over a county fabric turns a two-second operation into a twenty-minute one.
import operator
import pandas as pd
OPS = {"eq": operator.eq, "ne": operator.ne, "lt": operator.lt,
"lte": operator.le, "gt": operator.gt, "gte": operator.ge}
def attribute_mask(gdf, clause: dict) -> pd.Series:
"""One boolean column per attribute clause, evaluated over the whole frame."""
if "all" in clause:
masks = [attribute_mask(gdf, c) for c in clause["all"] if is_attribute_test(c)]
return pd.concat(masks, axis=1).all(axis=1) if masks else pd.Series(True, gdf.index)
if "any" in clause:
masks = [attribute_mask(gdf, c) for c in clause["any"] if is_attribute_test(c)]
return pd.concat(masks, axis=1).any(axis=1) if masks else pd.Series(True, gdf.index)
if clause["attr"] not in gdf.columns:
raise KeyError(f"applicability references unknown attribute {clause['attr']}")
return OPS[clause["op"]](gdf[clause["attr"]], clause["value"])
The KeyError is deliberate and should never be softened into a warning. A predicate over an attribute the fabric does not carry has silently applied to nothing, and a rule that applies to nothing produces no findings — which looks exactly like compliance.
Step 3: Decide the spatial test’s policy, then implement it
In the R-1 district is not a well-defined test until you say what “in” means. There are four defensible policies and they disagree on real parcels:
POLICIES = {
# the parcel's centroid falls inside the district
"centroid": lambda p, d: p.centroid.within(d),
# any part of the parcel touches the district
"intersects": lambda p, d: p.intersects(d),
# the whole parcel is inside
"contains": lambda p, d: d.contains(p),
# more than half the parcel's area is inside — the usual default
"majority": lambda p, d: p.intersection(d).area > 0.5 * p.area,
}
Majority-governs is the common default because it matches how most ordinances are administered in practice, and because it always produces exactly one district per parcel. Intersects is the right choice for overlays whose purpose is protective — a floodplain rule should attach to a parcel that is partly in the floodplain, not only to one that is mostly in it. The policy belongs in the rule pack next to the predicate, not in the evaluator, because it differs per rule.
Step 4: Evaluate spatial applicability with an indexed join
Run the spatial test as an overlay rather than a per-parcel loop, then reduce it according to the policy. The overlay gives the intersection areas that majority-governs needs, and it costs one indexed pass.
import geopandas as gpd
def spatial_mask(parcels, districts, value, policy="majority", col="zoning_district"):
"""Which parcels satisfy 'in <value>' under the stated policy."""
target = districts[districts[col] == value]
if policy == "intersects":
hits = parcels.sjoin(target[["geometry"]], predicate="intersects").index.unique()
return parcels.index.isin(hits)
parts = gpd.overlay(parcels[["parcel_id", "geometry"]], target[["geometry"]],
how="intersection", keep_geom_type=True)
share = parts.assign(a=parts.area).groupby("parcel_id")["a"].sum()
full = parcels.set_index("parcel_id").area
frac = (share / full).reindex(full.index).fillna(0.0)
threshold = {"majority": 0.5, "contains": 0.999, "centroid": 0.5}[policy]
return (frac > threshold).reindex(parcels["parcel_id"]).to_numpy()
Note that keep_geom_type=True is doing real work: an overlay of two polygon layers that share a boundary produces line and point fragments along it, and those fragments have zero area but non-zero row count. Left in, they inflate nothing but they break the assumption that every row is a polygon, which surfaces two steps later as a confusing error.
Step 5: Record why each rule applied, not just that it did
The applicability decision is the one a reviewer challenges most often, so it should be evidence rather than a boolean. Keep the fraction that drove it.
def applicability_record(parcel_id, rule_id, policy, fraction, decided):
return {"parcel_id": parcel_id, "rule_id": rule_id, "policy": policy,
"area_fraction_in_scope": round(float(fraction), 4), "applied": bool(decided)}
A parcel at 0.51 and a parcel at 0.99 both “applied”, and only one of them is worth a second look. Recording the fraction turns a contested verdict into a two-line explanation, and it feeds the split-parcel treatment in apportioning split-zoned parcels by area when apportionment is the better answer than a single winner.
Step 6: Flag the parcels the policy barely decided
Any parcel whose governing fraction sits near the threshold is a parcel where the policy, not the ordinance, produced the answer. These are worth surfacing every run.
BORDERLINE = 0.05 # within five points of the majority threshold
borderline = frac[(frac - 0.5).abs() < BORDERLINE]
print(f"{len(borderline)} parcels decided by policy rather than by margin")
On a typical county fabric this is a few hundred parcels out of a few hundred thousand — small enough to review, large enough that discovering them one complaint at a time is unpleasant.
Verification
Applicability is correct when every parcel is assigned by exactly one policy, no parcel is silently unassigned, and the counts reconcile against the district layer.
assigned = applicability.groupby("parcel_id")["applied"].sum()
assert (assigned <= 1).all(), "a parcel matched two mutually exclusive district rules"
unassigned = set(parcels["parcel_id"]) - set(applicability.loc[applicability["applied"], "parcel_id"])
print(f"{len(unassigned)} parcels matched no district rule")
by_district = applicability[applicability["applied"]].groupby("rule_id").size()
print(by_district.sort_values(ascending=False).head(10))
The unassigned count is the check that matters. A handful is normal — rights-of-way, water parcels, slivers left by the fabric. A few thousand means a predicate references an attribute value that does not appear in the data, usually a district code that is R1 in one layer and R-1 in the other.
Common Pitfalls
- Using centroid tests on irregular parcels. A crescent-shaped or L-shaped parcel can have a centroid outside its own boundary, so a centroid test can place it in a district it does not touch at all.
- Applying majority-governs to protective overlays. A parcel 40% inside a floodplain is inside the floodplain for every purpose that matters. Protective overlays want
intersects. - Letting the evaluator choose the policy. Two rules over the same district can legitimately want different policies. Storing the policy in the evaluator makes that impossible to express and invisible when it is wrong.
- Skipping the unassigned count. Rules that apply to nothing generate no findings, which is indistinguishable from a clean run until someone asks why a whole district produced no results.
- Comparing areas in a geographic CRS. Area fractions in degrees are wrong by a latitude-dependent factor. Project first — see reprojecting parcel layers to state plane.
Frequently Asked Questions
What if the ordinance genuinely does not say which policy applies?
Then the policy is an administrative practice rather than a legal one, and it should be recorded as an assumption with whoever confirmed it. Most planning departments have a consistent practice even where the code is silent; asking takes an email and settles the question permanently.
Should a split parcel get one verdict or two?
It depends on the standard. Setbacks are geometric and attach to a frontage, so they follow the district governing that frontage. Density and floor-area ratios are quantities over an area, so they are better apportioned than assigned — the treatment in apportioning split-zoned parcels by area. The rule pack should say which, per rule.
How do overlays interact with base districts?
Additively, unless the overlay says otherwise. A parcel in R-1 with a historic overlay is subject to both, and where they conflict the overlay usually governs — but “usually” is not a design, so the precedence should be explicit. Jurisdictional boundary and precedence resolution covers the general case, and routing parcels through floodplain overlay checks works one overlay end to end.
Is it faster to compute applicability once or per rule?
Once, cached by predicate rather than by rule. Rule packs reuse a small number of distinct applicability predicates across many rules — thirty districts might share four predicates — so memoising on the predicate’s serialised form typically eliminates 90% of the spatial work.
Does the borderline threshold need tuning?
Not really; five points either side of the majority threshold catches the parcels where the answer is a policy artefact, and widening it mostly adds parcels nobody will look at. What is worth tuning is what happens to them — flagging is the minimum, and routing them to review before a verdict issues is better where the standard is contentious.
How does this behave when a district layer is updated?
Applicability has to be recomputed, and the parcels whose assignment changed are the interesting output. Diffing the applicability table between two runs of the pipeline gives exactly that list, and it is usually far more useful than diffing the verdicts, because a changed assignment explains a changed verdict while the reverse is not true.
Can applicability be pushed into the database?
Yes, and for large fabrics it often should be — the overlay and the area fractions are a straightforward query, and PostGIS will use the spatial index without being asked. The trade-off is the usual one covered in choosing between in-database and in-process spatial joins: the database wins on data volume and loses on how easily the result can be reasoned about in a test.
Related
Part of: Scoping rule frameworks
- Translating ordinance text into machine-readable predicates — where these predicates come from.
- Handling edge cases in parcel boundary alignment — the fabric quality this depends on.
- Jurisdictional boundary and precedence resolution — when two authorities both claim a parcel.
- Apportioning split-zoned parcels by area — the alternative to picking a winner.
- Optimizing spatial joins for 100k parcel datasets — making the indexed pass fast.