Stabilizing Catchment Boundaries Across Years
This guide, part of Catchment & Service Area Delineation, addresses what happens after the first delineation: keeping boundaries steady enough that facility rates can be compared from one year to the next.
Problem Context & Constraints
A hospital’s preventable admission rate for 2025 divides its admissions by its service-area population. If the service area gained three ZIP codes between 2024 and 2025, the denominator rose, and the rate may fall even though nothing about care changed. Multiply that across a hundred hospitals re-delineated every year and trend analysis becomes an analysis of boundary changes.
Most year-to-year reassignments are noise. A rural ZIP code with 45 discharges, split 21 to 19 between two hospitals, will flip whenever a handful of patients choose differently. Some reassignments are real: a hospital closes its obstetric unit, a new facility opens, an insurer changes its network. The task is to let real changes through and hold back noise.
Three tools work together. Rolling pooling windows — a three-year window advanced one year at a time — smooth the flow matrix so that one unusual year cannot move an area on its own. Hysteresis — an area changes assignment only if the challenger’s lead exceeds a margin, and the lead persists for more than one window — stops areas that are near a tie from flipping back and forth. Event overrides — forced re-evaluation when a facility opens, closes or merges — make sure the rules do not hold back changes that are known to be real.
Prerequisites
python3.11pandas2.2.2,numpy1.26.4- Annual flow matrices of residential area by facility, built as in Delineating Hospital Service Areas From Discharge Flows
- The previous year’s published catchment file with its basis column
- A facility event log: openings, closures, mergers and service-line changes, with dates
- A consistent geography across years, or a crosswalk between vintages
Step-by-Step Solution
Compute the current window’s flows, compare each area’s current assignment with its strongest challenger, change only when the lead passes the margin for the required number of windows or when a facility event forces re-evaluation, and emit a change report.
# Stable year-over-year catchment updates with hysteresis and event overrides.
# Pinned: pandas==2.2.2, numpy==1.26.4
import logging
import numpy as np
import pandas as pd
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("stable_catchments")
WINDOW = 3 # years pooled per delineation
MARGIN = 0.10 # challenger must lead the incumbent by this share
PERSIST = 2 # ...in this many consecutive windows
def window_flows(annual: dict[int, pd.DataFrame], end_year: int) -> pd.DataFrame:
years = range(end_year - WINDOW + 1, end_year + 1)
return sum(annual[y] for y in years).fillna(0)
def challenger_lead(flows: pd.DataFrame, incumbent: pd.Series) -> pd.DataFrame:
shares = flows.div(flows.sum(axis=1), axis=0)
inc_share = pd.Series([shares.at[a, h] if h in shares.columns else 0.0
for a, h in incumbent.reindex(shares.index).items()], index=shares.index)
top = shares.idxmax(axis=1)
return pd.DataFrame({"incumbent": incumbent.reindex(shares.index), "challenger": top,
"lead": shares.max(axis=1) - inc_share})
def update(prev: pd.DataFrame, flows: pd.DataFrame, events: pd.DataFrame, year: int) -> pd.DataFrame:
"""prev: index area; columns hospital, pending_challenger, pending_count."""
c = challenger_lead(flows, prev["hospital"])
evented = set(events.loc[events["year"] == year, "facility_id"])
rows = {}
for area, r in c.iterrows():
old = prev.loc[area] if area in prev.index else None
hospital = old["hospital"] if old is not None else r["challenger"]
pend = old["pending_challenger"] if old is not None else None
count = int(old["pending_count"]) if old is not None else 0
reason = "new_area" if old is None else ""
if hospital in evented or r["challenger"] in evented:
# Facility events force re-evaluation without hysteresis.
if r["challenger"] != hospital:
hospital, reason = r["challenger"], "facility_event"
pend, count = None, 0
elif r["challenger"] != hospital and r["lead"] > MARGIN:
count = count + 1 if pend == r["challenger"] else 1
pend = r["challenger"]
if count >= PERSIST:
hospital, reason, pend, count = r["challenger"], "persistent_lead", None, 0
else:
pend, count = None, 0
rows[area] = {"hospital": hospital, "pending_challenger": pend,
"pending_count": count, "change_reason": reason}
out = pd.DataFrame.from_dict(rows, orient="index")
log.info("year=%d areas=%d persistent_switches=%d event_switches=%d pending=%d",
year, len(out), int((out.change_reason == "persistent_lead").sum()),
int((out.change_reason == "facility_event").sum()),
int((out.pending_count > 0).sum()))
return out
Pending challengers are the useful middle state. An area whose challenger has led once is not yet reassigned, but it is flagged, and planners know it may move next year. Publishing the pending list with each release gives hospitals notice before a boundary change affects their rates. It also gives analysts a way to test sensitivity: recomputing facility rates as if every pending area had already moved shows the largest boundary effect the next release could bring.
Facility events bypass hysteresis because the reason for waiting — uncertainty about whether a change is real — does not apply. When a hospital closes, its areas must move this year; when one opens, its early flows are an understatement, so re-evaluate in the year it opens and again two years later.
Validation & Edge Cases
Backtesting the rules. Run the stabilised procedure over past years and compare against plurality-only delineation. Check that areas that moved under plurality-only and stayed under the rules were genuinely contested — near-ties in the flows — and that real shifts, such as those after a known closure, were let through within one or two windows.
Margins that are too wide. A margin of 25 points will hold back genuine changes for years. Set the margin from observed noise: compute the year-to-year standard deviation of the top share in areas with no facility events, and set the margin at about twice that.
Geography changes. A new census vintage changes ZCTA boundaries, which is a forced re-delineation of its own. Crosswalk flows to the new geography, re-delineate from scratch with the rolling window, and publish a bridge showing how old areas map to new ones.
Mergers. When two hospitals merge under one identifier, areas split between them become firm assignments to the merged entity. Treat a merger as a facility event for both identifiers, and backfill the lineage table so past windows are recomputed on the merged identifier for comparison.
Trend analysis across a boundary change. Where a change is unavoidable, compute rates for the change year on both the old and new catchment, and report both. Analysts can then separate the effect of the boundary from the change in the outcome.
Small-count areas that fall back to travel time. Areas assigned by travel time because they have too few patients should not flip between travel-time and flow-based assignment as their counts hover around the minimum. Apply the same persistence rule to the switch between bases: an area moves to a flow-based assignment only after it has cleared the minimum count in two consecutive windows.
Pending lists that never resolve. Some areas keep a challenger that leads once, falls back, then leads again, resetting the counter every time. Log areas that have entered the pending state three or more times in five years; they are structurally shared and are better labelled as shared than held with one hospital indefinitely.
Service-line catchments on a different cycle. Obstetric or behavioural health catchments built from much smaller flows are noisier than general ones. Use a longer window or a wider margin for them rather than the general settings, and document the difference so that no one compares the two sets of boundaries as if they were built the same way.
INFO stable_catchments year=2025 areas=1068 persistent_switches=9 event_switches=0 pending=23
INFO stable_catchments bridge: 2020->2030 ZCTA crosswalk not yet required
Compliance Notes
Record the window length, margin and persistence settings, the facility event log used, each year’s change report, pending list and denominator impact table, and the version lineage linking every catchment file to its predecessor.
Hospitals subject to community health needs assessment requirements rely on these boundaries, and a change can alter which populations a hospital is expected to serve. Give affected facilities advance notice through the pending list, provide a route to query an assignment, and keep a record of queries and outcomes alongside the release.
Related Topics
- Catchment & Service Area Delineation — the parent guide.
- Comparing Fixed-Radius and Network Catchments — an alternative definition that is stable by construction.
- Crosswalking 2010 to 2020 Census Tracts for Trend Analysis — handling geography changes between vintages.