The Problem
Properties track physical occupancy and economic occupancy but don't decompose why revenue falls short. A property at 95% physical and 89% economic lost 6% somewhere. That could be concessions (pricing problem) or delinquency (collections problem). Without decomposition, you optimize the wrong lever.
Standard reporting shows one number per month with no directional signal. A property at 91% this month could be improving from 88% last quarter or declining from 94% last year.
The Framework
Economic occupancy decomposes into four mutually exclusive loss buckets: vacancy (unit empty), concessions (intentional discount), delinquency (earned but not collected), other (employee/model units). Each calculates as percentage of accrual-basis GPR.
The delinquency calculation:
Delinquency = GPR(accrual)
- Vacancy
- Employee discounts
- Model/Admin units
- One-time specials
- GPR(cash)
This is earned rent not collected. Cash-basis GPR measures collection effectiveness without polluting vacancy or pricing metrics. The buckets don't overlap.
Three time periods track momentum: T1 (trailing 1 month), T3 (trailing 3 months), T12 (trailing 12 months). Two variances create the quadrant map: Y-axis = Var T1→T3 (recent momentum), X-axis = Var T3→T12 (vs baseline).
The Quadrant Map
Quadrant I (Top Right): Recent improvement, above baseline. Maintaining gains.
Quadrant II (Top Left): Recent improvement, below baseline. Early turnaround, not yet stable.
Quadrant III (Bottom Left): Recent decline, below baseline. Requires intervention.
Quadrant IV (Bottom Right): Recent decline, above baseline. Early warning signal.
The Code
Finding Columns and Rows
def find_property_columns(df: pd.DataFrame,
header_row_idx: int) -> list[int]:
header = df.iloc[header_row_idx].tolist()
prop_cols = []
for j, v in enumerate(header):
if j == ACCOUNT_COL:
continue
name = str(v).strip()
if name and name.lower() not in {"total", "portfolio"}:
prop_cols.append(j)
return prop_cols
def find_metric_row(df: pd.DataFrame,
patterns: tuple[str, ...]) -> int:
colA = df.iloc[:, ACCOUNT_COL].astype(str).map(normalize)
for pat in patterns:
pat_n = normalize(pat)
hits = colA[colA == pat_n]
if len(hits) > 0:
return int(hits.index[0])
raise ValueError(f"Metric not found: {patterns}")
Property names live in row 7. Account names in column A vary ("Gross Potential Rent", "GPR", "Loss to Vacancy"). Pattern matching finds the right rows.
Extracting and Computing
def extract_metrics_from_sheet(xls_path: Path,
sheet_name: str,
method: str) -> pd.DataFrame:
df = pd.read_excel(xls_path, sheet_name=sheet_name,
header=None)
prop_cols = find_property_columns(df, HEADER_ROW_IDX)
prop_names = df.iloc[HEADER_ROW_IDX, prop_cols].tolist()
out = pd.DataFrame(index=prop_names)
for m in METRICS:
if m.method != method:
continue
r = find_metric_row(df, m.patterns)
vals = pd.to_numeric(df.iloc[r, prop_cols],
errors="coerce")
out[m.key] = vals.values
return out
def compute_economic_occupancy(tbl: pd.DataFrame) -> pd.DataFrame:
gpr = tbl["gpr_accrual"]
delinquency = (
tbl["gpr_accrual"] - tbl["vacancy"]
- tbl["employee_disc"] - tbl["model_admin"]
- tbl["concessions"] - tbl["gpr_cash"]
)
vac_pct = -tbl["vacancy"] / gpr
del_pct = -delinquency / gpr
conc_pct = -tbl["concessions"] / gpr
other_pct = -(tbl["employee_disc"] + tbl["model_admin"]) / gpr
econ_occ = 1.0 + (vac_pct + del_pct + conc_pct + other_pct)
return pd.DataFrame({
"Vac%": vac_pct, "BadDebt%": del_pct,
"Conc%": conc_pct, "Other%": other_pct,
"EconOcc%": econ_occ
}, index=tbl.index)
Extract values at metric row × property column intersections. Separate extractions for accrual and cash sheets. Each component converts to percentage of accrual GPR. Economic occupancy starts at 100% and subtracts each loss percentage.
Quadrant Construction
def build_quadrant_inputs(t1: pd.DataFrame,
t3: pd.DataFrame,
t12: pd.DataFrame) -> pd.DataFrame:
y = t1["EconOcc%"] - t3["EconOcc%"]
x = t3["EconOcc%"] - t12["EconOcc%"]
return pd.DataFrame({
"x_var_T3_T12": x,
"y_var_T1_T3": y
})
Y-axis shows recent trend direction. X-axis shows performance vs baseline.
Metrics and Implications
The decomposition creates mutually exclusive buckets with no overlap. Vacancy measures physical emptiness. Concessions measure pricing decisions. Delinquency measures collection failure. Other measures structural waivers.
A property at 91% economic occupancy lost 9%. That 9% could break down as: 4% to vacancy, 3% to concessions, 2% to delinquency, 0% to other. Another property also at 91% shows losses of: 2% to vacancy, 1% to concessions, 6% to delinquency, 0% to other. Same headline number, entirely different operational problems. The first needs pricing discipline, the second needs collections enforcement.
The quadrant map separates noise from signal. Quadrant III properties (declining and weak) require immediate escalation. Quadrant IV properties (strong but weakening) get early intervention while still above baseline. Quadrant II properties (recovering but weak) need consistency to cement gains.
What This Enables
Component breakdown directs specific interventions. Concession losses require pricing discipline. Delinquency losses require collections enforcement. Vacancy losses require leasing velocity improvements.
Quadrant position identifies when to intervene. Quadrant IV properties still perform above baseline but momentum turned negative. Early intervention prevents slide to Quadrant III. Leadership attention allocates based on trajectory, not just absolute performance.
Multi-period view tracks operational changes. A collections enforcement initiative shows as declining bad debt percentage across T1, T3, T12. A pricing discipline push shows as declining concession percentage.