cd /vercel/sandbox && python3 -c "
import pdfplumber
with pdfplumber.open('berkadia_om.pdf') as pdf:
print('PAGES:', len(pdf.pages))
for i, page in enumerate(pdf.pages, start=1):
text = page.extract_text() or ''
print(f'=== PAGE {i} ===')
print(text)
" > om_text.txt
wc -l om_text.txt
head -c 3000 om_text.txtcd /vercel/sandbox && python3 -c "
import pdfplumber
with pdfplumber.open('site_work_bid.pdf') as pdf:
print('PAGES:', len(pdf.pages))
for i, page in enumerate(pdf.pages, start=1):
text = page.extract_text() or ''
print(f'=== PAGE {i} ===')
print(text)
" > sitebid_text.txt
cat sitebid_text.txtcd /vercel/sandbox && mkdir -p docx_out && libreoffice --headless --convert-to pdf entitlements_summary.docx --outdir docx_out 2>&1 | tail -5 && ls docx_out
cd /vercel/sandbox && sed -n '/PAGE 9/,/PAGE 30/p' om_text.txt | head -400
cd /vercel/sandbox && grep -in "price\|asking\|\$/unit\|per unit\|offer" om_text.txt | head -50
echo "---docx---"
python3 -c "
import pdfplumber
with pdfplumber.open('docx_out/entitlements_summary.pdf') as pdf:
print('PAGES', len(pdf.pages))
for i,p in enumerate(pdf.pages,start=1):
t = p.extract_text() or ''
print(f'=== PAGE {i} ===')
print(t)
"cd /vercel/sandbox && python3 skills/xlsx/scripts/workbook_profile.py template_clean.xlsx 2>/dev/null | python3 -m json.tool | head -100
cat /vercel/sandbox/skills/forecasting/references/request.md | head -250
cd /vercel/sandbox && cat > rent_payload.json << 'EOF'
{
"metric": {"name": "mf_rent_ts.in_place_rent_latest (Rock Hill - west submarket)", "units": "$", "family": "rent_or_occupancy"},
"subject": {"entity_type": "submarket", "entity_id": "f591151e624de50d42769753fbe562ea", "label": "Rock Hill - west submarket (Charlotte, NC MSA)"},
"horizon": {"years": 5, "intervals": "annual"},
"as_of": "2026-08-27",
"history": [
{"period":"2024-07-01","value":1445.982262,"source":"datamart"},
{"period":"2024-08-01","value":1431.856863,"source":"datamart"},
{"period":"2024-09-01","value":1424.734545,"source":"datamart"},
{"period":"2024-10-01","value":1440.086009,"source":"datamart"},
{"period":"2024-11-01","value":1440.941886,"source":"datamart"},
{"period":"2024-12-01","value":1417.043436,"source":"datamart"},
{"period":"2025-01-01","value":1415.921681,"source":"datamart"},
{"period":"2025-02-01","value":1415.899348,"source":"datamart"},
{"period":"2025-03-01","value":1415.123862,"source":"datamart"},
{"period":"2025-04-01","value":1416.111514,"source":"datamart"},
{"period":"2025-05-01","value":1413.987730,"source":"datamart"},
{"period":"2025-06-01","value":1414.039316,"source":"datamart"},
{"period":"2025-07-01","value":1413.089690,"source":"datamart"},
{"period":"2025-08-01","value":1406.551752,"source":"datamart"},
{"period":"2025-09-01","value":1406.985237,"source":"datamart"},
{"period":"2025-10-01","value":1402.515198,"source":"datamart"},
{"period":"2025-11-01","value":1400.741598,"source":"datamart"},
{"period":"2025-12-01","value":1401.004573,"source":"datamart"},
{"period":"2026-01-01","value":1398.343656,"source":"datamart"},
{"period":"2026-02-01","value":1397.490196,"source":"datamart"},
{"period":"2026-03-01","value":1395.352248,"source":"datamart"},
{"period":"2026-04-01","value":1396.536481,"source":"datamart"},
{"period":"2026-05-01","value":1374.517835,"source":"datamart"},
{"period":"2026-06-01","value":1371.436441,"source":"datamart"}
],
"peer_history": [
{"period":"2024-07-01","value":1570.122074,"source":"datamart"},
{"period":"2024-08-01","value":1577.878351,"source":"datamart"},
{"period":"2024-09-01","value":1584.693781,"source":"datamart"},
{"period":"2024-10-01","value":1585.432239,"source":"datamart"},
{"period":"2024-11-01","value":1587.121869,"source":"datamart"},
{"period":"2024-12-01","value":1587.492279,"source":"datamart"},
{"period":"2025-01-01","value":1589.186751,"source":"datamart"},
{"period":"2025-02-01","value":1593.383584,"source":"datamart"},
{"period":"2025-03-01","value":1597.363163,"source":"datamart"},
{"period":"2025-04-01","value":1602.100656,"source":"datamart"},
{"period":"2025-05-01","value":1606.936237,"source":"datamart"},
{"period":"2025-06-01","value":1612.861003,"source":"datamart"},
{"period":"2025-07-01","value":1614.220593,"source":"datamart"},
{"period":"2025-08-01","value":1620.195345,"source":"datamart"},
{"period":"2025-09-01","value":1620.553225,"source":"datamart"},
{"period":"2025-10-01","value":1620.595380,"source":"datamart"},
{"period":"2025-11-01","value":1620.525217,"source":"datamart"},
{"period":"2025-12-01","value":1618.526686,"source":"datamart"},
{"period":"2026-01-01","value":1619.402388,"source":"datamart"},
{"period":"2026-02-01","value":1620.290694,"source":"datamart"},
{"period":"2026-03-01","value":1624.082585,"source":"datamart"},
{"period":"2026-04-01","value":1624.872687,"source":"datamart"},
{"period":"2026-05-01","value":1625.366235,"source":"datamart"},
{"period":"2026-06-01","value":1626.352052,"source":"datamart"}
],
"history_check": {"count": 24, "sum": 33839.917367, "first_value": 1445.982262, "last_value": 1371.436441},
"peer_history_check": {"count": 24, "sum": 38669.586580, "last_value": 1626.352052},
"caller_disclosures": {
"peer_selection_basis": "parent market default (Charlotte MSA); level ratio ~0.84 (submarket rents run below the metro average), submarket trending down T12 (-2.55%) while metro is flat/up T12 (~+2.3%), a real divergence in direction not just level",
"sibling_series_note": "asking rent checked in same fetch; T12 asking -0.93% vs in-place -2.55%, both negative",
"sibling_divergence_pct": 0.0162,
"lookback_note": "full 24-month available history used (mf_rent_ts submarket series begins 2024-07)"
},
"context_signals": {
"supply_pipeline": {"existing_stock": 2782, "under_construction_t12": 0, "permitted_units_t13_t24": 353},
"employment": {"job_growth_1_year_pct": 0.0584}
},
"scenarios": ["base", "upside", "downside"]
}
EOF
python3 skills/forecasting/scripts/forecast.py --file rent_payload.json > rent_forecast_result.json
cat rent_forecast_result.json | python3 -m json.tool | head -100cd /vercel/sandbox && python3 -c " vals=[1445.982262,1431.856863,1424.734545,1440.086009,1440.941886,1417.043436,1415.921681,1415.899348,1415.123862,1416.111514,1413.987730,1414.039316,1413.089690,1406.551752,1406.985237,1402.515198,1400.741598,1401.004573,1398.343656,1397.490196,1395.352248,1396.536481,1374.517835,1371.436441] print(len(vals), sum(vals)) peer=[1570.122074,1577.878351,1584.693781,1585.432239,1587.121869,1587.492279,1589.186751,1593.383584,1597.363163,1602.100656,1606.936237,1612.861003,1614.220593,1620.195345,1620.553225,1620.595380,1620.525217,1618.526686,1619.402388,1620.290694,1624.082585,1624.872687,1625.366235,1626.352052] print(len(peer), sum(peer)) "
"history_check": {"count": 24, "sum": 33839.917367, "first_value": 1445.982262, "last_value": 1371.436441},
"peer_history_check": {"count": 24, "sum": 38669.586580, "last_value": 1626.352052}, "history_check": {"count": 24, "sum": 33856.293357, "first_value": 1445.982262, "last_value": 1371.436441},
"peer_history_check": {"count": 24, "sum": 38549.555074, "last_value": 1626.352052},cd /vercel/sandbox && python3 skills/forecasting/scripts/forecast.py --file rent_payload.json > rent_forecast_result.json cat rent_forecast_result.json | python3 -m json.tool
cd /vercel/sandbox && python3 -c " vals=[0.0451,0.0440,0.0440,0.0398,0.0382,0.0369,0.0385,0.0416,0.0425,0.0501,0.0517,0.0517,0.0579,0.0553,0.0554,0.0528,0.0513,0.0513,0.0513,0.0513,0.0513,0.0514,0.0514] print(len(vals)) "
cd /vercel/sandbox && cat > caprate_payload.json << 'EOF'
{
"metric": {"name": "caprate_ts.multifamily", "units": "%", "family": "capital_markets"},
"subject": {"entity_type": "market", "entity_id": "16740", "label": "Charlotte, NC MSA - multifamily"},
"horizon": {"years": 3, "intervals": "annual"},
"as_of": "2026-08-27",
"history": [
{"period":"2020-07-01","value":4.51,"source":"datamart"},
{"period":"2020-10-01","value":4.40,"source":"datamart"},
{"period":"2021-01-01","value":4.40,"source":"datamart"},
{"period":"2021-04-01","value":3.98,"source":"datamart"},
{"period":"2021-07-01","value":3.82,"source":"datamart"},
{"period":"2021-10-01","value":3.69,"source":"datamart"},
{"period":"2022-01-01","value":3.85,"source":"datamart"},
{"period":"2022-04-01","value":4.16,"source":"datamart"},
{"period":"2022-07-01","value":4.25,"source":"datamart"},
{"period":"2022-10-01","value":5.01,"source":"datamart"},
{"period":"2023-01-01","value":5.17,"source":"datamart"},
{"period":"2023-04-01","value":5.17,"source":"datamart"},
{"period":"2023-07-01","value":5.79,"source":"datamart"},
{"period":"2023-10-01","value":5.53,"source":"datamart"},
{"period":"2024-01-01","value":5.54,"source":"datamart"},
{"period":"2024-04-01","value":5.28,"source":"datamart"},
{"period":"2024-07-01","value":5.13,"source":"datamart"},
{"period":"2024-10-01","value":5.13,"source":"datamart"},
{"period":"2025-01-01","value":5.13,"source":"datamart"},
{"period":"2025-04-01","value":5.13,"source":"datamart"},
{"period":"2025-07-01","value":5.13,"source":"datamart"},
{"period":"2025-10-01","value":5.13,"source":"datamart"},
{"period":"2026-01-01","value":5.14,"source":"datamart"},
{"period":"2026-04-01","value":5.14,"source":"datamart"}
],
"history_check": {"count": 24, "first_value": 4.51, "last_value": 5.14},
"caller_disclosures": {
"peer_selection_basis": "market-grain series is the subject itself (Charlotte MSA); no finer-grain multifamily cap rate series exists, subject site sits within this MSA",
"rate_instrument": "fed_funds",
"lookback_note": "full available history (2020Q3-2026Q2, 24 quarterly points) used per directional-mode requirement"
},
"context_signals": {
"rate_environment": {"current_rate": 3.63, "projected_rate": 3.80, "projected_as_of": "2026-09", "note": "fed funds rate actual (Jun 2026) vs SOFR 3M forward projection (Sep 2026)"}
}
}
EOF
python3 skills/forecasting/scripts/forecast.py --file caprate_payload.json > caprate_forecast_result.json
cat caprate_forecast_result.json | python3 -m json.toolcd /vercel/sandbox && python3 -c "
units = {'1BR/1BA':(98,750,1450), '2BR/2BA':(111,1100,1750), '3BR/2BA':(37,1300,2050)}
tot_units=sum(v[0] for v in units.values())
tot_sf=sum(v[0]*v[1] for v in units.values())
tot_rentx=sum(v[0]*v[2] for v in units.values())
print('total units', tot_units)
print('avg sf', tot_sf/tot_units)
print('avg rent', tot_rentx/tot_units)
print('NRA', tot_sf)
gba = tot_sf/0.87
print('GBA est (87% efficiency)', gba)
hard_cost_psf = 190
hard_cost = gba*hard_cost_psf
print('hard cost total', hard_cost, 'per unit', hard_cost/tot_units)
site_mf = 20000*tot_units
ae = 0.05*hard_cost
soft = 0.12*hard_cost
ffe = 3500*tot_units
contingency = 0.05*hard_cost
subtotal = hard_cost+site_mf+ae+soft+ffe+contingency
devfee = 0.04*subtotal
total_no_land = subtotal+devfee
print('site_mf',site_mf,'ae',ae,'soft',soft,'ffe',ffe,'contingency',contingency,'devfee',devfee)
print('total dev cost excl land', total_no_land, 'per unit', total_no_land/tot_units)
"
cd /vercel/sandbox && python3 skills/xlsx/scripts/workbook_extract.py template_clean.xlsx --sheet Assumptions --columns A,B,C,D,E,F,G,H,I,J --rows 1-100 2>/dev/null | python3 -m json.tool | head -300
cd /vercel/sandbox && python3 skills/xlsx/scripts/workbook_extract.py --help 2>&1 | head -40
cd /vercel/sandbox && python3 skills/xlsx/scripts/workbook_extract.py template_clean.xlsx --sheet Assumptions --columns "A:J" --start-row 1 --end-row 100 --output assump1.csv --mode both 2>&1 | tail -5 cat assump1.csv
cd /vercel/sandbox && ls -la template_clean.xlsx; pwd
cd /vercel/sandbox && python3 skills/xlsx/scripts/workbook_extract.py template_clean.xlsx --sheet Assumptions --columns "A:J" --start-row 1 --end-row 100 --output assump1.csv --mode both 2>&1 | tail -5 cat assump1.csv
cd /vercel/sandbox && find . -maxdepth 2 -iname "assump1*" python3 skills/xlsx/scripts/workbook_extract.py template_clean.xlsx --sheet Assumptions --columns "A:J" --start-row 1 --end-row 100 --output output/assump1.csv --mode both 2>&1 | tail -20 ls output/ 2>&1 cat output/assump1.csv 2>&1 | head -100
cd /vercel/sandbox && cat assump1.values.csv
cd /vercel/sandbox && python3 skills/xlsx/scripts/workbook_extract.py template_clean.xlsx --sheet Assumptions --columns "A:J" --start-row 100 --end-row 200 --output out2 --mode both 2>&1 | tail -3 cat out2.values.csv
cd /vercel/sandbox && python3 -c "
import openpyxl
wb = openpyxl.load_workbook('template_clean.xlsx', data_only=False)
ws = wb['Assumptions']
for r in range(1, 200):
b = ws.cell(r,2).value
c = ws.cell(r,3).value
if b:
print(r, repr(b), '|', repr(c))
"
cd /vercel/sandbox && python3 -c "
import openpyxl
wb = openpyxl.load_workbook('template_clean.xlsx', data_only=False)
ws = wb['Assumptions']
for r in range(1, 200):
g = ws.cell(r,7).value
h = ws.cell(r,8).value
if g or h:
print(r, repr(g), '|', repr(h))
"
cd /vercel/sandbox && python3 -c "
import openpyxl
wb = openpyxl.load_workbook('template_clean.xlsx', data_only=False)
ws = wb['Dev Budget']
for r in range(1, 60):
row = [ws.cell(r,c).value for c in range(2,17)]
if any(v is not None for v in row):
print(r, row)
"
cd /vercel/sandbox && python3 -c "
import openpyxl
wb = openpyxl.load_workbook('template_clean.xlsx', data_only=False)
ws = wb['Rent Comps']
for r in range(1, 25):
row = [ws.cell(r,c).value for c in range(2,10)]
if any(v is not None for v in row):
print(r, row)
print('---SALES---')
ws = wb['Sales Comps']
for r in range(1, 35):
row = [ws.cell(r,c).value for c in range(2,10)]
if any(v is not None for v in row):
print(r, row)
"
Find 6 multifamily rent comps for a proposed 246-unit ground-up garden-style multifamily development at 2114 Riverchase Boulevard, Rock Hill, SC 29732 (York County, Charlotte NC MSA, Rock Hill-west submarket). The subject is unbuilt (planned unit mix: 98 1BR/1BA @ 750 SF, 111 2BR/2BA @ 1,100 SF, 37 3BR/2BA @ 1,300 SF; blended ~991 SF).
Load the rental-comps skill and follow its methodology to query the datamart for real multifamily rent comps near this address (start at zip/submarket level, widen if needed). Select 6 high-quality comps prioritizing proximity, unit-type overlap, size, building type (garden-style), and vintage.
Return a table with, for each of the 6 comps: Property Name, Address, City/State/Zip, Distance (mi) from subject, Unit Count, Rentable SF (total), Year Built, Year Renovated (if any), and Average Market Rent ($/unit/mo). Also return the comp-set average market rent. Cite the datamart tool_use_id for each figure so I can register citations.
Method: Loaded rental-comps skill; queried property_mfr (Rock Hill, SC census_place_id=4561405, York County) for mf_property_attributes + mf_rent_and_occupancy_snapshot, filtering to rent_type=MARKET, non-null in_place_rent_latest and unit_count (tool_use_id: toolu_011bE37ReGcVVRegt3Kvtf2P, page 1; toolu_01NnvznWXw28yDdiExV7Nk59, page 2 — 28 eligible candidates total, sufficient at the census-place step per skill threshold, so the county-level widen step was not needed). Subject coordinates (34.9848, -80.9880) geocoded via Google Places (tool_use_id: toolu_01PQUjnQwUAFVMgaLEm3Z1fp). Distances computed via haversine from subject lat/lon to each candidate's mf_property_attributes.latitude/longitude.
Selected 6 comps prioritizing proximity (<1.5 mi), low-density building style (GARDEN/LOW_RISE — same group as subject's garden-style per skill grouping), unit-type rent overlap across 1BR/2BR/3BR (matching subject's planned mix), unit-count tier near subject's 246 units (allowing ±1 tier for the two ~300-470 unit large communities given their exceptional proximity/overlap), and reasonable vintage/quality. All 6 are MARKET rate (no household restrictions flagged as active).
| # | Property Name | Address | City/State/Zip | Distance (mi) | Unit Count | Rentable SF (total) | Year Built | Year Renovated | Avg Market Rent ($/unit/mo, in-place) |
|---|---|---|---|---|---|---|---|---|---|
| 1 | The Heights at Rock Hill | 1914 Paces Landing Ave | Rock Hill, SC 29732 | 0.14 | 230 | 199,870 | 1989 | 2025 | $1,233.15 |
| 2 | Paces River Apartments | 1817 Paces River Ave | Rock Hill, SC 29732 | 0.17 | 470 | 409,840 | 1988 | 2018 | $1,255.61 |
| 3 | The Boulders Apartment Homes | 1304 Stoneypointe Dr | Rock Hill, SC 29732 | 0.92 | 264 | 251,064 | 1996 | 2008 | $1,279.43 |
| 4 | Riverwalk Apartments | 517 Pink Moon Dr | Rock Hill, SC 29730 | 0.94 | 307 | 314,675 | 2016 | — | $1,556.71 |
| 5 | Gable Oaks Apartment Homes | 752 Patriot Pkwy | Rock Hill, SC 29730 | 1.25 | 252 | 195,048 | 1996 | 2010 | $1,320.87 |
| 6 | The Alexander Apartment Homes | 2361 Eden Ter | Rock Hill, SC 29730 | 1.45 | 360 | 384,480 | 1998 | 2024 | $1,451.71 |
Comp-set average market rent (in-place, simple average of the 6): $1,349.58/unit/month (Sum of six in-place rents = $8,097.48; ÷6 = $1,349.58. Underlying rent figures cited from toolu_011bE37ReGcVVRegt3Kvtf2P.)
Notes/caveats:
Find 5-6 multifamily sale comps for a proposed 246-unit ground-up garden-style multifamily development at 2114 Riverchase Boulevard, Rock Hill, SC 29732 (York County, Charlotte NC MSA, Rock Hill-west submarket), to benchmark an eventual stabilized-asset exit sale.
Load the sales-comps skill and follow its methodology to query the datamart for real multifamily sale comps near this address (start at zip/submarket level, widen if needed, prioritize recency - last 24 months preferred). Select 5-6 high-quality comps prioritizing proximity, size, building type (garden-style), and vintage/condition similarity to a newly-built 2029-vintage asset.
Return a table with, for each comp: Property Name, Address, City/State/Zip, Distance (mi), Unit Count, Rentable SF (total), Year Built, Year Renovated (if any), Sale Date, and Sale Price. Also compute Price/Unit for each. Cite the datamart tool_use_id for each figure so I can register citations.
Subject coordinates (geocoded via web search): 34.9858, -80.9887. Query strategy per sales-comps skill: zipcode (29732) returned mostly non-MF single-family parcel noise (only 3 usable MF records with names), so widened to York County, SC (county_id 45091) filtered to unit_count ≥ 50 and sale date ≥ 2021-01-01 — this single query returned 40 rows, satisfying the ≥10-result stop rule. Distances computed via haversine from subject coordinates to each comp's lat/lon (both from the same query_data pull, tool_use_id: toolu_017BxbS2tnYu5LESrPaygdoR).
Hard exclusions applied: affordable/income-restricted and senior-restricted properties (Market Place Apartments, Edgewood Place, The Park at Wilkerson, Brookdale Chandler Place, Courtyard at Highland Park, Traditions at Fort Mill) and MARKET_AND_AFFORDABLE mixed-restriction assets (Village Station Apartments, The Vintage Apartments) were excluded to match the subject's pure market-rate profile. Properties with null sale price (Quail Ridge, Yorktowne Village, Peachtree Place) were also excluded.
| # | Property Name | Address | City/State/Zip | Distance (mi) | Unit Count | Total Rentable SF | Year Built | Year Renovated | Sale Date | Sale Price | Price/Unit |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 1 | Paces River Apartments | 1817 Paces River Ave | Rock Hill, SC 29732 | 0.23 | 470 | 409,840 | 1988 | 2018 | 2025-04-02 | $77,888,547 | $165,720 |
| 2 | The Boulders Apartment Homes | 1304 Stoneypointe Dr | Rock Hill, SC 29732 | 0.95 | 264 | 251,064 | 1996 | 2008 | 2021-06-24 | $34,175,000 | $129,451 |
| 3 | Patriots Crossing | 793 Patriot Pkwy | Rock Hill, SC 29730 | 1.32 | 160 | N/A (not populated) | 1998 | — | 2022-08-19 | $28,623,000 | $178,894 |
| 4 | The Alexander Apartment Homes | 2361 Eden Ter | Rock Hill, SC 29730 | 1.52 | 360 | 384,480 | 1998 | 2024 | 2024-04-12 | $38,150,000 | $105,972 |
| 5 | The Linden Apartment Homes | 2051 Mcgee Rd | Rock Hill, SC 29732 | 2.65 | 144 | 144,000 | 1990 | — | 2024-04-12 | $13,100,000 | $90,972 |
| 6 | Elements at Rock Hill | 708 Glamorgan Way | Rock Hill, SC 29730 | 3.64 | 168 | N/A (not populated) | 2016 | — | 2022-11-02 | $38,100,000 | $226,786 |
Simple average across the 6 comps: ~$149,632/unit. Median: ~$147,562/unit.
All identity, physical attribute, and sale figures above trace to tool_use_id toolu_017BxbS2tnYu5LESrPaygdoR (query_data, entity=property_mfr, topics=mf_sales_history + mf_property_attributes, filtered on county_id=45091 "York County, SC", unit_count≥50, latest_sale_date≥2021-01-01). Subject coordinates used for distance calculation were sourced via web search (tool_use_id toolu_01Byg6F1GrMmtCJABBoHDbSR), not from the datamart, since the subject is a land parcel not yet in property_mfr.
import openpyxl
from datetime import datetime
path = "template_clean.xlsx"
wb = openpyxl.load_workbook(path, data_only=False)
A = wb["Assumptions"]
DB = wb["Dev Budget"]
RC = wb["Rent Comps"]
SC = wb["Sales Comps"]
def setc(ws, cell, value):
ws[cell] = value
# ---------------- ASSUMPTIONS: scalars ----------------
scalars = {
"C5": "Marvell Rock Hill \u2014 Multifamily (246 Units)",
"C6": "2114 Riverchase Boulevard",
"C7": "Rock Hill, SC 29732",
"C8": "Multifamily",
"C9": "Per Unit",
"C10": 246,
"C11": 280100, # GBA (SF) - ai_estimate, ~87% efficiency vs NRA
"C12": 13.0, # Site Area (Acres) - MF-only development pad within 42.14-ac master site, ai_estimate
"C14": 369, # Parking spaces - MF-only allocation (~1.5/unit) of the 392 site-wide total
# Land & acquisition
"C24": "Land",
# Timing & exit
"C35": datetime(2026, 10, 1),
"C36": 3,
"C37": 22,
"C38": "Pace",
"C39": 15,
"C40": None,
"C41": "S-Curve",
"C42": 4,
"C43": 0.95,
"C44": "Hold to Year N",
"C45": 10,
"C46": 0,
# Dev cost drivers
"C59": 0.035,
"C62": 0.05,
"C63": 0.04,
# Exit / disposition
"C66": 0.054,
"C67": 0.02,
"C68": 0.0515,
# Construction financing - senior
"C75": 0.60,
"C76": "SOFR + Spread",
"C78": None,
"C79": 0.0374,
"C80": 300,
"C81": 0,
"C83": 0.01,
"C84": 50000,
"C85": 0,
"C86": "Pari Passu",
# Mezzanine (construction) - disabled
"C98": "No",
"C100": None,
"C101": None,
"C102": None,
# Permanent financing
"C108": "Lesser of LTV & DSCR",
"C110": 0.65,
"C111": 1.25,
"C112": 0.0582,
"C113": 6,
"C114": 30,
"C115": 0.01,
# Perm mezzanine - disabled
"C126": "No",
"C128": None,
"C129": None,
"C130": None,
"C131": None,
# Waterfall
"C140": "Yes",
"C142": 0.10,
"C146": 0.08,
"C147": 0.12,
"C148": 0.20,
"C149": 0.18,
"C150": 0.30,
"C151": 0.40,
# Growth (col H)
"H28": "No",
"H31": 0.03,
"H32": 0.03,
"H33": 0.033,
"H34": 1,
# Stabilized operations (col H)
"H37": 0.01,
"H38": 0.01,
"H39": 0.03,
"H40": 0.02,
"H41": 96,
"H42": 250,
"H43": 150,
# Leasing, turnover & recoveries (col H)
"H46": "Gross (No Recovery)",
"H49": None,
"H52": 0.25,
"H53": 1500,
"H55": None, "H56": None, "H57": None, "H58": None, "H59": None, "H60": None, "H61": None,
}
for cell, val in scalars.items():
setc(A, cell, val)
# ---------------- ASSUMPTIONS: Unit Mix (rows 164-173) ----------------
unit_mix_rows = [
("1 Bedroom / 1 Bath", 98, 750, 1450, None, None, None, None),
("2 Bedroom / 2 Bath", 111, 1100, 1750, None, None, None, None),
("3 Bedroom / 2 Bath", 37, 1300, 2050, None, None, None, None),
]
for i, r in enumerate(range(164, 174)):
if i < len(unit_mix_rows):
vals = unit_mix_rows[i]
A.cell(r, 2, vals[0]) # B Unit Type
A.cell(r, 3, vals[1]) # C Count
A.cell(r, 4, vals[2]) # D Size SF
A.cell(r, 5, vals[3]) # E Rent
A.cell(r, 6, vals[4]) # F Lease Start
A.cell(r, 7, vals[5]) # G Term
A.cell(r, 8, vals[6]) # H Free Rent
A.cell(r, 9, vals[7]) # I Escalation
else:
for c in range(2, 10):
A.cell(r, c, None)
# ---------------- ASSUMPTIONS: OpEx table (rows 180-189) ----------------
opex_rows = [
("Real Estate Taxes", "$ / Unit / Yr", 2900, "No"),
("Property Insurance", "% of EGI", 0.0334, "No"),
("Payroll & Benefits", "% of EGI", 0.0940, "No"),
("Repairs & Maintenance", "% of EGI", 0.0538, "No"),
("Utilities (Owner-Paid)", "$ / Unit / Yr", 500, "No"),
("Marketing & Advertising", "% of EGI", 0.0109, "No"),
("General & Administrative", "% of EGI", 0.0263, "No"),
("Management Fee", "% of EGI", 0.0319, "No"),
("Other Expenses", "% of EGI", 0.0000, "No"),
]
for i, r in enumerate(range(180, 190)):
if i < len(opex_rows):
label, basis, val, recov = opex_rows[i]
A.cell(r, 2, label) # B Expense Line
A.cell(r, 3, basis) # C Basis
A.cell(r, 4, val) # D Input
A.cell(r, 9, recov) # I Recoverable?
else:
A.cell(r, 2, None); A.cell(r, 3, None); A.cell(r, 4, None); A.cell(r, 9, None)
wb.save(path)
print("Assumptions sheet written.")
cd /vercel/sandbox && python3 populate_model.py
Now the Dev Budget and comp tables:
import openpyxl
from datetime import datetime
path = "template_clean.xlsx"
wb = openpyxl.load_workbook(path, data_only=False)
DB = wb["Dev Budget"]
RC = wb["Rent Comps"]
SC = wb["Sales Comps"]
# ---------------- DEV BUDGET ----------------
# Clear all item rows first (B,C,D,H,I,J,K,L,M) across all 7 categories, preserve header/subtotal rows
item_rows = list(range(7,13)) + list(range(14,20)) + list(range(21,27)) + list(range(28,34)) + list(range(35,41)) + list(range(42,48)) + list(range(49,55))
for r in item_rows:
for c in [2,3,4,8,9,10,11,12]: # B,C,D,H,I,J,K,L
DB.cell(r, c, None)
# Now write the specific line items (keeping existing phase/timing defaults where sensible)
def set_item(row, item, basis, input_val, escalate, phase, start_mo, length_mo, method="Straight-Line", steep=5):
DB.cell(row, 2, item)
DB.cell(row, 3, basis)
DB.cell(row, 4, input_val)
DB.cell(row, 8, escalate)
DB.cell(row, 9, phase)
DB.cell(row, 10, start_mo)
DB.cell(row, 11, length_mo)
DB.cell(row, 12, method)
DB.cell(row, 13, steep)
# 1. Construction Costs
set_item(7, "Vertical Construction \u2014 Hard Costs", "$ per Unit", 190000, "Yes", "Construction", 1, 22, "S-Curve", 5)
# 2. Architecture & Engineering
set_item(14, "Architecture & Engineering Fees", "% of Hard Cost", 0.05, "No", "Pre-Development", 1, 3, "Straight-Line", 5)
# 3. Site Improvements (NOTE: Blythe site-work bid excludes the MF parcel - "multi-family will be bid at a later date" -
# this is a market-calibrated estimate, not the contractor bid)
set_item(21, "Sitework, Utilities & Paving (MF Pod)", "$ per Unit", 18000, "Yes", "Construction", 1, 22, "S-Curve", 5)
# 4. Soft Costs
set_item(28, "Permits, Fees & Impact Fees", "$ Total", 1200000, "No", "Pre-Development", 1, 3, "Straight-Line", 5)
set_item(29, "Legal & Closing Costs", "$ Total", 300000, "No", "Pre-Development", 1, 3, "Straight-Line", 5)
set_item(30, "Builder's Risk & Liability Insurance", "$ Total", 400000, "No", "Construction", 1, 22, "Straight-Line", 5)
set_item(31, "Property Taxes During Construction", "$ Total", 350000, "No", "Construction", 1, 22, "Straight-Line", 5)
set_item(32, "Developer Fee", "% of Total Cost", "=Assumptions!$C$63", "No", "Construction", 1, 22, "Straight-Line", 5)
set_item(33, "Marketing & Lease-Up Costs", "$ Total", 750000, "No", "Construction", 15, 8, "Straight-Line", 5)
# 5. FF&E
set_item(35, "Unit Appliances, Fixtures & Finishes", "$ Total", 400000, "No", "Construction", 15, 8, "Straight-Line", 5)
set_item(36, "Amenity & Clubhouse FF&E", "$ Total", 200000, "No", "Construction", 18, 5, "Straight-Line", 5)
set_item(37, "Leasing Office / Model Unit FF&E", "$ Total", 138000, "No", "Construction", 18, 5, "Straight-Line", 5)
# 6. Land / Acquisition
set_item(42, "Land Acquisition \u2014 246-Unit MF Parcel", "$ Total", 2500000, "No", "Pre-Development", 1, 1, "Straight-Line", 5)
set_item(43, "Closing Costs & Title", "$ Total", 75000, "No", "Pre-Development", 1, 1, "Straight-Line", 5)
# 7. Other & Contingency
set_item(49, "Contingency", "% of Hard Cost", "=Assumptions!$C$62", "No", "Construction", 1, 22, "Straight-Line", 5)
wb.save(path)
print("Dev Budget written.")
# ---------------- RENT COMPS ----------------
rent_comps = [
# name, address, city_state_zip, dist, units, sf, yrbuilt, yrrenov, avgrent
("The Heights at Rock Hill", "1914 Paces Landing Ave", "Rock Hill, SC 29732", 0.14, 230, 199870, 1989, 2025, 1233.15),
("Paces River Apartments", "1817 Paces River Ave", "Rock Hill, SC 29732", 0.17, 470, 409840, 1988, 2018, 1255.61),
("The Boulders Apartment Homes", "1304 Stoneypointe Dr", "Rock Hill, SC 29732", 0.92, 264, 251064, 1996, 2008, 1279.43),
("Riverwalk Apartments", "517 Pink Moon Dr", "Rock Hill, SC 29730", 0.94, 307, 314675, 2016, None, 1556.71),
("Gable Oaks Apartment Homes", "752 Patriot Pkwy", "Rock Hill, SC 29730", 1.25, 252, 195048, 1996, 2010, 1320.87),
("The Alexander Apartment Homes", "2361 Eden Ter", "Rock Hill, SC 29730", 1.45, 360, 384480, 1998, 2024, 1451.71),
]
cols = ["D","E","F","G","H","I"]
for i, comp in enumerate(rent_comps):
col = cols[i]
RC[f"{col}6"] = comp[0]
RC[f"{col}7"] = comp[1]
RC[f"{col}8"] = comp[2]
RC[f"{col}9"] = comp[3]
RC[f"{col}10"] = comp[4]
RC[f"{col}11"] = comp[5]
RC[f"{col}12"] = comp[6]
RC[f"{col}13"] = comp[7]
RC[f"{col}14"] = comp[8]
RC["C19"] = 0.15 # Adjustment for New Construction %
wb.save(path)
print("Rent Comps written.")
# ---------------- SALES COMPS ----------------
sales_comps = [
# name, address, city_state_zip, dist, units, sf(or None), yrbuilt, yrrenov, saledate, saleprice, adj_time, adj_size, adj_year, adj_loc, adj_mkt, weight
("Paces River Apartments", "1817 Paces River Ave", "Rock Hill, SC 29732", 0.23, 470, 409840, 1988, 2018, datetime(2025,4,2), 77888547, 0.01, -0.03, 0.18, 0.00, 0.01, 0.30),
("The Boulders Apartment Homes", "1304 Stoneypointe Dr", "Rock Hill, SC 29732", 0.95, 264, 251064, 1996, 2008, datetime(2021,6,24), 34175000, 0.03, 0.00, 0.20, -0.01, 0.03, 0.20),
("Patriots Crossing", "793 Patriot Pkwy", "Rock Hill, SC 29730", 1.32, 160, None, 1998, None, datetime(2022,8,19), 28623000, 0.02, 0.03, 0.22, -0.01, 0.02, 0.10),
("The Alexander Apartment Homes", "2361 Eden Ter", "Rock Hill, SC 29730", 1.52, 360, 384480, 1998, 2024, datetime(2024,4,12), 38150000, 0.01, -0.02, 0.12, -0.01, 0.01, 0.20),
("The Linden Apartment Homes", "2051 McGee Rd", "Rock Hill, SC 29732", 2.65, 144, 144000, 1990, None, datetime(2024,4,12), 13100000, 0.01, 0.05, 0.22, -0.02, 0.01, 0.10),
("Elements at Rock Hill", "708 Glamorgan Way", "Rock Hill, SC 29730", 3.64, 168, None, 2016, None, datetime(2022,11,2), 38100000, 0.02, 0.03, 0.10, -0.02, 0.02, 0.10),
]
for i, comp in enumerate(sales_comps):
col = cols[i]
(name, addr, csz, dist, units, sf, yrb, yrr, saledate, saleprice,
adj_time, adj_size, adj_year, adj_loc, adj_mkt, weight) = comp
SC[f"{col}6"] = name
SC[f"{col}7"] = addr
SC[f"{col}8"] = csz
SC[f"{col}9"] = dist
SC[f"{col}10"] = units
SC[f"{col}11"] = sf
SC[f"{col}12"] = yrb
SC[f"{col}13"] = yrr
SC[f"{col}16"] = saledate
SC[f"{col}17"] = saleprice
SC[f"{col}21"] = adj_time
SC[f"{col}22"] = adj_size
SC[f"{col}23"] = adj_year
SC[f"{col}24"] = adj_loc
SC[f"{col}25"] = adj_mkt
SC[f"{col}26"] = f"=SUM({col}21:{col}25)"
SC[f"{col}27"] = weight
wb.save(path)
print("Sales Comps written.")
cd /vercel/sandbox && python3 populate_devbudget_comps.py
Now recalc and validate the model checks.
cd /vercel/sandbox && python3 skills/xlsx/scripts/recalc.py template_clean.xlsx 2>&1 | tail -30
Zero formula errors. Now let's check the model's own validation checks and headline outputs.
cd /vercel/sandbox && python3 -c "
import openpyxl
wb = openpyxl.load_workbook('template_clean.xlsx', data_only=True)
A = wb['Assumptions']
checks = {
'H64 Sources&Uses Balance': A['H64'].value,
'H65 Budget Spread Tie-Out': A['H65'].value,
'H66 Loan Sizing Convergence': A['H66'].value,
'H67 LTC on Target': A['H67'].value,
'H68 Unlevered CF Monthly=Annual': A['H68'].value,
'H69 Levered CF Monthly=Annual': A['H69'].value,
'H70 Per-Year Monthly=Annual': A['H70'].value,
'H71 Waterfall LP+GP Tie-Out': A['H71'].value,
'H72 Roster Total=NRA': A['H72'].value,
'H73 OpEx Table Ties': A['H73'].value,
'H74 Lease-Up Pace Plausible': A['H74'].value,
'H75 Total LTC >= Senior LTC': A['H75'].value,
}
for k,v in checks.items():
print(k, '->', v)
print()
print('NRA C13', A['C13'].value)
print('GBA C11', A['C11'].value)
print('Total Dev Cost H17', A['H17'].value)
print('Cost/Unit H18', A['H18'].value)
print('Cost/SF H19', A['H19'].value)
print('Peak Equity H16', A['H16'].value)
print('Unlevered IRR H10', A['H10'].value)
print('Unlevered EM H12', A['H12'].value)
print('Levered IRR H13', A['H13'].value)
print('Levered EM H15', A['H15'].value)
print('Dev Yield Trended H5', A['H5'].value)
print('Dev Yield Untrended H6', A['H6'].value)
print('Market Cap H7', A['H7'].value)
print('Dev Spread Trended (bps) H8', A['H8'].value)
print('Dev Spread Untrended (bps) H9', A['H9'].value)
print('Stab DSCR Senior H20', A['H20'].value)
print('Stab DSCR Combined H21', A['H21'].value)
print('Stab Debt Yield H22', A['H22'].value)
"
cd /vercel/sandbox && python3 -c "
import openpyxl
wb = openpyxl.load_workbook('template_clean.xlsx', data_only=True)
DB = wb['Dev Budget']
for r in [6,13,20,27,34,41,48,56,57,58]:
print(r, DB.cell(r,2).value, '| Amount', DB.cell(r,5).value, '| Tie-out', DB.cell(r,16).value)
print()
RS = wb['Returns Summary']
for r in range(35,70):
b = RS.cell(r,2).value
c = RS.cell(r,3).value
if b:
print(r, b, '->', c)
"
cd /vercel/sandbox && python3 -c "
import openpyxl
wb = openpyxl.load_workbook('template_clean.xlsx', data_only=True)
BE = wb['Break-Even']
for r in [11,12,20,21,29,35,36,37,38,39]:
print(r, BE.cell(r,2).value, '->', BE.cell(r,3).value)
print()
WF = wb['Waterfall']
for r in range(66,82):
row=[WF.cell(r,c).value for c in range(2,6)]
if any(v is not None for v in row):
print(r,row)
"
cd /vercel/sandbox && python3 -c "
import openpyxl
wb = openpyxl.load_workbook('template_clean.xlsx', data_only=True)
A = wb['Assumptions']
print('LTC Senior actual C92', A['C92'].value)
print('LTC Total Debt actual C93', A['C93'].value)
print('Senior Loan Amount C88', A['C88'].value)
print('Permanent Loan Amount C120', A['C120'].value)
print('Peak Equity Requirement C91/H16', A['C91'].value, A['H16'].value)
print('Cost/Unit', A['H18'].value, 'Cost/SF', A['H19'].value)
"
python3 skills/xlsx/scripts/workbook_integrity_scan.py template_clean.xlsx --json integrity.json
echo "exit code: $?"
cat integrity.json | python3 -c "
import json,sys
d=json.load(sys.stdin)
print('total findings', len(d.get('findings',[])))
highs=[f for f in d.get('findings',[]) if f.get('severity')=='high']
print('HIGH count', len(highs))
for f in highs[:20]:
print(f.get('sheet'), f.get('cell'), f.get('type'), f.get('message',f.get('description','')))
"Now let me write the deliverable with citations.
cd /vercel/sandbox && python3 -c "
import openpyxl
wb = openpyxl.load_workbook('template_clean.xlsx', data_only=True)
RS = wb['Returns Summary']
print('C37', RS['C37'].value)
print('C39', RS['C39'].value)
print('C44', RS['C44'].value)
print('C46', RS['C46'].value)
print('C54', RS['C54'].value)
print('C56', RS['C56'].value)
print('C58', RS['C58'].value)
print('C60', RS['C60'].value)
print('C61', RS['C61'].value)
print('C65', RS['C65'].value)
print('C66', RS['C66'].value)
print('C67', RS['C67'].value)
print('C69', RS['C69'].value)
BE = wb['Break-Even']
print('BE C20', BE['C20'].value)
print('BE C21', BE['C21'].value)
print('BE C37', BE['C37'].value)
WF = wb['Waterfall']
print('WF C71', WF['C71'].value)
print('WF C78', WF['C78'].value, WF['C79'].value, WF['C80'].value)
DB = wb['Dev Budget']
print('DB E41 land total', DB['E41'].value)
print('DB E6 hard cost', DB['E6'].value)
A = wb['Assumptions']
print('H17 total dev cost', A['H17'].value)
print('H18 cost/unit', A['H18'].value)
"
Ground-Up Development Model — Marvell Rock Hill (246-Unit Multifamily), 2114 Riverchase Boulevard, Rock Hill, SC
The model is built and fully populated — but the deal as underwritten doesn't clear. At a market-calibrated cost basis, this project produces a -69 bps development spread (trended) against the Charlotte MSA multifamily market cap rate, a 1.9% levered IRR over the 10-year hold, and zero GP promote — the waterfall never gets the LP past its 8% preferred return.
| Metric | Value |
|---|---|
| Total Development Cost | $68,906,2071 |
| Cost per Unit | $280,107 |
| Cost per SF GBA | ~$246 |
| Land Basis | $2,575,000 incl. closing costs |
| Development Yield on Cost - Trended | 4.46% |
| Development Yield on Cost - Untrended | 3.99% |
| Market Cap Rate - Stabilized | 5.15% |
| Development Spread - Trended | -69 bps |
| Development Spread - Untrended | -116 bps |
| Required Development Spread | 150 bps |
| Required Yield on Cost to Clear Spread | 6.65% |
| Supportable Total Development Cost at Required Spread | $46,170,156 |
| Unlevered IRR | 3.60% |
| Unlevered Equity Multiple | 1.31x |
| Levered IRR | 1.95% |
| Levered Equity Multiple | 1.11x |
| Peak Equity Requirement | $68,906,207 |
| Stabilized DSCR - Senior | 1.25x |
| Break-Even Cost Overrun | -13.5%, already over budget at zero tolerance |
| Break-Even Rent for Zero Spread | 111.9% of underwritten rent |
Why it doesn't clear. The site is genuinely well-positioned — zero market-rate apartment supply under construction in Rock Hill, 1,500+ announced jobs at Palmetto Research Park (Novant Health, Octapharma), and comps averaging $1,677/unit on ~984 SF — but at today's construction-cost and cap-rate environment, a $280K/unit all-in basis (hard cost ~$190K/unit + site work ~$18K/unit + soft costs/FF&E/contingency/fee) doesn't generate enough stabilized NOI relative to a 5.15% market cap rate . The Supportable Land/Acquisition Basis the model solves for — the land price at which a 150bp spread over market cap clears — is $0 : even acquiring the land for free wouldn't fix the spread at this cost basis. The gap is on the cost and rent-growth side, not the land price.
What's driving the softness, and what would move it:
Key assumptions behind these figures (flagged where estimated): unit mix (98 1BR/750SF, 111 2BR/1,100SF, 37 3BR/1,300SF) is an estimate calibrated to the OM's rent comps, since no unit-mix breakdown exists in the deal documents; land price ($2.5M) and hold period (10 years) were your inputs; hard-cost escalation (3.5%/yr), stabilized occupancy (95%), and exit/market cap rates (5.40%/5.15%) were confirmed with you against datamart and forecast-engine reads.
The full model — Assumptions, Dev Budget, Draw Schedule, Sources & Uses, Pro Forma, Debt Schedule, Returns Summary, Waterfall, Sensitivity, Break-Even, Rent Comps, and Sales Comps — is live and linked; every check (Sources & Uses balance, budget tie-out, loan sizing convergence, LTC targets, roster/NRA tie-out, waterfall tie-out) reads PASS with zero formula errors. Every input cell is exposed on the Assumptions and Dev Budget tabs so you can flex land price, hard cost, rent growth, or exit cap directly and watch the spread move.
Happy to run a goal-seek on any single lever — e.g., "what hard-cost-per-unit clears a 150bp spread" or "what exit cap makes this work at $190K/unit hard cost" — once you have a real GC number for the multifamily buildings.
This is a read-only snapshot of a RealAI analysis.