Composing base zoning and overlay districts without losing either

The portal publishes one field, zoning, and its values look like R-1, R-1-HO, RSF1/WP, R1(A), and MU-2-TOD-HP. Your crosswalk is keyed on base codes, so R-1-HO either fails to resolve or — worse — matches a rule that strips unknown suffixes and resolves to whatever R-1 resolves to. Either way the historic overlay is gone, and the parcel’s facade requirements went with it. The overlay was never lost by a bug; it was discarded by a parser that had no concept of it. This guide splits fused codes safely, as the ingestion-side companion to overlay district modeling.

Diagnosis: finding the overlays hiding in your base codes jump to heading

Start from the distinct values, not from the records. A county with 41 000 parcels usually has fewer than 200 distinct zoning strings, which is small enough to inspect.

def audit_codes(parcels, crosswalk):
    """Every distinct code, with whether the crosswalk knows it and whether it looks
    like it has something appended."""
    import re
    rows = []
    for code, n in parcels["zoning"].value_counts().items():
        known = code in crosswalk
        # A base code is usually a letter class plus a digit. Anything after that is
        # a candidate overlay token — but "candidate" is the operative word.
        m = re.match(r"^([A-Z]{1,3}-?\d{0,2}[A-Z]?)([-/(].+)?$", code.strip().upper())
        rows.append({"code": code, "parcels": n, "in_crosswalk": known,
                     "base_guess": m.group(1) if m else None,
                     "tail": (m.group(2) or "").strip("-/()") if m else None})
    return sorted(rows, key=lambda r: (-r["parcels"]))

Three patterns show up in the output, and only the first is safely splittable by a general rule.

Five fused-looking codes, and what each one actually is A five-row by three-column matrix of zoning code strings that look fused. Rows are R-1-HO, R-1A, RSF1/WP, MU-2-TOD-HP and R1(A). Columns are what the string actually is, what a strip-after-the-first-hyphen rule produces, and what the correct handling is. R-1-HO is a base plus a registered overlay and splits correctly. R-1A is a distinct district and must not be split. MU-2-TOD-HP has one registered and one unregistered token, so it splits partially and the remainder goes to review. Five fused-looking codes, and what each one actually is what it actually is a strip-after-hyphen rule gives correct handling R-1-HO base + registered overlay R-1 + HO — correct split R-1A a distinct district R-1 + phantom "A" atomic — never split RSF1/WP base + overlay, slash separator RSF1/WP unchanged split on "/" too MU-2-TOD-HP base + 1 known + 1 unknown MU-2 + TOD-HP partial split, HP to review R1(A) district or base + overlay R1(A) unchanged jurisdiction rule decides safe needs a rule or review wrong
Only the district list settles these. A positional rule gets row one right and row two badly wrong — it tears a real district into a base plus a phantom overlay — and rows three and five differ only in punctuation, which is a per-jurisdiction convention rather than a general one.

A known base plus a known overlay tokenR-1-HO where both R-1 and HO appear elsewhere independently. This is a fused pair and splitting it is correct.

A distinct base district that merely looks fusedR-1A is very often its own district with its own standards, not R-1 plus an A overlay. Splitting it invents an overlay and assigns the wrong base.

A base plus a token nobody recognises — could be either. This is the queue, not a decision.

The distinguishing test is the ordinance’s district list, and there is no substitute for it:

def classify_tail(base, tail, district_list, overlay_registry) -> str:
    if f"{base}-{tail}" in district_list or f"{base}{tail}" in district_list:
        return "distinct_district"      # R-1A is its own district: do NOT split
    if tail in overlay_registry:
        return "known_overlay"          # safe to split
    return "unknown"                    # to review, never guessed

Step-by-step implementation jump to heading

1. Write the split as per-jurisdiction data, not as a regex jump to heading

Separator conventions differ by county — hyphen, slash, parentheses, sometimes a space — and so do the token vocabularies. A single regex tuned to one county silently mis-splits the next.

from dataclasses import dataclass, field


@dataclass(frozen=True)
class SplitRule:
    jurisdiction: str
    separators: tuple[str, ...] = ("-", "/", "(", ")")
    # Tokens that are overlays IN THIS JURISDICTION. "HO" is a historic overlay in
    # one city and a homeowner-association overlay in the next, so this is scoped.
    overlay_tokens: frozenset[str] = frozenset()
    # Codes that must never be split, however fused they look.
    atomic_codes: frozenset[str] = frozenset()
    max_overlays: int = 4


@dataclass(frozen=True)
class Split:
    base: str
    overlays: tuple[str, ...]
    unresolved: tuple[str, ...] = ()
    rule: str = ""


def split_code(raw: str, rule: SplitRule, district_list) -> Split:
    code = " ".join(raw.upper().split())

    if code in rule.atomic_codes or code in district_list:
        # The whole string is a district in its own right. This check comes FIRST,
        # which is what stops R-1A being torn into R-1 plus a phantom overlay.
        return Split(code, (), rule=f"{rule.jurisdiction}:atomic")

    tokens = [code]
    for sep in rule.separators:
        tokens = [t for part in tokens for t in part.split(sep) if t]

    if not tokens:
        return Split(code, (), rule="empty")

    base, tail = tokens[0], tokens[1:]
    if base not in district_list:
        # The leading token is not a district either. Do not guess a base.
        return Split(code, (), unresolved=tuple(tokens),
                     rule=f"{rule.jurisdiction}:base_unknown")

    overlays = tuple(t for t in tail if t in rule.overlay_tokens)
    unresolved = tuple(t for t in tail if t not in rule.overlay_tokens)

    if len(overlays) > rule.max_overlays:
        raise ValueError(f"{code}: {len(overlays)} overlays exceeds the declared "
                         f"maximum for {rule.jurisdiction}; the split rule is wrong")

    return Split(base, overlays, unresolved, rule=f"{rule.jurisdiction}:split")

2. Prove the split round-trips jump to heading

A split you cannot reverse is a split you cannot trust. Recomposing the parts and comparing against the original catches separator and ordering mistakes immediately.

def recompose(split: Split, rule: SplitRule) -> str:
    sep = rule.separators[0]
    return sep.join((split.base, *split.overlays, *split.unresolved))


def round_trips(raw: str, rule: SplitRule, district_list) -> bool:
    s = split_code(raw, rule, district_list)
    return recompose(s, rule).replace(" ", "") == " ".join(raw.upper().split()).replace(" ", "")

Round-tripping will fail legitimately where the source uses mixed separators in one string — MU-2/TOD — so treat a failure as a signal to add the separator to the rule rather than as a data error.

3. Route the unresolved tokens, do not drop them jump to heading

def ingest_zoning(raw, rule, district_list, quarantine):
    s = split_code(raw, rule, district_list)

    if s.unresolved:
        # An unrecognised token might be an overlay nobody has registered. Recording
        # the parcel with a partial split and a flag is right; silently discarding
        # the token is how a wellhead overlay disappears.
        quarantine.record(raw, reason="unresolved_token", tokens=s.unresolved,
                          partial_base=s.base, partial_overlays=s.overlays)

    return s
Distinct code coverage before and after the split rule Stacked bar chart of 186 distinct zoning code strings in one county, under three parsing approaches. Matching whole strings against the crosswalk resolves 118 and leaves 68 unresolved. Stripping after the first hyphen resolves 171 but mis-splits 9 distinct districts into phantom overlays. The per-jurisdiction split rule resolves 179 correctly, mis-splits none, and leaves 7 with unregistered tokens routed to review. Distinct code coverage before and after the split rule 0 50 100 150 200 250 68 118 whole-string match 171 strip after hyphen 179 per-jurisdiction rule Distinct zoning code strings (186) mis-split (phantom overlay) unresolved / to review resolved correctly Counts from one county; the middle bar resolves more codes and is the only one that is wrong.
The middle bar is the trap: it resolves more codes than the first and does so by inventing overlays on nine real districts. The right-hand bar resolves nearly as many again and its residue is 7 codes queued for registration rather than 9 silent errors.

4. Store both, never the fused string alone jump to heading

ALTER TABLE parcel_version
    ADD COLUMN base_code     text  NOT NULL,
    ADD COLUMN overlay_codes text[] NOT NULL DEFAULT '{}',
    ADD COLUMN raw_zoning    text  NOT NULL,      -- exactly what the portal said
    ADD COLUMN split_rule    text  NOT NULL;      -- which rule produced the split

-- Overlay codes are sorted on write so the array is comparable and hashable; an
-- unsorted array makes every re-publication look like a change.
ALTER TABLE parcel_version
    ADD CONSTRAINT overlay_codes_sorted
    CHECK (overlay_codes = (SELECT array_agg(x ORDER BY x)
                              FROM unnest(overlay_codes) AS x));

Keeping raw_zoning is what makes the split auditable and revisable: when a token turns out to be an overlay rather than part of the base, the fix is a rule change plus a reprocess, not an archaeology exercise.

Verification & testing jump to heading

LARIMER = SplitRule("larimer", overlay_tokens=frozenset({"HO", "WP", "AH", "TOD"}),
                    atomic_codes=frozenset({"R-1A", "R-2B"}))
DISTRICTS = {"R-1", "R-2", "MU-2", "R-1A", "R-2B"}


@pytest.mark.parametrize("raw,base,overlays", [
    ("R-1",            "R-1",  ()),
    ("R-1-HO",         "R-1",  ("HO",)),
    ("MU-2-TOD-HP",    "MU-2", ("TOD",)),        # HP unregistered → unresolved
    ("R-1/WP",         "R-1",  ("WP",)),
])
def test_splits(raw, base, overlays):
    s = split_code(raw, LARIMER, DISTRICTS)
    assert s.base == base and s.overlays == overlays


def test_atomic_district_is_never_split():
    s = split_code("R-1A", LARIMER, DISTRICTS)
    assert s.base == "R-1A" and s.overlays == ()   # not R-1 + phantom "A"


def test_unresolved_token_is_kept_not_dropped():
    s = split_code("MU-2-TOD-HP", LARIMER, DISTRICTS)
    assert "HP" in s.unresolved                   # surfaced for registration


def test_round_trip():
    for raw in ("R-1", "R-1-HO", "R-1A"):
        assert round_trips(raw, LARIMER, DISTRICTS)


def test_every_distinct_code_is_covered(parcels):
    """The gate that matters in production: no distinct code may be unhandled."""
    bad = [c for c in parcels["zoning"].unique()
           if split_code(c, LARIMER, DISTRICTS).unresolved]
    assert not bad, f"{len(bad)} distinct codes have unresolved tokens: {bad[:5]}"

That last test is the one to run against real data as a gate. It fails loudly when a county introduces a new overlay token, which is exactly the event you want to hear about.

Failure recovery jump to heading

A distinct district was split into a phantom overlay. The parcels now have the wrong base and an overlay that does not exist, and their resolved standards are wrong in both directions. Add the code to atomic_codes, reprocess from raw_zoning, and re-derive anything computed from the standards.

Three split failures, and whether raw_zoning saves you A three-row by three-column matrix of split failures. Rows are a distinct district split into a phantom overlay, an overlay token silently dropped by an earlier parser, and overlay arrays stored unsorted. Columns are the symptom, the repair, and whether the raw zoning string makes the repair a reprocess rather than a re-fetch. In every case keeping the raw string reduces the repair to a rule change plus a reprocess. Three split failures, and whether raw_zoning saves you symptom repair raw_zoning makes it a… District split into a phantom overlay wrong base and a fake overlay mark atomic, reprocess reprocess Overlay token silently dropped constraints missing from parcels register token, reprocess reprocess Overlay arrays stored unsorted phantom change events every run normalise + add constraint reprocess cheap noisy materially wrong
The third column is the same answer three times, which is the argument for the raw_zoning column: every one of these repairs is a rule change plus a reprocess if the original string was kept, and a re-fetch or an archaeology exercise if it was not.

An overlay token was silently dropped by an earlier parser. Reprocess from raw_zoning — which is why that column exists. If the raw string was not kept, the overlays must be recovered from the source archive, and if that is also absent, from the portal by re-fetch.

Overlay arrays stored unsorted. Every re-publication compares unequal, so the change feed is full of no-op overlay changes. Normalise the arrays in place, add the sort constraint, and re-run the change detector for the affected window to remove the phantom events.

Frequently asked questions jump to heading

Why not just strip anything after the first hyphen?

Because R-1 itself contains a hyphen, and R-1A is frequently a distinct district rather than R-1 plus an overlay. A positional rule cannot distinguish a base code’s internal punctuation from a separator, so it either splits too eagerly and invents overlays or too timidly and loses them. The district list is the only authority that settles it.

Should overlay tokens be a global list?

No, for the same reason base codes are not. HO is a historic overlay in one jurisdiction and a homeowner-association overlay in the next, and A may be an overlay in one place and part of a district name in another. The token vocabulary is scoped to the jurisdiction, exactly as the crosswalk in zoning taxonomy mapping is.

What should happen to an unrecognised token?

It should be recorded on the parcel as unresolved and surfaced for registration — never dropped and never guessed at. Dropping it silently removes a real regulatory constraint; guessing assigns standards the ordinance does not support. A partial split with a flag is honest and is usually resolved by ten minutes with the ordinance’s district list.

Why keep the raw string when the split is stored?

Because the split embodies rules that will change. When a token is reclassified from unknown to overlay, or a code turns out to be atomic, reprocessing from the raw string is a re-run; reprocessing without it is a re-fetch at best and archaeology at worst. It costs one text column per version.