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.
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.
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.
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.”
Related jump to heading
- Parent topic: Address Geocoding & Parcel Matching
- Section overview: Automated Feed Ingestion & GIS Data Parsing
- Matching permit addresses to parcels with fuzzy and spatial joins — where the tied candidate set comes from
- Municipal Data Structures — modelling entities that own parcels rather than being them
- Development Capacity & Buildable Area — why capacity belongs to the common element, not to the sum of units