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.

Two kinds of applicability test, evaluated in orderAttribute tests read a column and are unambiguous; spatial tests compare two geometries and need a stated policy before they mean anything.One applicability predicatea tree of clauses over attributes and layersAttribute clausesvectorized column comparisons; no geometrySpatial clausesmeaningless until "in" is definedApplies, with the fraction that decided itevidence, not a boolean
Attribute tests are cheap and 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.

Four definitions of "in the district", on the same parcelCentroid, intersects, contains and majority policies compared by what they do to an irregular parcel and to a protective overlay.Base districtProtective overlayCentroidFails on concave parcelsMisses parcels partly insideIntersectsAssigns a parcel to severalCorrect — any part is insideContainsLeaves boundary parcels unassignedFar too narrowMajorityThe usual default; exactly oneWrong — 40% in is still in
The policy belongs beside the rule, because two rules over one district can legitimately want different ones.

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")
Where the governing area fraction actually fallsDistribution of the fraction of a parcel inside its governing district on a representative county fabric, showing the thin population near the threshold.Wholly inside one district (>0.99)96.4% of parcelsClearly governed (0.55–0.99)2.9% of parcelsDecided by policy (0.45–0.55)0.6% of parcelsMatched no district at all0.1% of parcelsIllustrative shape. A fat middle band means the parcel and district layers are not aligned, which is a topology problem rather than a policy one.
Parcels within five points of the threshold were decided by the policy, not by the ordinance — and they are the ones worth surfacing every run.

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.

Part of: Scoping rule frameworks