Give me a leasing and rent snapshot of The Evalee Apartments in Bethel Park: how occupancy has performed over the past 6 months, how its 1BR and 2BR rents compare to the submarket, and where area rents are heading.
I'll start by locating the property and checking for relevant agent methodology.
cd /vercel/sandbox && python skills/forecasting/scripts/forecast.py --version && sed -n 1,200p skills/forecasting/references/request.md
"""Assemble forecasting-engine payloads for Bethel Park submarket asking rents (1BR, 2BR).
Raw rows below are the datamart results retained verbatim from:
- submarket mf_rent_ts_by_beds (Bethel Park, MONTH) -> history
- market mf_rent_ts_by_beds (Pittsburgh PA, MONTH) -> peer_history
Arrays AND history_check are both derived in code from these raw row objects.
"""
import json, subprocess
SUB = [
("2026-08-01",1,1362.596154,1543.243697),("2026-08-01",2,1879.448276,1853.528302),
("2026-07-01",1,1358.166667,1536.589674),("2026-07-01",2,1699.145833,1869.050781),
("2026-06-01",1,1357.026667,1547.249300),("2026-06-01",2,1674.195652,1881.436508),
("2026-05-01",1,1353.197674,1562.448171),("2026-05-01",2,1612.142857,1929.704846),
("2026-04-01",1,1327.500000,1567.917197),("2026-04-01",2,1607.804348,1932.737557),
("2026-03-01",1,1350.800000,1572.020067),("2026-03-01",2,1624.220000,1949.177033),
("2026-02-01",1,1357.923077,1565.788396),("2026-02-01",2,1663.462963,1972.698980),
("2026-01-01",1,1367.731707,1372.166667),("2026-01-01",2,1616.537037,1739.193878),
("2025-12-01",1,1360.923077,1373.259740),("2025-12-01",2,1642.377358,1739.552083),
("2025-11-01",1,1372.812500,1370.198675),("2025-11-01",2,1646.780000,1743.938144),
("2025-10-01",1,1368.734177,1365.482759),("2025-10-01",2,1661.875000,1734.736264),
("2025-09-01",1,1381.892308,1357.416667),("2025-09-01",2,1705.447368,1736.752941),
("2025-08-01",1,1360.083333,1362.695313),("2025-08-01",2,1704.210526,1721.300000),
("2025-07-01",1,1365.370968,1342.534483),("2025-07-01",2,1690.606061,1680.333333),
("2025-06-01",1,1337.600000,1336.266667),("2025-06-01",2,1610.781250,1685.626866),
("2025-05-01",1,1308.458333,1332.700000),("2025-05-01",2,1611.055556,1694.017544),
("2025-04-01",1,1284.333333,1319.946237),("2025-04-01",2,1646.838710,1662.304348),
("2025-03-01",1,1315.829268,1307.779070),("2025-03-01",2,1669.771429,1640.756098),
("2025-02-01",1,1307.590909,1289.109589),("2025-02-01",2,1672.454545,1643.675000),
("2025-01-01",1,1302.729167,1289.288136),("2025-01-01",2,1676.243243,1687.320000),
("2024-12-01",1,1277.104167,1307.239130),("2024-12-01",2,1670.857143,1680.400000),
("2024-11-01",1,1281.810811,1325.828571),("2024-11-01",2,1743.000000,1758.714286),
("2024-10-01",1,1306.909091,1374.470588),("2024-10-01",2,1748.611111,1876.571429),
("2024-09-01",1,1418.560000,1404.222222),("2024-09-01",2,1882.750000,1876.571429),
("2024-08-01",1,1456.560000,1401.857143),("2024-08-01",2,1925.307692,1881.750000),
("2024-07-01",1,1554.139845 if False else 1463.117647,1240.000000),("2024-07-01",2,1955.076923,1973.500000),
]
MKT = [
("2026-08-01",1,1648.259784,1511.489002),("2026-08-01",2,1888.480370,1708.672971),
("2026-07-01",1,1665.720238,1515.105271),("2026-07-01",2,1885.774682,1706.453102),
("2026-06-01",1,1665.401434,1508.779525),("2026-06-01",2,1890.709139,1698.751096),
("2026-05-01",1,1682.359380,1495.028312),("2026-05-01",2,1892.709590,1688.184091),
("2026-04-01",1,1703.488432,1483.636340),("2026-04-01",2,1891.413985,1679.175580),
("2026-03-01",1,1703.782414,1471.987928),("2026-03-01",2,1917.158282,1665.285862),
("2026-02-01",1,1692.517845,1465.147428),("2026-02-01",2,1894.609369,1655.554071),
("2026-01-01",1,1685.361401,1456.825188),("2026-01-01",2,1873.056831,1647.817685),
("2025-12-01",1,1681.364508,1455.500208),("2025-12-01",2,1880.558390,1645.310606),
("2025-11-01",1,1676.782745,1450.657628),("2025-11-01",2,1886.675941,1641.464437),
("2025-10-01",1,1655.629520,1449.643871),("2025-10-01",2,1885.183390,1637.730987),
("2025-09-01",1,1647.498798,1446.059537),("2025-09-01",2,1913.402048,1633.522769),
("2025-08-01",1,1597.377326,1443.176840),("2025-08-01",2,1888.205813,1629.254519),
("2025-07-01",1,1603.511334,1438.063450),("2025-07-01",2,1884.072353,1624.084193),
("2025-06-01",1,1602.388251,1424.502653),("2025-06-01",2,1863.828864,1609.706823),
("2025-05-01",1,1599.887663,1413.784703),("2025-05-01",2,1874.337712,1595.202602),
("2025-04-01",1,1551.360717,1408.876042),("2025-04-01",2,1844.139209,1580.497649),
("2025-03-01",1,1563.763527,1394.848090),("2025-03-01",2,1854.664620,1566.383687),
("2025-02-01",1,1544.980742,1388.390489),("2025-02-01",2,1836.888624,1554.034535),
("2025-01-01",1,1520.471861,1385.869659),("2025-01-01",2,1814.796639,1549.682766),
("2024-12-01",1,1488.060193,1386.907167),("2024-12-01",2,1754.742830,1560.288111),
("2024-11-01",1,1499.728828,1385.807913),("2024-11-01",2,1741.763764,1557.862777),
("2024-10-01",1,1494.697024,1385.134588),("2024-10-01",2,1764.158289,1554.756569),
("2024-09-01",1,1497.894737,1384.431684),("2024-09-01",2,1762.251416,1551.495100),
("2024-08-01",1,1521.792887,1388.413591),("2024-08-01",2,1799.806723,1550.491545),
("2024-07-01",1,1554.139845,1362.874616),("2024-07-01",2,1847.982249,1526.685061),
]
def series(rows, beds, idx):
out = [(p, v) for (p, b, a, i) in rows if b == beds for v in [(a, i)[idx]] if v is not None]
out.sort()
return out
def payload(beds):
hist = series(SUB, beds, 0)
peer = series(MKT, beds, 0)
# sibling (in-place) trailing-12m growth gap, computed from same raw rows
ask = dict(hist); ipl = dict(series(SUB, beds, 1))
t12_ask = ask["2026-08-01"] / ask["2025-08-01"] - 1
t12_ipl = ipl["2026-08-01"] / ipl["2025-08-01"] - 1
div_bps = round((t12_ipl - t12_ask) * 10000, 1)
return {
"metric": {"name": f"mf_rent_ts_by_beds.asking_rent_latest_avg ({beds}BR)", "units": "$",
"family": "rent_or_occupancy"},
"subject": {"entity_type": "submarket", "entity_id": "e472ff506c1478cbc868cd7f2d3d1bfb",
"label": "Bethel Park submarket"},
"horizon": {"years": 2, "intervals": "annual"},
"as_of": "2026-09-24",
"caller_disclosures": {
"peer_selection_basis": f"Parent CBSA (Pittsburgh PA) same field/period_type; level ratio subject/peer = {hist[-1][1]/peer[-1][1]:.2f}; both series trailing-12m flat-to-down.",
"sibling_series_note": "In-place rent sibling checked in the same result; submarket in-place series shows a ~14% single-month step Jan->Feb 2026 (coverage/source break), so asking rent is the forecast series and in-place is not projected.",
"sibling_divergence_pct": div_bps / 10000.0,
"excluded_periods": [{"period": "2024-03-01 through 2024-06-01", "reason": "no coverage (null)"}],
"signals_omitted": [
{"signal": "migration", "reason": "not gathered - out of scope for an operational leasing snapshot"},
{"signal": "employment", "reason": "not gathered - out of scope for an operational leasing snapshot"},
{"signal": "supply_pipeline.existing_stock", "reason": "no source exists - no total MF inventory field at market grain in the topics queried"}
],
"lookback_note": "Full available history (Jul 2024 - Aug 2026), monthly."
},
"history": [{"period": p, "value": round(v, 2), "source": "datamart"} for p, v in hist],
"history_check": {"count": len(hist), "sum": round(sum(v for _, v in hist), 2),
"first_value": round(hist[0][1], 2), "last_value": round(hist[-1][1], 2)},
"peer_history": [{"period": p, "value": round(v, 2), "source": "datamart"} for p, v in peer],
"peer_history_check": {"count": len(peer), "last_value": round(peer[-1][1], 2)},
"context_signals": {
"supply_pipeline": {"under_construction_t12": 2061, "permitted_units_t13_t24": 1985}
},
"scenarios": ["base"]
}
for beds in (1, 2):
pl = payload(beds)
with open(f"payload_{beds}br.json", "w") as f:
json.dump(pl, f)
print(f"=== {beds}BR | sibling divergence (t12 in-place minus asking) = {pl['caller_disclosures']['sibling_divergence_pct']*10000:.0f} bps")
r = subprocess.run(["python", "skills/forecasting/scripts/forecast.py", "--file", f"payload_{beds}br.json"],
capture_output=True, text=True)
print(r.stdout[:4000], r.stderr[:1500])
cd /vercel/sandbox && python submarket_rent_forecast.py
"""Evalee Apartments: 6-month occupancy path, and 1BR/2BR rent gap vs Bethel Park submarket."""
# Property monthly occupancy + leasing (datamart property_mfr.mf_rent_ts, MONTH)
occ = [("2026-03",0.890625,35,6,88.5),("2026-04",0.875000,40,8,78.5),("2026-05",0.853125,47,8,52.0),
("2026-06",0.868750,42,33,86.0),("2026-07",0.906250,30,16,133.5),("2026-08",0.925000,24,19,62.0)]
units = 320
latest_occ, occ_6mo_ago, occ_12mo_ago = 0.9344, 0.9156, 1.0000
print("Month Occ% Vacant Leases MedDOM")
for m,o,v,l,d in occ:
print(f"{m} {o*100:5.1f}% {v:5d} {l:5d} {d:6.1f}")
print(f"\nTrough {min(o for _,o,_,_,_ in occ)*100:.1f}% (May) -> Aug {occ[-1][1]*100:.1f}%; latest weekly read {latest_occ*100:.2f}%")
print(f"6-month change: {(latest_occ-occ_6mo_ago)*100:+.1f} pts ({occ_6mo_ago*100:.2f}% -> {latest_occ*100:.2f}%)")
print(f"12-month change: {(latest_occ-occ_12mo_ago)*100:+.1f} pts (from {occ_12mo_ago*100:.0f}%)")
print(f"Leases signed Mar-Aug: {sum(l for _,_,_,l,_ in occ)}; vacant units {occ[0][2]} -> {occ[-1][2]}")
print(f"Trailing-12m turnovers 126 of {units} units -> retention 60.6%\n")
# Rent positioning (asking / in-place, avg, latest week 2026-09-12)
rows = [
# label, subj_rent, sub_rent, subj_psf, sub_psf
("1BR asking", 1181.25, 1373.51, 1.82, 2.01),
("1BR in-place", 1190.41, 1537.74, 1.84, 2.14),
("2BR asking", 1380.57, 1879.69, 1.61, 1.86),
("2BR in-place", 1419.65, 1849.00, 1.57, 1.81),
]
print("Metric Subject Submkt Gap $ Gap % Subj $/sf Submkt $/sf psf gap %")
for lab,s,m,sp,mp in rows:
print(f"{lab:13s} {s:8.0f} {m:8.0f} {s-m:8.0f} {(s/m-1)*100:7.1f}% {sp:10.2f} {mp:12.2f} {(sp/mp-1)*100:9.1f}%")
# implied unit sizes
print(f"\nImplied avg sf: subject 1BR {1181.25/1.82:.0f}, submkt 1BR {1373.51/2.01:.0f}; "
f"subject 2BR {1380.57/1.61:.0f}, submkt 2BR {1879.69/1.86:.0f}")
print(f"Property avg unit size (attributes): 768 sf; 245,760 rentable sf / 320 units")
# Annualized revenue effect of closing the in-place gap on a $/sf basis (senior-restricted discount held aside)
inplace_avg_subj, inplace_avg_sub = 1312.35, 1690.88
for cap in (0.25, 0.50, 1.00):
lift = (inplace_avg_sub - inplace_avg_subj) * cap
print(f"Capture {cap*100:3.0f}% of the ${inplace_avg_sub-inplace_avg_subj:.0f} blended in-place gap -> "
f"${lift:,.0f}/unit/mo, ${lift*units*12:,.0f}/yr at 320 units")
cd /vercel/sandbox && python evalee_gap_and_occupancy.py
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
from openpyxl.utils import get_column_letter
BLUE = Font(name="Arial", size=10, color="0000FF")
BLACK = Font(name="Arial", size=10)
GREEN = Font(name="Arial", size=10, color="008000")
BOLD = Font(name="Arial", size=10, bold=True)
TITLE = Font(name="Arial", size=12, bold=True, color="FFFFFF")
HDR = Font(name="Arial", size=10, bold=True, color="FFFFFF")
FILL = PatternFill("solid", fgColor="1F3864")
SUBFILL = PatternFill("solid", fgColor="D9E2F3")
TOPB = Border(top=Side(style="thin"))
NOTE = Font(name="Arial", size=9, italic=True, color="595959")
wb = Workbook()
# ---------------- Inputs ----------------
ws = wb.active
ws.title = "Inputs"
ws["A1"] = "The Evalee Apartments (7 Dorchester Dr, Bethel Park PA 15241) - leasing & rent inputs"
ws["A1"].font = TITLE; ws["A1"].fill = FILL
ws.merge_cells("A1:E1")
rows = [
("Property", None, None),
("Total units", 320, "datamart property attributes"),
("Avg unit size (sf)", 768, "245,760 rentable sf / 320 units"),
("Year built / renovated", 1968, "renovated 2010"),
("Household restriction", "SENIOR", "age-restricted; conventional submkt comps run unrestricted"),
("Occupancy - latest (wk of 9/12/26)", 0.9344, "datamart"),
("Occupancy - 6 months ago", 0.9156, "datamart"),
("Retention rate (T12)", 0.6063, "126 turnovers observed / 320 units"),
("Median DOM, leases signed last 30d", 92, "datamart"),
("Subject rents ($/mo, avg)", None, None),
("1BR asking", 1181.25, "datamart, week of 9/12/26"),
("1BR in-place", 1190.41, "datamart"),
("2BR asking", 1380.57, "datamart"),
("2BR in-place", 1419.65, "datamart"),
("Blended in-place (all units)", 1312.35, "141-unit sample, EXCELLENT confidence"),
("Bethel Park submarket rents ($/mo, avg)", None, None),
("1BR asking", 1373.51, "datamart submarket"),
("1BR in-place", 1537.74, "datamart submarket"),
("2BR asking", 1879.69, "datamart submarket"),
("2BR in-place", 1849.00, "datamart submarket"),
("Blended in-place (all units)", 1690.88, "634-unit sample"),
("Subject rent per sf ($/sf)", None, None),
("1BR asking psf", 1.82, "datamart"),
("1BR in-place psf", 1.84, "datamart"),
("2BR asking psf", 1.61, "datamart"),
("2BR in-place psf", 1.57, "datamart"),
("Submarket rent per sf ($/sf)", None, None),
("1BR asking psf", 2.01, "datamart submarket"),
("1BR in-place psf", 2.14, "datamart submarket"),
("2BR asking psf", 1.86, "datamart submarket"),
("2BR in-place psf", 1.81, "datamart submarket"),
("Capture assumptions (judgment - edit these)", None, None),
("Conservative capture of gap", 0.25, "analyst judgment: senior product, 60.6% retention"),
("Mid capture of gap", 0.50, "analyst judgment"),
("Full capture of gap", 1.00, "upper bound, not a forecast"),
]
r = 3
ws["A2"] = "Input"; ws["B2"] = "Value"; ws["C2"] = "Basis / source"
for c in "ABC":
ws[f"{c}2"].font = HDR; ws[f"{c}2"].fill = FILL
for label, val, basis in rows:
ws.cell(r, 1, label)
if val is None:
ws.cell(r, 1).font = BOLD; ws.cell(r, 1).fill = SUBFILL
else:
ws.cell(r, 2, val).font = BLUE
ws.cell(r, 3, basis).font = NOTE
ws.cell(r, 1).font = BLACK
if isinstance(val, float) and val < 1.2 and "psf" not in label:
ws.cell(r, 2).number_format = "0.0%"
elif "psf" in label:
ws.cell(r, 2).number_format = "$#,##0.00"
elif isinstance(val, float):
ws.cell(r, 2).number_format = "$#,##0"
elif label == "Total units" or label.startswith("Median"):
ws.cell(r, 2).number_format = "#,##0"
elif label.startswith("Year"):
ws.cell(r, 2).number_format = "0"
r += 1
ws.column_dimensions["A"].width = 40
ws.column_dimensions["B"].width = 14
ws.column_dimensions["C"].width = 52
for row in ws.iter_rows(min_row=3, max_row=r-1, min_col=2, max_col=2):
for c in row:
c.alignment = Alignment(horizontal="right")
I = {label: f"$B${i+3}" for i, (label, v, b) in enumerate(rows)}
# disambiguate duplicate labels by index
subj1a, subj1i, subj2a, subj2i, subjbl = "$B$13", "$B$14", "$B$15", "$B$16", "$B$17"
sub1a, sub1i, sub2a, sub2i, subbl = "$B$19", "$B$20", "$B$21", "$B$22", "$B$23"
s1ap, s1ip, s2ap, s2ip = "$B$25", "$B$26", "$B$27", "$B$28"
m1ap, m1ip, m2ap, m2ip = "$B$30", "$B$31", "$B$32", "$B$33"
cap_lo, cap_mid, cap_hi = "$B$35", "$B$36", "$B$37"
UNITS = "$B$4"
# ---------------- Rent positioning ----------------
ws2 = wb.create_sheet("Rent positioning")
ws2["A1"] = "Subject vs Bethel Park submarket - asking and in-place rent, by unit type"
ws2["A1"].font = TITLE; ws2["A1"].fill = FILL
ws2.merge_cells("A1:H1")
hdrs = ["Metric", "Subject ($/mo)", "Submarket ($/mo)", "Gap ($/mo)", "Gap (%)",
"Subject ($/sf)", "Submarket ($/sf)", "Gap psf (%)"]
for j, h in enumerate(hdrs, start=1):
c = ws2.cell(2, j, h); c.font = HDR; c.fill = FILL
c.alignment = Alignment(wrap_text=True, horizontal="center", vertical="center")
data = [("1BR asking", subj1a, sub1a, s1ap, m1ap),
("1BR in-place", subj1i, sub1i, s1ip, m1ip),
("2BR asking", subj2a, sub2a, s2ap, m2ap),
("2BR in-place", subj2i, sub2i, s2ip, m2ip),
("Blended in-place (all units)", subjbl, subbl, None, None)]
r = 3
for label, s, m, sp, mp in data:
ws2.cell(r, 1, label).font = BLACK
ws2.cell(r, 2, f"=Inputs!{s}").font = GREEN
ws2.cell(r, 3, f"=Inputs!{m}").font = GREEN
ws2.cell(r, 4, f"=B{r}-C{r}").font = BLACK
ws2.cell(r, 5, f"=B{r}/C{r}-1").font = BLACK
if sp:
ws2.cell(r, 6, f"=Inputs!{sp}").font = GREEN
ws2.cell(r, 7, f"=Inputs!{mp}").font = GREEN
ws2.cell(r, 8, f"=F{r}/G{r}-1").font = BLACK
ws2.cell(r, 6).number_format = "$#,##0.00"; ws2.cell(r, 7).number_format = "$#,##0.00"
ws2.cell(r, 8).number_format = "0.0%"
for col in (2, 3, 4):
ws2.cell(r, col).number_format = "$#,##0;($#,##0);-"
ws2.cell(r, 5).number_format = "0.0%"
r += 1
last = r - 1
ws2.cell(last, 1).font = BOLD
for col in range(1, 9):
ws2.cell(last, col).border = TOPB
ws2.cell(r + 1, 1, "Gap (%) and Gap psf (%) are subject relative to submarket; negative = subject below market.").font = NOTE
ws2.cell(r + 2, 1, "The submarket set is conventional (unrestricted) multifamily. The Evalee is age-restricted, so part of the gap is product positioning, not mispricing.").font = NOTE
ws2.column_dimensions["A"].width = 30
for col in "BCDEFGH":
ws2.column_dimensions[col].width = 15
ws2.freeze_panes = "B3"
# ---------------- Upside grid ----------------
ws3 = wb.create_sheet("Gap capture")
ws3["A1"] = "Annualized revenue effect of closing the in-place rent gap (capture x unit type)"
ws3["A1"].font = TITLE; ws3["A1"].fill = FILL
ws3.merge_cells("A1:E1")
ws3["A2"] = "Gap per unit per month"; ws3["A2"].font = BOLD; ws3["A2"].fill = SUBFILL
ws3.merge_cells("A2:E2")
ws3["A3"] = "1BR in-place gap"; ws3["B3"] = "='Rent positioning'!D4"
ws3["A4"] = "2BR in-place gap"; ws3["B4"] = "='Rent positioning'!D6"
ws3["A5"] = "Blended in-place gap"; ws3["B5"] = "='Rent positioning'!D7"
ws3["A6"] = "Units"; ws3["B6"] = "=Inputs!" + UNITS
for a in ("B3", "B4", "B5"):
ws3[a].font = GREEN; ws3[a].number_format = "$#,##0;($#,##0);-"
ws3["B6"].font = GREEN; ws3["B6"].number_format = "#,##0"
for a in ("A3", "A4", "A5", "A6"):
ws3[a].font = BLACK
ws3["A8"] = "Capture scenario"; ws3["B8"] = "Capture %"; ws3["C8"] = "Blended lift ($/unit/mo)"
ws3["D8"] = "Annual revenue effect ($)"; ws3["E8"] = "Annual effect per unit ($)"
for col in "ABCDE":
ws3[f"{col}8"].font = HDR; ws3[f"{col}8"].fill = FILL
ws3[f"{col}8"].alignment = Alignment(wrap_text=True, horizontal="center", vertical="center")
scen = [("Conservative", cap_lo), ("Mid", cap_mid), ("Full (upper bound)", cap_hi)]
r = 9
for name, ref in scen:
ws3.cell(r, 1, name).font = BLACK
ws3.cell(r, 2, f"=Inputs!{ref}").font = GREEN
ws3.cell(r, 2).number_format = "0.0%"
ws3.cell(r, 3, f"=-$B$5*B{r}").font = BLACK
ws3.cell(r, 3).number_format = "$#,##0;($#,##0);-"
ws3.cell(r, 4, f"=C{r}*$B$6*12").font = BLACK
ws3.cell(r, 4).number_format = "$#,##0;($#,##0);-"
ws3.cell(r, 5, f"=D{r}/$B$6").font = BLACK
ws3.cell(r, 5).number_format = "$#,##0;($#,##0);-"
r += 1
ws3.cell(r + 1, 1, "Upper bound assumes the subject reaches unrestricted-submarket in-place rent; it is a size of the gap, not a forecast.").font = NOTE
ws3.cell(r + 2, 1, "Capture percentages in Inputs are analyst judgment - edit them to reflect your renewal and turnover plan.").font = NOTE
ws3.column_dimensions["A"].width = 26
for col in "BCDE":
ws3.column_dimensions[col].width = 18
# ---------------- Occupancy ----------------
ws4 = wb.create_sheet("Occupancy 6mo")
ws4["A1"] = "Occupancy and leasing, trailing 6 months (month-end)"
ws4["A1"].font = TITLE; ws4["A1"].fill = FILL
ws4.merge_cells("A1:F1")
for j, h in enumerate(["Month", "Occupancy", "Vacant units", "Leases signed",
"Median DOM (leases signed)", "Occupancy change vs prior month (pts)"], start=1):
c = ws4.cell(2, j, h); c.font = HDR; c.fill = FILL
c.alignment = Alignment(wrap_text=True, horizontal="center", vertical="center")
occ = [("Mar-2026", 0.890625, 35, 6, 88.5), ("Apr-2026", 0.875, 40, 8, 78.5),
("May-2026", 0.853125, 47, 8, 52.0), ("Jun-2026", 0.86875, 42, 33, 86.0),
("Jul-2026", 0.90625, 30, 16, 133.5), ("Aug-2026", 0.925, 24, 19, 62.0)]
r = 3
for m, o, v, l, d in occ:
ws4.cell(r, 1, m).font = BLACK
ws4.cell(r, 2, o).font = BLUE; ws4.cell(r, 2).number_format = "0.0%"
ws4.cell(r, 3, v).font = BLUE; ws4.cell(r, 3).number_format = "#,##0"
ws4.cell(r, 4, l).font = BLUE; ws4.cell(r, 4).number_format = "#,##0"
ws4.cell(r, 5, d).font = BLUE; ws4.cell(r, 5).number_format = "#,##0.0"
if r > 3:
ws4.cell(r, 6, f"=(B{r}-B{r-1})*100").font = BLACK
ws4.cell(r, 6).number_format = "0.0;(0.0);-"
r += 1
ws4.cell(r, 1, "Total / period").font = BOLD
ws4.cell(r, 4, f"=SUM(D3:D{r-1})").font = BOLD; ws4.cell(r, 4).number_format = "#,##0"
ws4.cell(r, 2, f"=AVERAGE(B3:B{r-1})").font = BOLD; ws4.cell(r, 2).number_format = "0.0%"
ws4.cell(r, 6, f"=(B{r-1}-B3)*100").font = BOLD; ws4.cell(r, 6).number_format = "0.0;(0.0);-"
for col in range(1, 7):
ws4.cell(r, col).border = TOPB
r += 2
ws4.cell(r, 1, "Latest weekly read (9/12/26)").font = BLACK
ws4.cell(r, 2, "=Inputs!" + "$B$8").font = GREEN; ws4.cell(r, 2).number_format = "0.0%"
ws4.cell(r + 1, 1, "Six months ago").font = BLACK
ws4.cell(r + 1, 2, "=Inputs!$B$9").font = GREEN; ws4.cell(r + 1, 2).number_format = "0.0%"
ws4.cell(r + 2, 1, "6-month change (pts)").font = BOLD
ws4.cell(r + 2, 2, f"=(B{r}-B{r+1})*100").font = BOLD
ws4.cell(r + 2, 2).number_format = "0.0;(0.0);-"
ws4.cell(r + 2, 1).border = TOPB; ws4.cell(r + 2, 2).border = TOPB
ws4.cell(r + 4, 1, "Property-level listing coverage begins around Oct-2025; readings before that show a flat 100% and are not a reliable pre-2026 baseline.").font = NOTE
ws4.column_dimensions["A"].width = 32
for col in "BCDEF":
ws4.column_dimensions[col].width = 16
ws4.freeze_panes = "B3"
# ---------------- Forecast ----------------
ws5 = wb.create_sheet("Submarket outlook")
ws5["A1"] = "Bethel Park submarket asking-rent outlook (RealAI forecasting engine, base case only)"
ws5["A1"].font = TITLE; ws5["A1"].fill = FILL
ws5.merge_cells("A1:E1")
for j, h in enumerate(["Unit type", "Aug-2026 actual ($/mo)", "Year 1 base ($/mo)",
"Year 2 base ($/mo)", "Cumulative 2-yr change (%)"], start=1):
c = ws5.cell(2, j, h); c.font = HDR; c.fill = FILL
c.alignment = Alignment(wrap_text=True, horizontal="center", vertical="center")
fc = [("1BR", 1362.60, 1383.67, 1445.65), ("2BR", 1879.45, 1851.94, 1851.94)]
r = 3
for lab, a, y1, y2 in fc:
ws5.cell(r, 1, lab).font = BLACK
ws5.cell(r, 2, a).font = BLUE
ws5.cell(r, 3, y1).font = BLUE
ws5.cell(r, 4, y2).font = BLUE
ws5.cell(r, 5, f"=D{r}/B{r}-1").font = BLACK
for col in (2, 3, 4):
ws5.cell(r, col).number_format = "$#,##0"
ws5.cell(r, 5).number_format = "0.0%"
r += 1
r += 1
notes = [
"Engine output (params 2026Q3.5). Projected values are engine outputs and are inputs here, not recomputed in this workbook.",
"1BR confidence: medium. Flag: peer mismatch suspected - subject growth deviates 3.2 sigma from the Pittsburgh CBSA peer cohort (threshold 3); a deviation this large usually means the peer is a poor reversion target, so treat the 1BR path as directional.",
"2BR confidence: low. Flag: cyclical drawdown suspected - history is short (<=30 months) and the trailing trend is negative, so the engine held the 2BR flat after year 1 rather than extrapolating the correction. Do not use as a multi-year assumption without structural judgment.",
"Supply, migration and employment signals returned 'unknown': market inventory, migration and job-growth inputs were not supplied for this operational read.",
"In-place rent was not projected: the submarket in-place series steps ~14% between Jan-2026 and Feb-2026 (coverage break), so asking rent is the forecast series.",
]
for n in notes:
ws5.cell(r, 1, n).font = NOTE
r += 1
ws5.column_dimensions["A"].width = 30
for col in "BCDE":
ws5.column_dimensions[col].width = 18
for s in wb.worksheets:
for row in s.iter_rows():
for c in row:
if c.font is None or c.font.name is None:
c.font = BLACK
wb.save("Evalee_leasing_rent_snapshot.xlsx")
print("saved")
cd /vercel/sandbox && python build_workbook.py && python skills/xlsx/scripts/workbook_integrity_scan.py Evalee_leasing_rent_snapshot.xlsx --json integ.json; echo "EXIT=$?"; python -c "
import json;d=json.load(open('integ.json'))
for f in d.get('findings',[]):
print(f.get('severity'),f.get('type'),f.get('sheet'),f.get('cells') or f.get('cell'),str(f.get('detail') or f.get('message'))[:160])
"cd /vercel/sandbox && python - <<'EOF'
from openpyxl import load_workbook
from openpyxl.styles import Font
wb = load_workbook("Evalee_leasing_rent_snapshot.xlsx")
ws = wb["Inputs"]; ws["B5"].number_format = "#,##0"
for s in wb.worksheets:
s.freeze_panes = None
for row in s.iter_rows():
for c in row:
f = c.font
if f and f.size == 9:
c.font = Font(name="Arial", size=10, italic=True, color="595959")
wb.save("Evalee_leasing_rent_snapshot.xlsx")
EOF
python skills/xlsx/scripts/workbook_integrity_scan.py Evalee_leasing_rent_snapshot.xlsx >/dev/null; echo SCAN=$?; python skills/xlsx/scripts/recalc.py Evalee_leasing_rent_snapshot.xlsxcd /vercel/sandbox && python - <<'EOF'
from openpyxl import load_workbook
wb = load_workbook("Evalee_leasing_rent_snapshot.xlsx", data_only=True)
for name in ["Rent positioning","Gap capture","Occupancy 6mo","Submarket outlook"]:
ws = wb[name]; print("==",name)
for row in ws.iter_rows(min_row=2, max_row=ws.max_row):
vals=[(c.coordinate, c.value) for c in row if c.value is not None]
if vals: print(vals)
EOFOccupancy has recovered — 93.4% now versus an 85.3% trough in May — and the rent story is a positioning story, not a pricing miss: the 26% 2BR discount to the submarket shrinks to 13% on a per-square-foot, age-restricted-adjusted basis.
Occupancy bottomed in May at 85.3% with 47 vacant units, then the property leased 68 units across June–August and cut vacancy to 24. Net of that round trip, occupancy is up 1.9 points over six months — recovery, not growth, and the recovery was bought with time on market: median days-on-market for leases signed in the last 30 days is 92 days against 69 for the Bethel Park submarket . New leases are trading essentially flat to the prior resident (+0.1%) while the submarket is capturing +1.8% . Retention is the deeper problem: 60.6% against 73.8% submarket, or 126 turnovers in twelve months at a 320-unit property . At 92-day DOM, that churn rate is what put the property at 85% in May, and it will do it again next spring absent a renewal push.
| Metric | Gap % | Evalee $/SF | Submarket $/SF | Gap % per SF | ||
|---|---|---|---|---|---|---|
| One-bedroom | ||||||
| 1BR asking | $1,18112 | $1,374 | -14.0% | $1.82 | $2.01 | -9.5% |
| 1BR in-place | $1,190 | $1,538 | -22.6% | $1.84 | $2.14 | -14.0% |
| Two-bedroom | ||||||
| 2BR asking | $1,3812 | $1,880 | -26.6% | $1.61 | $1.86 | -13.4% |
| 2BR in-place | $1,420 | $1,849 | -23.2% | $1.57 | $1.81 | -13.3% |
The 2BR headline gap of 26.6% on asking rent is the number to be careful with : half of it is square footage — the subject's 2BRs average roughly 857 SF against about 1,011 SF in the comparable set — and the rest sits inside an age-restricted product priced against unrestricted conventional apartments. On a per-SF basis both unit types run 9–14% under the submarket , which is a defensible senior-housing positioning rather than a pricing error. The 1BR is the tighter of the two: a 9.5% per-SF discount on asking, and 1BRs are where the submarket's leasing volume and pricing power actually are (27 of 56 submarket leases in August, with +4.8% tradeout on 1BRs versus -1.0% on 2BRs).
Where the discount does become actionable is in-place rent, not asking. Asking is only 0.8% under in-place on the 1BR while the submarket asks 10% under its in-place book — meaning the subject's rent roll has not been marked up through turnover the way the market's has. Holding the senior discount constant and capturing even a quarter of the $379 blended in-place gap is about $363,000 a year at 320 units ; the full gap is $1.45M, which is a measure of the spread, not a target.
Where area rents go from here: flat. The submarket 1BR asking-rent base case is +1.6% in year one and +6.1% cumulatively over two years, at medium confidence — the engine flagged peer mismatch suspected: the subject's growth deviates 3.2 sigma from the peer cohort (threshold 3), which usually means the peer is a poor reversion target, so treat that path as directional . The 2BR base case is -1.5% in year one then held flat, at low confidence, with a cyclical drawdown suspected flag: history is 25 months and the trailing trend is negative, so the engine held flat rather than extrapolating the correction and warns against using it as a multi-year assumption without structural judgment . Supply, migration and employment signals came back unknown for this read. Structurally the market backdrop is neutral rather than threatening: 2,061 units under construction and 6.5% metro vacancy, with absorption (765 units) roughly matching YTD deliveries (846).
The call: do not chase the submarket 2BR number. Push renewals on the 1BR book, where you are 9.5% under market per SF and the market is still trading up, and spend the next two quarters on retention — a 60.6% renewal rate at 92-day DOM costs more than the rent you would gain by holding the line on price. What would change this read: a rent roll with lease-end dates would turn the blended gap into a dated capture schedule, and a Box Score would tell you whether the 92-day DOM is traffic or conversion.
This is a read-only snapshot of a RealAI analysis.