Customer Class & Service Tier Mapping in Municipal Utility Billing
The single most consequential decision a billing pipeline makes about an account happens before a rate is ever applied: what class is this, and which service tier governs it. This guide sits under Municipal Utility Billing Architecture & Rate Taxonomy and addresses one specific production failure — the silent misclassification that routes a metered account to the wrong tariff. When a mixed-use parcel is billed as residential, when an industrial account lands on a standard tier, or when a deprecated schedule reference slips through, the error is invisible at ingestion and expensive at reconciliation. Misaligned classifications cascade into rate miscalculations, compliance violations, and systematic revenue leakage across an entire billing run.
Class and tier mapping is the translation matrix between physical service delivery and the financial ledger. It converts raw service attributes — meter type, parcel zoning, demand profile, jurisdiction — into a deterministic billing path so that every account resolves to exactly one tariff, surcharge set, and assistance eligibility state. The remedy for misclassification is engineering discipline: strict schema validation at ingestion, a documented precedence hierarchy for conflicting attributes, decimal-safe tier-boundary math, deliberate fallback for missing data, and an immutable audit trail that lets a finance team reconstruct why any account was mapped the way it was.
Figure: Classification routing — service attributes resolve to a deterministic billing path before the calculation engine runs.
Prerequisites
This workflow targets a modern, reproducible Python stack. Pin the following before implementing anything below:
- Python 3.11+ — required for
zoneinfoin the standard library,StrEnum, andX | Noneunion syntax used throughout. - Pydantic v2 (
pydantic>=2.6) — every class-tier contract uses v2model_validator/field_validatorsemantics; v1 models will not validate these structures. decimal.Decimalfor all monetary and threshold arithmetic — neverfloat. Tier boundaries and demand thresholds are compared and quantized asDecimalto keep classification reproducible.zoneinfo(notpytz) — effective dates and mid-cycle reclassifications are resolved in the utility’s local civil time, so every date comparison must be timezone-aware.
Data assumptions: each incoming account record carries an account_id, a raw customer_class and service_tier (from the customer information system, or CIS), a service_address used for jurisdiction resolution, and a rate_schedule reference. Authoritative parcel and zoning attributes arrive from the municipal GIS layer and take precedence over heuristic consumption thresholds. Upstream, records should already have passed schema validation & data quality checks so that this stage handles classification logic, not malformed payloads.
Access permissions matter here: the class-to-tier mapping table and the tier thresholds it references are rate configuration, not billing execution. Write access to those tables must be isolated from the code that runs a billing cycle. See Security Boundaries & Role-Based Access for the separation-of-duties pattern that gates who may edit tier thresholds or approve schedule overrides.
Architecture Overview
Class and tier mapping is a staged pipeline, not a single lookup. Each stage has an explicit exception path so that an ambiguous parcel or a missing schedule is diverted rather than allowed to corrupt the run. A raw account record enters from the CIS, its class is resolved against a documented precedence hierarchy, the class-tier pair is validated for compatibility, the pair is resolved to a point-in-time rate schedule, and every decision emits an audit record before the mapped account is handed to the calculation engine.
The sections below implement each stage in order.
Step-by-Step Implementation
Step 1 — Enforce a strict class-tier contract at ingestion
Municipal account data arrives from disparate systems: the CIS, GIS parcel layers, assessor metadata, and legacy zoning codes. Without strict typing, a malformed class-tier pairing cascades into incorrect statements and audit failures. Pydantic v2 enforces structural integrity before any routing occurs. The contract captures the account identifier, customer class, service tier, and a rate schedule reference — and it rejects a schedule whose identifier does not match its class prefix, which is the most common data-entry error in a mapping table.
from datetime import date
from decimal import Decimal
from enum import StrEnum
from pydantic import BaseModel, ConfigDict, model_validator
class CustomerClass(StrEnum):
RESIDENTIAL = "RES"
COMMERCIAL = "COM"
INDUSTRIAL = "IND"
MUNICIPAL = "MUN"
AGRICULTURAL = "AGR"
class ServiceTier(StrEnum):
STANDARD = "STD"
HIGH_DEMAND = "HIGH"
CRITICAL = "CRIT"
class RateScheduleRef(BaseModel):
model_config = ConfigDict(frozen=True) # schedule references are immutable snapshots
schedule_id: str
effective_date: date
jurisdiction_code: str
class AccountMapping(BaseModel):
account_id: str
customer_class: CustomerClass
service_tier: ServiceTier
rate_schedule: RateScheduleRef
@model_validator(mode="after")
def schedule_prefix_matches_class(self) -> "AccountMapping":
# A schedule ID must begin with its class code (e.g. "RES-TIER2-2026").
# A mismatch means the mapping table paired an account with the wrong tariff.
if not self.rate_schedule.schedule_id.startswith(self.customer_class.value):
raise ValueError(
f"schedule {self.rate_schedule.schedule_id!r} is incompatible "
f"with class {self.customer_class.value!r}"
)
return self
Note the model is now purely structural: validation raises on hard errors and does not mutate a mapping_status field. Compatibility policy (an industrial account on a standard tier is suspicious but not fatal) belongs in a separate resolution step, covered next, so the contract stays a pure gate.
Step 2 — Resolve the class by documented precedence
Hardcoded if/elif chains degrade the moment a zoning ordinance changes. Production class resolution uses an explicit precedence hierarchy: authoritative GIS parcel classification wins, a municipal ordinance code is the next fallback, and only when both are absent does the engine fall back to a consumption-baseline heuristic. This ordering is itself auditable data, not buried logic, so a finance team can point to the exact rule that classified a disputed account. The full mixed-use and legacy-zoning treatment lives in How to Map Residential vs Commercial Classes in Python; the resolver below shows the precedence spine.
from dataclasses import dataclass
@dataclass(frozen=True)
class ClassSignals:
gis_parcel_class: CustomerClass | None
ordinance_class: CustomerClass | None
annual_kwh: Decimal | None # consumption baseline, used only as last resort
# Ordered precedence: the first non-null source wins. Documented, not implicit.
def resolve_class(signals: ClassSignals) -> tuple[CustomerClass, str]:
if signals.gis_parcel_class is not None:
return signals.gis_parcel_class, "gis_parcel"
if signals.ordinance_class is not None:
return signals.ordinance_class, "ordinance_code"
if signals.annual_kwh is not None:
# Heuristic threshold only when authoritative sources are silent.
cls = (
CustomerClass.COMMERCIAL
if signals.annual_kwh > Decimal("40000")
else CustomerClass.RESIDENTIAL
)
return cls, "consumption_heuristic"
raise LookupError("no class signal available; route to fallback")
# The returned reason string ("gis_parcel", "ordinance_code", …) is written to the
# audit record so every classification is traceable to its authoritative source.
Step 3 — Validate class-tier compatibility as reviewable policy
Some class-tier pairings are structurally valid yet operationally wrong. An industrial account on the standard tier, or a residential account flagged critical-infrastructure, signals a misconfiguration that should be flagged for human review rather than silently billed or hard-rejected. Encoding this as a compatibility policy keeps the decision explicit and reversible.
from enum import StrEnum
class MappingStatus(StrEnum):
VALIDATED = "VALIDATED"
FLAGGED_REVIEW = "FLAGGED_REVIEW"
# Pairings that are legal but suspicious. Each entry is a policy decision a
# finance lead can review, not a rule hidden in a validator.
SUSPECT_PAIRS: frozenset[tuple[CustomerClass, ServiceTier]] = frozenset(
{
(CustomerClass.INDUSTRIAL, ServiceTier.STANDARD),
(CustomerClass.RESIDENTIAL, ServiceTier.CRITICAL),
}
)
def classify_status(mapping: AccountMapping) -> MappingStatus:
pair = (mapping.customer_class, mapping.service_tier)
return MappingStatus.FLAGGED_REVIEW if pair in SUSPECT_PAIRS else MappingStatus.VALIDATED
A FLAGGED_REVIEW account still flows through the cycle on its mapped schedule — the flag routes a copy to a review queue so revenue is not held up while a human confirms the pairing.
Step 4 — Resolve the class-tier pair to a point-in-time rate schedule
Once class and tier are settled, the pair must resolve to a specific schedule for the service date. This is the junction where mapping feeds the calculation engine, and where the difference between marginal and cliff pricing matters. Whether a municipality uses progressive consumption pricing or flat-tiered demand charges, the class-tier pair selects the parameters described in Step-Rate vs Block-Rate Structure Design. A residential account mapped to a conservation block resolves to different tier boundaries than a commercial account routed to a step-rate industrial tariff. Resolution is point-in-time: the schedule in force on the account’s service_date, in its jurisdiction, is selected — never simply “the latest.”
from zoneinfo import ZoneInfo
UTILITY_TZ = ZoneInfo("America/Denver")
def resolve_schedule(
customer_class: CustomerClass,
service_tier: ServiceTier,
service_date: date,
jurisdiction_code: str,
catalog: list[RateScheduleRef],
) -> RateScheduleRef:
# Candidate schedules for this class prefix and jurisdiction, effective on or
# before the service date; pick the most recent one still in force.
prefix = customer_class.value
candidates = [
s
for s in catalog
if s.schedule_id.startswith(prefix)
and s.jurisdiction_code == jurisdiction_code
and s.effective_date <= service_date
]
if not candidates:
# No schedule in force: defer to deterministic fallback (Step 5).
raise LookupError(f"no {prefix} schedule for {jurisdiction_code} on {service_date}")
return max(candidates, key=lambda s: s.effective_date)
Step 5 — Route missing data through deterministic fallback
Real-world datasets contain gaps. When an account lacks a class signal or references a deprecated schedule, the pipeline must apply deliberate fallback logic rather than rejecting the transaction. Implementing Fallback Routing for Missing Rate Data applies the jurisdiction’s default tariff, tags the account for true-up, and keeps the cycle on schedule during migrations, legacy imports, or temporary CIS outages — while preserving a clear trail for later reconciliation. The classification engine must also evaluate each account against the Assistance Program Eligibility Taxonomy at this stage so low-income discounts or medical life-support exemptions are applied without exposing protected financial or health data.
def map_account(record: dict, catalog: list[RateScheduleRef], signals: ClassSignals) -> dict:
try:
customer_class, class_source = resolve_class(signals)
except LookupError:
# No authoritative or heuristic signal: default class for the jurisdiction.
customer_class, class_source = CustomerClass.RESIDENTIAL, "fallback_default"
tier = ServiceTier(record["service_tier"])
try:
schedule = resolve_schedule(
customer_class, tier, record["service_date"], record["jurisdiction_code"], catalog
)
outcome = "MAPPED"
except LookupError:
schedule = default_schedule(customer_class, record["jurisdiction_code"])
outcome = "FALLBACK" # provisional; flagged for true-up
mapping = AccountMapping(
account_id=record["account_id"],
customer_class=customer_class,
service_tier=tier,
rate_schedule=schedule,
)
status = classify_status(mapping)
return {
"mapping": mapping,
"class_source": class_source,
"outcome": outcome,
"status": status,
}
Edge-Case Handling
Municipal utility data produces classification edge cases that generic ETL never anticipates. Handle each one deliberately:
- Mixed-use parcels. A storefront with an apartment above it carries both residential and commercial signals. Precedence alone is not enough; the parcel must be split into sub-accounts or routed to a dedicated mixed-use tariff, and the decision recorded. Never let the first non-null signal silently win a genuinely mixed parcel.
- Mid-cycle reclassification. When a property converts (a residence becomes a rental office on the 14th), consumption before and after the conversion date belongs to different classes. Split the cycle at the conversion boundary in
UTILITY_TZand map each segment independently, exactly as a mid-month rate change is handled — never re-map the whole cycle to the new class. - Deprecated schedule references. A CIS record may point at a schedule that was retired. Because
resolve_schedulefilters oneffective_date <= service_date, a retired schedule with a future retirement is still valid for past service dates; a truly missing schedule falls through to Step 5’s default rather than raising into the batch. - Retroactive PUC orders. A commission order effective the first of a prior month re-prices already-mapped accounts. Point-in-time resolution makes this tractable: re-run mapping with the new schedule’s
effective_dateand produce an adjustment, rather than editing historical records in place. - Leap-year and DST boundaries. Because all date logic uses
zoneinfoand civil dates rather than naive offsets, February 29 and the spring-forward hour resolve correctly; a class effective on a DST-transition day still selects the right schedule. - Null or zero consumption on heuristic paths. When class resolution reaches the consumption heuristic and
annual_kwhisNoneor zero, do not default to residential by omission — treat it as a missing signal and route to fallback so the choice is explicit and auditable.
Verification and Audit Trail
A mapping decision is only defensible if a finance team can reconstruct why it was made. Every mapped account emits an immutable audit record, and records are chained by hash so any later alteration is detectable. The chain records the resolved class, its source (gis_parcel, ordinance_code, consumption_heuristic, or fallback_default), the schedule chosen, and the compatibility status.
import hashlib
import json
def audit_record(prev_hash: str, result: dict) -> dict:
mapping = result["mapping"]
payload = {
"account_id": mapping.account_id,
"customer_class": mapping.customer_class.value,
"service_tier": mapping.service_tier.value,
"schedule_id": mapping.rate_schedule.schedule_id,
"class_source": result["class_source"],
"outcome": result["outcome"],
"status": result["status"].value,
"prev_hash": prev_hash,
}
# Canonical JSON (sorted keys) so the digest is reproducible across runs.
encoded = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
payload["record_hash"] = hashlib.sha256(encoded).hexdigest()
return payload
def verify_chain(records: list[dict]) -> bool:
prev = "GENESIS"
for rec in records:
recomputed = audit_record(prev, {
"mapping": _rehydrate(rec), # reconstruct AccountMapping from stored fields
"class_source": rec["class_source"],
"outcome": rec["outcome"],
"status": MappingStatus(rec["status"]),
})
if recomputed["record_hash"] != rec["record_hash"]:
return False
prev = rec["record_hash"]
return True
To confirm correctness before a production run, replay the mapper against a fixture of historical accounts with known-good classifications and assert the class distribution matches the prior cycle within tolerance:
def test_class_distribution_stable(historical: list[dict], mapped: list[dict]) -> None:
from collections import Counter
expected = Counter(r["customer_class"] for r in historical)
actual = Counter(m["mapping"].customer_class.value for m in mapped)
for cls, count in expected.items():
# A >5% swing in any class between cycles signals a mapping regression.
assert abs(actual[cls] - count) <= count * 0.05, f"class {cls} drifted"
At month-end, mapped accounts feed batch reconciliation and ledger synchronization. Automated scripts compare mapped class distributions against general-ledger postings, flagging variances before final invoice generation so multi-jurisdictional taxes, franchise fees, and special-assessment charges land on the correct service class. All mapping decisions must remain auditable under Data Governance & Privacy Compliance, which mandates cryptographic hashing of the snapshots that satisfy municipal audit and state retention requirements.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
ValueError: schedule incompatible with class at ingestion |
Mapping table paired an account with a schedule whose ID prefix does not match its class | Correct the schedule reference in the CIS export, or fix the mapping table entry; the Step 1 contract is working as intended |
| Whole batch aborts on one bad record | Class resolution or schedule lookup raised into the batch loop instead of being caught | Wrap each account in map_account’s try/except so failures divert to fallback, never halt the run |
| Industrial accounts billed at standard rates | Compatibility policy not consulted, or SUSPECT_PAIRS missing the pairing |
Run classify_status on every mapping and route FLAGGED_REVIEW to the review queue |
| Class distribution swings between cycles | An ordinance or GIS refresh changed authoritative signals mid-run | Compare class_source counts across cycles; a jump in consumption_heuristic means authoritative sources went silent |
| Retired schedule still applied | Resolution picked “latest” instead of point-in-time | Confirm resolve_schedule filters on effective_date <= service_date and selects the max by effective date |
Audit chain fails verify_chain |
A historical record was edited in place | Records are append-only; reverse the edit and post a compensating adjustment instead |
| Mixed-use parcel billed as single class | Precedence let the first non-null signal win a genuinely mixed parcel | Detect dual signals and split into sub-accounts or route to the mixed-use tariff |
FAQ
Why should class-tier compatibility be a flag rather than a hard validation error?
Because the pairing is legal but merely suspicious. An industrial account on the standard tier may be a genuine small-load site or a data-entry mistake. Hard-rejecting it stalls a bill for a possibly-correct account; silently billing it hides a possibly-wrong one. Flagging routes a copy to human review while the cycle proceeds, which is the only option that neither delays revenue nor buries an error.
What determines which class wins when GIS, ordinance, and consumption disagree?
A documented precedence hierarchy, applied in order: authoritative GIS parcel classification first, municipal ordinance code second, consumption baseline heuristic only as a last resort. The winning source is recorded on the audit record, so a disputed classification can always be traced to the exact rule and data source that produced it.
How do I reclassify an account that converts partway through a billing cycle?
Split the cycle at the conversion date in the utility’s local timezone and map each segment independently — pre-conversion usage under the old class, post-conversion under the new one. This mirrors mid-cycle rate handling and avoids re-pricing usage that was correctly classified before the conversion.
What happens when an account references a rate schedule that no longer exists?
Point-in-time resolution first tries to find the schedule in force on the service date. If none exists, the account is routed through fallback to the jurisdiction’s default tariff, tagged as a provisional FALLBACK outcome for true-up, and flagged for review — the cycle completes on schedule and no wrong figure is posted.
How do I prove to an auditor that a past classification was not altered?
Each audit record chains the previous record’s hash into its own SHA-256 digest over canonical JSON. Changing any past record changes its hash and therefore every hash after it, so recomputing the chain with verify_chain and comparing to the stored digests immediately reveals tampering. Combined with the retention rules in the governance layer, this satisfies typical PUC audit requirements.
Related Topics
- Municipal Utility Billing Architecture & Rate Taxonomy — the parent guide; where mapping sits in the wider billing stack.
- How to Map Residential vs Commercial Classes in Python — the deep dive on mixed-use parcels and legacy zoning.
- Step-Rate vs Block-Rate Structure Design — the tier-boundary models a mapped class resolves into.
- Fallback Routing for Missing Rate Data — deterministic handling when a class or schedule is absent.
- Assistance Program Eligibility Taxonomy — evaluating subsidy eligibility alongside class mapping.
- Security Boundaries & Role-Based Access — who may edit tier thresholds and approve overrides.