Resolving one address to many parcels in condominium plats

The dashboard says 1 842 permits were issued in the downtown district last quarter. The planning department says 61. The difference is one tower: a 140-unit condominium plat where every unit is a separate parcel sharing one street address, so a single re-roofing permit matched all 140 parcels and was counted 140 times. The same structure breaks the other direction too — a zoning question about the tower returns 140 rows with identical answers, and someone “simplifies” it by taking the first. Neither behaviour is a matching bug; both come from treating a one-to-many relationship as a failed one-to-one. This guide models it properly, extending the ambiguity handling in address geocoding & parcel matching.

Diagnosis: recognising a plat rather than a match failure jump to heading

A condominium plat has a distinctive signature, and distinguishing it from a genuine ambiguity matters because the handling is different.

Many parcels, one address, near-identical geometry. Unit parcels in a vertical plat frequently share the same footprint polygon, or carry no footprint at all with only the common element mapped. Two parcels with the same address and the same geometry are not competing candidates; they are stacked interests in one building.

Telling a plat from a genuine ambiguity A five-row by three-column matrix of signals. Rows are the candidate count, whether zoning agrees across candidates, whether the candidates share an identifier prefix, whether their geometry is identical, and whether a unit number column is present. Columns describe what a condominium plat looks like, what an address collision looks like, and what the signal proves. Zoning agreement is the discriminating signal: plat members share one district by construction, so a candidate set with differing codes is a collision rather than a plat. Telling a plat from a genuine ambiguity condominium plat address collision what it proves Candidate count often 20–200 usually 2 suggestive only Zoning agrees across candidates always usually not discriminating Shared identifier prefix usually no strong Identical geometry common in towers no strong Unit number column present usually no strong expected for a plat neutral points at a collision
Zoning agreement is the discriminator. Plat members sit in one district by construction, so a candidate set that disagrees on zoning is two separate properties sharing an address string — which needs a tie-break, not a grouping.

Identical zoning across the set. All units in a plat sit in one district by construction. If the candidate set disagrees on zoning, it is not a plat — it is a real ambiguity, probably a split parcel or an address collision.

Unit designators in the parcel data. 0714-22-1-101 through 0714-22-1-240, or a unit_no column. A shared prefix across the candidate set is the strongest single indicator.

def looks_like_plat(candidates, parcels_gdf) -> bool:
    """Distinguish 'one building, many interests' from 'genuinely ambiguous'."""
    if len(candidates) < 3:
        return False
    rows = parcels_gdf.loc[candidates]

    if rows["zoning_code"].nunique() > 1:
        return False                     # a plat shares one district by construction

    prefixes = {pid.rsplit("-", 1)[0] for pid in candidates}
    if len(prefixes) == 1:
        return True                      # 0714-22-1-101 … -240

    # Vertical plats often share one footprint, or have none at all.
    areas = rows.geometry.area.round(2)
    return areas.nunique() == 1 or rows.geometry.is_empty.all()

Step-by-step implementation jump to heading

1. Model the plat as a first-class entity jump to heading

The fix is a layer of indirection: matches resolve to a plat, and the plat has members. Without it, every consumer has to re-derive the grouping and they will each do it differently.

CREATE TABLE plat (
    plat_id        text PRIMARY KEY,        -- 0714-22-1
    source_id      text NOT NULL,
    street_key     text NOT NULL,           -- the shared address
    kind           text NOT NULL,           -- 'condominium' | 'townhouse' | 'range'
    common_geom    geometry(MultiPolygon, 26913),
    member_count   integer NOT NULL,
    zoning_code    text NOT NULL,           -- shared by construction; asserted below
    CONSTRAINT plat_members_positive CHECK (member_count > 0)
);

CREATE TABLE plat_member (
    plat_id   text NOT NULL REFERENCES plat(plat_id),
    parcel_id text NOT NULL,
    unit_no   text,
    PRIMARY KEY (plat_id, parcel_id)
);

-- The assertion that makes plat.zoning_code trustworthy: no plat may contain
-- members with differing zoning. If this fires, it was never a plat.
CREATE OR REPLACE VIEW plat_zoning_conflict AS
SELECT m.plat_id, count(DISTINCT p.zoning_code) AS codes
  FROM plat_member m JOIN parcel_current p USING (parcel_id)
 GROUP BY m.plat_id HAVING count(DISTINCT p.zoning_code) > 1;

2. Resolve a match to the plat, and say so jump to heading

def resolve_candidates(candidates, parcels_gdf, plats):
    if len(candidates) == 1:
        return Match(candidates[0], "confirmed", evidence="single parcel")

    plat_id = plats.plat_containing_all(candidates)
    if plat_id is not None:
        # NOT ambiguous. The address genuinely identifies this plat, and the plat
        # is the correct unit of answer for anything the units share.
        return Match(None, "confirmed", evidence=f"plat:{plat_id}",
                     plat_id=plat_id, members=candidates)

    if looks_like_plat(candidates, parcels_gdf):
        return Match(None, "review", evidence="plat-shaped but unregistered",
                     candidates=candidates)

    return Match(None, "ambiguous", evidence=f"{len(candidates)} unrelated candidates",
                 candidates=candidates)

3. Answer at the level the question is asked jump to heading

This is where the inflation is actually prevented. A plat match is not a licence to fan out; each consuming question has a correct cardinality.

One plat match, four questions, four cardinalities A four-row by three-column matrix of consuming questions against how a plat match should be answered. Rows are zoning, permit count, development capacity and ownership. Columns are the correct cardinality, what a naive fan-out to members produces, and the size of the error for a 140-unit plat. Zoning has one answer and a fan-out returns 140 identical rows. A permit count is one and a fan-out returns 140. Capacity belongs to the common element and a fan-out over-counts by 140 times. Ownership is the single question that is genuinely per member. One plat match, four questions, four cardinalities correct cardinality what a fan-out gives error on a 140-unit plat Zoning designation one, shared 140 identical rows noise, not error Permit count one 140 140× over-count Development capacity one, common element 140 × the whole site 140× over-count Ownership 140, per member 140 — correct here none correct harmless but wasteful materially wrong
Three of the four questions have one answer and the fourth has 140. A single fan-out rule cannot serve them, which is why the resolver refuses to answer a question whose cardinality has not been declared — that refusal is what keeps the 140× inflation from coming back.
def answer(question: str, match: Match, store):
    """One plat match, four different correct answers."""
    if question == "zoning":
        # Shared by construction, and asserted by the conflict view. One answer.
        return store.plat_zoning(match.plat_id)

    if question == "permit_count":
        # The permit applies ONCE to the building. Fanning out to members is the
        # 140x inflation this whole page exists to prevent.
        return 1

    if question == "capacity":
        # Development capacity is a property of the land, so it belongs to the
        # common element — not to each unit, and not to their sum.
        return store.plat_capacity(match.plat_id)

    if question == "ownership":
        # The one question that genuinely IS per member.
        return store.members_with_owners(match.plat_id)

    raise ValueError(f"no cardinality rule for {question!r}; add one before answering")

The ValueError matters. A new consuming question without a declared cardinality is exactly how the inflation returns, so the code refuses rather than defaulting to a fan-out.

4. Build the plat registry from the data you have jump to heading

def discover_plats(parcels_gdf, min_members=3):
    """Group by (street_key, zoning_code) and promote plat-shaped groups. Run this
    as a periodic job; new plats are recorded continuously."""
    found = []
    grouped = parcels_gdf.groupby(["addr_key", "zoning_code"]).groups
    for (addr_key, zoning), idx in grouped.items():
        members = list(idx)
        if len(members) < min_members:
            continue
        if not looks_like_plat(members, parcels_gdf):
            continue
        prefixes = {pid.rsplit("-", 1)[0] for pid in members}
        found.append({
            "plat_id": next(iter(prefixes)) if len(prefixes) == 1 else f"plat:{addr_key}",
            "street_key": addr_key,
            "zoning_code": zoning,
            "members": members,
            "kind": "condominium" if parcels_gdf.loc[members].geometry.area.nunique() == 1
                    else "townhouse",
        })
    return found

Verification & testing jump to heading

def test_permit_on_a_plat_counts_once(store, plats):
    m = resolve_candidates(unit_parcels(140), gdf, plats)
    assert m.tier == "confirmed" and m.plat_id is not None
    assert answer("permit_count", m, store) == 1


def test_zoning_question_returns_one_answer(store, plats):
    m = resolve_candidates(unit_parcels(140), gdf, plats)
    assert isinstance(answer("zoning", m, store), str)      # not a list of 140


def test_a_plat_with_mixed_zoning_is_not_a_plat(gdf, plats):
    mixed = two_parcels_with_codes("R-1", "C-1")
    m = resolve_candidates(mixed, gdf, plats)
    assert m.tier == "ambiguous"          # a real ambiguity, not a plat


def test_unregistered_plat_goes_to_review_not_ambiguous(gdf, plats_empty):
    m = resolve_candidates(unit_parcels(140), gdf, plats_empty)
    assert m.tier == "review" and len(m.candidates) == 140


def test_new_question_without_a_cardinality_rule_raises(store, plats):
    m = resolve_candidates(unit_parcels(140), gdf, plats)
    with pytest.raises(ValueError):
        answer("assessed_value", m, store)

For a standing production check, compare permit counts per district against the planning department’s own published totals. A ratio near one is healthy; a ratio of thirty is one tower.

Failure recovery jump to heading

Counts already inflated in published reports. Recompute with plat-aware cardinality and publish the corrected figures alongside the originals, naming the cause. The correction will be large and concentrated on a handful of buildings, which makes it easy to explain and easy to verify.

District permit counts, before and after plat-aware counting Grouped bar chart of quarterly permit counts across four districts, comparing a naive count that fans out to plat members against a plat-aware count, with the planning department's published figure as the reference. In the downtown district the naive count is 1 842 against a true 61. The other three districts differ by only a few permits, because they contain few or no condominium plats. The error is concentrated entirely where the plats are. District permit counts, before and after plat-aware counting 0 500 1000 1500 2000 2500 1842 61 61 downtown 96 94 94 north ridge 141 138 138 riverside 88 88 88 west end Permits issued in the quarter naive fan-out count plat-aware count planning dept published The plat-aware count matches the published figure in all four districts.
The error is concentrated, not spread: three districts are within a handful of permits and downtown is out by a factor of thirty because of a few towers. That concentration is what makes the corrected figures easy to explain — and what makes the original error so easy to miss in a site-wide average.

A plat registered that is not one. The plat_zoning_conflict view finds these: members with differing zoning were never a plat. Dissolve the registration and re-resolve those matches as genuine ambiguities.

Units matched individually before the plat existed. Those matches are not wrong — a specific unit is a real parcel — but they are inconsistent with plat-level matches made later. Re-resolve the affected records so the same address produces the same cardinality regardless of when it was processed.

Frequently asked questions jump to heading

Why not just pick the first unit parcel?

Because it produces an answer that is right by accident for shared attributes and wrong for everything else. Zoning is identical across the plat so picking any unit works — until somebody asks about ownership, assessed value, or unit count, where unit 101 is not representative. The indirection costs one table and removes the whole class of “which unit did we pick?” questions.

Is a plat match ambiguous or confirmed?

Confirmed, provided the plat is registered. The address genuinely and unambiguously identifies that plat; what was ambiguous was only the attempt to force it onto one member. Reporting it as ambiguous pushes a resolved case into a review queue and trains reviewers to ignore the queue.

How do I tell a condominium plat from an address collision?

Zoning agreement plus a shared identifier prefix or shared geometry. Members of a plat sit in one district by construction, so a candidate set with differing zoning codes is a collision — two separate properties that happen to share an address string — and it needs a real tie-break rather than a grouping.

What about townhouse plats where each unit has its own footprint?

Same indirection, different kind. Footprints differ, so geometry-based questions are genuinely per member, while zoning and the plat’s common elements remain shared. Recording the kind is what lets the cardinality rules distinguish “shared by construction” from “shared only in this instance.”