How to Map Residential vs Commercial Classes in Python

Deciding whether a metered account is residential or commercial is rarely the binary lookup it looks like on a rate card. The moment a billing run touches real municipal data it hits mixed-use parcels, deprecated zoning codes, blank assessor fields, and mid-cycle property conversions — and every one of those, mishandled, silently routes an account to the wrong tariff. This page is the focused counterpart to the broader Customer Class & Service Tier Mapping workflow inside the Municipal Utility Billing Architecture & Rate Taxonomy subsystem, and it solves one specific sub-problem: given a parcel’s zoning code, its assessor use indicator, and its consumption baseline, decide residential vs commercial vs mixed-use deterministically, with a documented precedence order and a recorded reason for every decision. Get that precedence wrong — let a consumption spike override authoritative zoning — and a household with an electric vehicle gets billed as a business, an error invisible at ingestion and expensive at reconciliation.

Prerequisites

The classifier is deliberately dependency-light so it can run inline at the mapping stage. Only the following are assumed:

  • Python 3.11+ — for StrEnum and the X | None union syntax used throughout.
  • Pydantic v2 (pydantic>=2.6) — the parcel contract uses v2 field_validator semantics; v1 will not validate these models. This page assumes each record has already cleared upstream schema validation & data quality checks and focuses purely on the classification decision.
  • decimal.Decimal for the consumption ratio — never float. The kWh-per-square-foot threshold is a governed number that must compare identically across runs and Python builds.

Data assumptions: each parcel arrives with an account_id, an optional zoning_code sourced from the municipal GIS or ordinance layer, an optional primary_use_indicator from the assessor, and optional square_footage and annual_kwh (a rolling twelve-month baseline). Any of the three signal fields may be null — that is the whole reason a precedence order exists rather than a single if.

Annotated Implementation

The core rule is precedence, not heuristics: authoritative sources win, and the consumption ratio is only ever a last resort. Each classification returns the deciding source alongside the class so the audit trail can reconstruct why a parcel was mapped, never just what it was mapped to. Mixed-use is always tested before plain residential or commercial so a R-C- overlay code is never captured by the bare R- prefix.

Documented precedence order for classifying a parcel as residential, commercial, or mixed-use A parcel record enters and passes down an ordered chain of three checks. First the GIS zoning code is normalized and prefix-matched to mixed-use, residential, or commercial; a match ends the chain with the source tagged "zoning". If the zoning code is null the assessor primary-use code is tested, tagged "assessor_use". If that is also null the decimal kWh-per-square-foot ratio is compared to the governed threshold — above it reads commercial, at or below it reads residential, tagged "consumption_heuristic". If no signal exists at all the parcel resolves to UNKNOWN, tagged "fallback". Every outcome writes its class and deciding source to an immutable audit record. PRECEDENCE OUTCOME · DECIDING SOURCE AUDIT null null no signal match match decides Parcel record account_id + up to 3 signals 1 · Zoning authority normalize → prefix match 2 · Assessor use primary-use code set 3 · Consumption ratio kWh ÷ ft² · last resort 4 · Fallback no authoritative signal MIXED_USE · RESIDENTIAL · COMMERCIAL SOURCE zoning · mixed-use tested first MIXED_USE · RESIDENTIAL · COMMERCIAL SOURCE assessor_use ratio > 15.0 → COMMERCIAL · ≤ 15.0 → RESIDENTIAL SOURCE consumption_heuristic · decimal-safe UNKNOWN → fallback routing SOURCE fallback · provisional tariff + true-up Immutable audit record (class, deciding_source) written for every parcel reconstructs why a parcel was mapped, not just what

Figure: The documented precedence chain — authoritative zoning first, assessor use second, the decimal consumption heuristic only as a last resort, and an explicit UNKNOWN fallback — with every branch recording its deciding source.

from __future__ import annotations

from decimal import Decimal
from enum import StrEnum

from pydantic import BaseModel, Field, field_validator


class CustomerClass(StrEnum):
    RESIDENTIAL = "RES"
    COMMERCIAL = "COM"
    MIXED_USE = "MIX"
    UNKNOWN = "UNK"


class ParcelRecord(BaseModel):
    # Validation-first: a malformed parcel is a defect for quarantine, not a
    # classification candidate. A null signal field is fine (precedence handles
    # it); a negative area or usage is corruption and must never reach the rules.
    account_id: str = Field(..., pattern=r"^ACCT-\d{6,}$")
    zoning_code: str | None = None            # authoritative GIS / ordinance code
    primary_use_indicator: str | None = None  # assessor primary-use code
    square_footage: Decimal | None = None
    annual_kwh: Decimal | None = None         # rolling 12-month baseline

    @field_validator("square_footage")
    @classmethod
    def positive_area(cls, v: Decimal | None) -> Decimal | None:
        if v is not None and v <= 0:
            raise ValueError("square_footage must be positive when provided")
        return v

    @field_validator("annual_kwh")
    @classmethod
    def non_negative_usage(cls, v: Decimal | None) -> Decimal | None:
        if v is not None and v < 0:
            raise ValueError("annual_kwh cannot be negative")
        return v


# --- Configuration, kept out of the logic so ordinance updates are data edits ---

RESIDENTIAL_ZONING_PREFIXES = ("R-", "RS-", "RM-", "A-")
COMMERCIAL_ZONING_PREFIXES = ("C-", "I-", "B-", "M-")
MIXED_USE_ZONING_PREFIXES = ("MU-", "R-C-", "C-R-")

RESIDENTIAL_USE = {"SINGLE_FAMILY", "CONDO", "APARTMENT", "MOBILE_HOME"}
COMMERCIAL_USE = {"RETAIL", "OFFICE", "INDUSTRIAL", "WAREHOUSE", "HOSPITALITY"}
MIXED_USE_USE = {"MIXED_USE", "COMMERCIAL_RESIDENTIAL"}

# Deprecated codes are translated into the canonical scheme *before* matching.
# Never mutate the source record — build a canonical view of it instead.
LEGACY_ZONING_MAP = {"R1": "R-1", "R2": "R-2", "C1": "C-1", "MU": "MU-1"}

# Marginal usage above this kWh-per-square-foot ratio reads as commercial.
# Calibrate annually against audited baselines to stop misclassification drift.
KWH_PER_SQFT_THRESHOLD = Decimal("15.0")


def normalize_zoning(code: str) -> str:
    canonical = code.strip().upper()
    return LEGACY_ZONING_MAP.get(canonical, canonical)


def classify_parcel(record: ParcelRecord) -> tuple[CustomerClass, str]:
    """Return (class, deciding_source). Precedence is documented and ordered."""

    # 1. Zoning authority (highest precedence). Mixed-use first, always.
    if record.zoning_code:
        code = normalize_zoning(record.zoning_code)
        if code.startswith(MIXED_USE_ZONING_PREFIXES):
            return CustomerClass.MIXED_USE, "zoning"
        if code.startswith(RESIDENTIAL_ZONING_PREFIXES):
            return CustomerClass.RESIDENTIAL, "zoning"
        if code.startswith(COMMERCIAL_ZONING_PREFIXES):
            return CustomerClass.COMMERCIAL, "zoning"

    # 2. Assessor primary-use indicator (second precedence).
    if record.primary_use_indicator:
        use = record.primary_use_indicator.strip().upper()
        if use in MIXED_USE_USE:
            return CustomerClass.MIXED_USE, "assessor_use"
        if use in RESIDENTIAL_USE:
            return CustomerClass.RESIDENTIAL, "assessor_use"
        if use in COMMERCIAL_USE:
            return CustomerClass.COMMERCIAL, "assessor_use"

    # 3. Consumption heuristic (last resort). Runs only when both the baseline
    #    usage and the area are present. square_footage is validated > 0, so this
    #    division can never raise ZeroDivisionError.
    if record.annual_kwh is not None and record.square_footage is not None:
        ratio = record.annual_kwh / record.square_footage
        if ratio > KWH_PER_SQFT_THRESHOLD:
            return CustomerClass.COMMERCIAL, "consumption_heuristic"
        return CustomerClass.RESIDENTIAL, "consumption_heuristic"

    # 4. Explicit fallback — never guess when no authoritative signal exists.
    return CustomerClass.UNKNOWN, "fallback"

The returned source string (zoning, assessor_use, consumption_heuristic, fallback) is what makes the decision defensible. Write it to the immutable mapping audit record so a finance team can point to the exact rule that classified any disputed account, and calibrate the heuristic threshold on a schedule rather than letting it silently drift against real consumption. A UNKNOWN result is a first-class outcome, not a bug: it hands the parcel to Fallback Routing for Missing Rate Data with a provisional tariff and a true-up flag instead of forcing a wrong class.

Edge Cases and Billing Gotchas

Municipal parcel data produces classification failures that generic ETL never anticipates. These are the four that most often turn a “clean” mapping into a wrong bill:

  • Mixed-use parcels. Ground-floor retail with apartments above carries both residential and commercial signals. Testing the mixed-use prefixes and use codes first keeps a R-C- overlay from being swallowed by the plain R- residential branch. A genuinely mixed parcel should not be forced to a single class — route it to a dedicated mixed-use tariff, or split it into sub-accounts, and record the decision. Never let the first non-null signal quietly win a parcel that is really both.
  • Legacy zoning codes. Older CIS and assessor exports still carry deprecated codes like R1 where the current scheme uses R-1. normalize_zoning translates them through LEGACY_ZONING_MAP into a canonical view before prefix matching, and it does so without mutating the source record. Extend the map as you discover retired codes rather than widening the prefix tuples, which would blur the distinction between historical and current schemes.
  • Consumption anomalies masquerading as commercial. Electric-vehicle charging, a pool pump, or a home crypto rig can push a residential parcel’s kWh-per-square-foot ratio over the commercial threshold. Because the consumption heuristic runs only when both authoritative sources are silent, a parcel with a residential zoning code is safe — its usage never reaches the ratio branch. When you do land in the heuristic, use the rolling twelve-month annual_kwh baseline (not a single spiky month) and log any parcel classified commercial purely by consumption for spot review.
  • Missing all three signals. A parcel with null zoning_code, null primary_use_indicator, and no consumption baseline resolves to UNKNOWN rather than defaulting to residential by omission. Defaulting silently is exactly the failure this precedence chain exists to prevent — an explicit UNKNOWN keeps the choice visible and routes it to fallback for a flagged, trued-up provisional bill.

Verification Snippet

Lock the precedence order in place with assertions against fixtures that encode each branch. The decisive tests are the conflict cases: a residential zoning code must beat a commercial-looking consumption ratio, and mixed-use must beat plain residential.

from decimal import Decimal


def test_zoning_beats_consumption() -> None:
    # A home with heavy EV load must stay residential — zoning outranks usage.
    rec = ParcelRecord(
        account_id="ACCT-100200",
        zoning_code="R-1",
        annual_kwh=Decimal("48000"),
        square_footage=Decimal("2000"),  # ratio 24 > 15, but zoning wins
    )
    assert classify_parcel(rec) == (CustomerClass.RESIDENTIAL, "zoning")


def test_mixed_use_prefix_not_captured_by_residential() -> None:
    rec = ParcelRecord(account_id="ACCT-100201", zoning_code="R-C-2")
    assert classify_parcel(rec) == (CustomerClass.MIXED_USE, "zoning")


def test_legacy_code_normalized_before_match() -> None:
    rec = ParcelRecord(account_id="ACCT-100202", zoning_code="c1")
    assert classify_parcel(rec) == (CustomerClass.COMMERCIAL, "zoning")


def test_heuristic_only_when_authoritative_silent() -> None:
    rec = ParcelRecord(
        account_id="ACCT-100203",
        annual_kwh=Decimal("40000"),
        square_footage=Decimal("2000"),  # ratio 20 > 15
    )
    assert classify_parcel(rec) == (CustomerClass.COMMERCIAL, "consumption_heuristic")


def test_no_signal_falls_back_to_unknown() -> None:
    rec = ParcelRecord(account_id="ACCT-100204")
    assert classify_parcel(rec) == (CustomerClass.UNKNOWN, "fallback")

Run this suite against a replay of a historical billing window and confirm the classifier reproduces the class distribution the billing team recorded — a swing of more than a few percent in any class between cycles signals that an ordinance refresh or a GIS update changed the authoritative signals underneath you.

Frequently Asked Questions

Why not let unusually high consumption override the zoning code?

Because zoning is authoritative and consumption is circumstantial. A residence with an electric vehicle, a pool, or a workshop can post commercial-scale usage while remaining, legally and for rate purposes, residential. The precedence order runs the consumption heuristic only when both the zoning code and the assessor use indicator are null, so an authoritative signal can never be overridden by a usage spike. When the heuristic does decide a class, that decision is tagged consumption_heuristic in the audit record precisely so it can be reviewed.

How do I handle a parcel that is genuinely both residential and commercial?

Do not force it to one class. Test the mixed-use prefixes and use codes first so a MU-, R-C-, or C-R- parcel resolves to MIXED_USE, then either route that account to a dedicated mixed-use tariff or split it into residential and commercial sub-accounts metered or apportioned separately. Record the choice on the audit record; a mixed-use parcel silently billed as single-class is one of the most common sources of ratepayer disputes.

What should happen when a parcel has no zoning code, no use indicator, and no consumption history?

It resolves to UNKNOWN, which is a deliberate outcome rather than an error. UNKNOWN parcels are handed to fallback routing, which applies the jurisdiction’s default tariff, flags the account for true-up, and keeps the billing cycle on schedule. Defaulting a signal-less parcel straight to residential would bury the gap; an explicit UNKNOWN keeps it visible and correctable.

How do I keep deprecated zoning codes from misclassifying old accounts?

Normalize before you match. normalize_zoning maps retired codes such as R1 or MU to their canonical R-1 / MU-1 forms through an explicit lookup table, then applies the prefix rules to the canonical value — without ever mutating the stored record. Extend the lookup table as you find more legacy codes rather than loosening the prefix tuples, which would erase the line between historical and current schemes.

Where does class classification sit relative to rate calculation and assistance eligibility?

It runs before both. The resolved class selects the tariff parameters described in Step-Rate vs Block-Rate Structure Design, and it gates the Assistance Program Eligibility Taxonomy — hardship and low-income rates apply only to residential accounts, so a commercial parcel misclassified as residential could wrongly qualify for a subsidy. Because class drives money and eligibility downstream, who may edit the prefix tables and thresholds is itself governed by Security Boundaries & Role-Based Access.

Up: Customer Class & Service Tier Mapping · Municipal Utility Billing Architecture & Rate Taxonomy