We are going to build a pitch book for Hanover, one slide at a time. The analysis you perform will build to the final deliverable, so wait until I tell you to build the entire deck. We will start with the opening analysis, that will eventually form the first content slide:
For ZIP 01810 (Andover, MA), build the demand section of an equity-raise deck for a 334-unit market-rate community at 300 Minuteman Road: median household income, renter household income, share of households earning $150K or more, net worth tier versus the Boston MSA, and the median household income of people moving into 01810 versus moving out. Write three investment-highlight bullets from it.
mkdir -p hanover && cat > hanover/demand_calcs_01810.py <<'EOF'
# Demand-section calcs: ZIP 01810 vs Boston MSA (14460). Inputs from RealAI Datamart (SuperCensus, as of 2026-08-15)
zip_ = dict(hhi_med=180672, hhi_avg=211050, renter=95223, owner=216283, msa_pct=82,
br={'lt150':[0,0,0,.003973,.008035,.050592,.076373,.192831], '150_200':.061363,'200_350':.184090,'350_500':.144358,'500p':.278386},
nw={'0_25':.0496,'25_50':.0182,'50_75':.0128,'75_100':.0112,'100_150':.0210,'150_250':.0333,'250_500':.0697,'500_750':.0629,'750_1m':.0842,'1m_2m':.1442,'2m_3m':.1824,'3m':.3105},
mig_in=297090, mig_out=251631, n_in=1163, n_out=1284, age_in=41, age_out=52, nw_in=6.1, nw_out=8.1, net=-121, net_pct=-.004821)
msa = dict(hhi_med=124803, hhi_avg=196182, renter=74832,
br={'lt150':[.00106,.003668,.013787,.019218,.046793,.115057,.121731,.213173], '150_200':.051479,'200_350':.180997,'350_500':.113903,'500p':.119133},
nw={'0_25':.1180,'25_50':.0378,'50_75':.0277,'75_100':.0248,'100_150':.0475,'150_250':.0714,'250_500':.1294,'500_750':.0890,'750_1m':.0919,'1m_2m':.1144,'2m_3m':.1059,'3m':.1421},
mig_in=147010, mig_out=166331)
def s150(d): b=d['br']; return b['150_200']+b['200_350']+b['350_500']+b['500p']
def nw1m(d): n=d['nw']; return n['1m_2m']+n['2m_3m']+n['3m']
for k,d in [('01810',zip_),('Boston MSA',msa)]:
print(k, f"150K+ share={s150(d):.1%} 200K+={s150(d)-d['br']['150_200']:.1%} NW $1M+={nw1m(d):.1%} NW<$100K={sum(list(d['nw'].values())[:4]):.1%}")
print(f"Median HHI premium vs MSA: {zip_['hhi_med']/msa['hhi_med']-1:.1%} (${zip_['hhi_med']-msa['hhi_med']:,})")
print(f"Renter median premium vs MSA: {zip_['renter']/msa['renter']-1:.1%} (${zip_['renter']-msa['renter']:,})")
print(f"Renter/owner ratio 01810: {zip_['renter']/zip_['owner']:.1%}")
print(f"150K+ share gap vs MSA: {(s150(zip_)-s150(msa))*100:.1f} pts")
print(f"NW $1M+ gap vs MSA: {(nw1m(zip_)-nw1m(msa))*100:.1f} pts")
print(f"Migration: in ${zip_['mig_in']:,} vs out ${zip_['mig_out']:,}; delta ${zip_['mig_in']-zip_['mig_out']:,} ({zip_['mig_in']/zip_['mig_out']-1:.1%})")
print(f"MSA migration delta ${msa['mig_in']-msa['mig_out']:,}")
print(f"In-mover median vs resident median: {zip_['mig_in']/zip_['hhi_med']-1:.1%}")
# 30% rent-to-income affordability at renter median
print(f"Renter median supports rent at 30% RTI: ${zip_['renter']*.30/12:,.0f}/mo; in-mover median: ${zip_['mig_in']*.30/12:,.0f}/mo")
# Consistency check: bracket distribution vs reported median/avg
cum=sum(zip_['br']['lt150']); print(f"Check 01810: share below $150K={cum:.1%}, below $200K={cum+zip_['br']['150_200']:.1%} -> bracket-implied median lies in $200K-$350K band, reported median ${zip_['hhi_med']:,}")
lb = 150e3*zip_['br']['150_200']+200e3*zip_['br']['200_350']+350e3*zip_['br']['350_500']+500e3*zip_['br']['500p']
print(f"Check 01810: lower-bound mean from $150K+ brackets alone=${lb:,.0f} vs reported avg ${zip_['hhi_avg']:,}")
EOF
python3 hanover/demand_calcs_01810.pyAndover 01810's demand story is newcomer income, not population growth. People moving in earn $45K more than people moving out, while the Boston MSA as a whole is losing income through migration.
Demand scorecard: ZIP 01810 vs. Boston MSA
| Metric | 01810 Advantage | ||
|---|---|---|---|
| Median household income | $180,672 | $124,803 | +44.8% |
| Renter median household income | $95,223 | $74,832 | +27.2% |
| Households earning $150K+ (SuperCensus brackets) | 66.8%1 | 46.6% | +20.3 pts |
| Households earning $200K+ (ACS 2024 5-yr) | 44% | — | — |
| Median net worth tier | Tier 9 ($1M–$2M) | Tier 7 ($500K–$750K) | Two tiers higher |
| Households with net worth $1M+ | 63.7% | 36.2% | +27.5 pts |
| In-mover median HHI | $297,0902 | $147,0102 | — |
| Out-mover median HHI | $251,631 | $166,331 | — |
| Migration income delta (in minus out) | +$45,459 | –$19,321 | 01810 gains income; MSA loses it |
What the numbers say
Draft investment highlights
Before these go on a slide
When you're ready, the next slide could cover supply and rent comps.
yes plan slide 2 as a rent comp slide for a new 334-unit community at 300 Minuteman Road, Andover, MA: multifamily properties of 120+ units in the Andover submarket built 2016 or later. For each, show units, year built, rent type, in-place rent by bedroom and per square foot, and occupancy, against the Andover submarket's in-place rent by bedroom. Then count how many 120+ unit properties have delivered in the submarket since 2016
slide 3 would cover For ZIP 01810 (Andover, MA), chart median asking rent over the last 12 months and show current occupancy, average new-lease tradeout, and median days on market for leases signed in the past 30 days
cat > hanover/slides2_3_calcs.py <<'EOF'
# Slide 2 comp premiums vs Andover submarket; Slide 3 ZIP 01810 12-mo asking trend. Source: RealAI Rent Index, as of 2026-09-26
sub = {'1':2437.07,'2':2764.10,'3':3617.50,'avg':2798.95,'psf':2.66}
comps = {'The Slate at Andover (225u, 2016)':{'1':2408.32,'2':2962.80,'3':4171.46,'avg':2719.28,'psf':2.81,'occ':.9556},
'Berry Farms (196u, 2016)':{'1':2515.42,'2':2991.56,'3':3736.07,'avg':2848.11,'psf':2.98,'occ':.9337}}
for n,c in comps.items():
print(n, ' | '.join(f"{k}BR {c[k]/sub[k]-1:+.1%}" for k in '123'), f"| avg {c['avg']/sub['avg']-1:+.1%} | psf {c['psf']/sub['psf']-1:+.1%}")
for k in '123':
a=(comps['The Slate at Andover (225u, 2016)'][k]+comps['Berry Farms (196u, 2016)'][k])/2
print(f"Comp-avg {k}BR ${a:,.0f} vs submarket ${sub[k]:,.0f}: {a/sub[k]-1:+.1%}")
print("Comp-set weighted occupancy:", f"{(.9556*225+.9337*196)/421:.1%}", "vs submarket 93.2%")
# Slide 3: monthly median asking, Sep-2025..Aug-2026
ts = {'2025-09':2732,'2025-10':2750,'2025-11':2620,'2025-12':2590,'2026-01':2565,'2026-02':2628,'2026-03':2640.5,'2026-04':2640,'2026-05':2800,'2026-06':2824,'2026-07':2700,'2026-08':2621}
v=list(ts.values())
print(f"12-mo window Sep25->Aug26: {v[-1]/v[0]-1:+.1%}; peak ${max(v):,.0f} (Jun-26), trough ${min(v):,.0f} (Jan-26); trough->peak {max(v)/min(v)-1:+.1%}")
print(f"Current snapshot (9/26/26) median asking $2,820 vs in-place median $2,559: spread {2820/2559-1:+.1%}")
tr = {'2026-03':(.0305,13),'2026-04':(.004858,26),'2026-05':(.032766,23),'2026-06':(.051478,28),'2026-07':(.035834,18),'2026-08':(.059155,23)}
w=sum(p*n for p,n in tr.values())/sum(n for _,n in tr.values())
print(f"Mar-Aug 2026 lease-weighted new-lease tradeout: {w:+.1%} on {sum(n for _,n in tr.values())} leases")
EOF
python3 hanover/slides2_3_calcs.pyNow the calculations.
mkdir -p hanover && cat > hanover/slides2_3_calcs.py <<'EOF'
# Slide 2 comp premiums vs Andover submarket; Slide 3 ZIP 01810 trends. Source: RealAI Rent Index as of 2026-09-26
sub = {'1':2437.07,'2':2764.10,'3':3617.50,'avg':2798.95,'psf':2.66}
comps = { # name: units, yr, 1BR,2BR,3BR, avg, psf, occ
'The Slate at Andover':(225,2016,2408.32,2962.80,4171.46,2719.28,2.81,.9556),
'Berry Farms':(196,2016,2515.42,2991.56,3736.07,2848.11,2.98,.9337),
'The Point at Merrimack River*':(248,2018,2561.99,3156.15,3886.40,2834.95,2.94,.9677),
'Haven North Andover*':(192,2019,2512.29,3176.32,None,2836.58,2.71,.9531),
'Avalon North Andover*':(221,2022,2475.56,2992.63,4051.36,2763.34,2.95,.9502),
'505 Sutton*':(136,2022,2799.66,3248.26,None,2854.92,3.26,.8676)}
def prem(v,k): return None if v is None else v/sub[k]-1
for n,c in comps.items():
p=[prem(c[2],'1'),prem(c[3],'2'),prem(c[4],'3')]
print(f"{n:32s} 1BR {p[0]:+.1%} | 2BR {p[1]:+.1%} | 3BR {'n/a' if p[2] is None else f'{p[2]:+.1%}'} | avg {c[5]/sub['avg']-1:+.1%} | psf {c[6]/sub['psf']-1:+.1%}")
def summ(names,label):
sel=[comps[n] for n in names]; U=sum(s[0] for s in sel)
out=[label, f"units={U}", f"wtd occ={sum(s[0]*s[7] for s in sel)/U:.1%}", f"wtd avg in-place=${sum(s[0]*s[5] for s in sel)/U:,.0f}"]
for i,k in [(2,'1'),(3,'2'),(4,'3')]:
v=[s[i] for s in sel if s[i]]; a=sum(v)/len(v); out.append(f"{k}BR simple avg ${a:,.0f} ({a/sub[k]-1:+.1%} vs sub, n={len(v)})")
print(' | '.join(out))
summ(['The Slate at Andover','Berry Farms'],'Strict (Andover submarket)')
summ(list(comps),'Expanded (6 props)')
# Slide 3
ts = {'2025-09':2732,'2025-10':2750,'2025-11':2620,'2025-12':2590,'2026-01':2565,'2026-02':2628,'2026-03':2640.5,'2026-04':2640,'2026-05':2800,'2026-06':2824,'2026-07':2700,'2026-08':2621}
v=list(ts.values())
print(f"Sep25->Aug26 median asking: {v[-1]/v[0]-1:+.1%}; trough ${min(v):,.0f} Jan-26, peak ${max(v):,.0f} Jun-26 ({max(v)/min(v)-1:+.1%})")
print(f"Current (9/26/26) median asking $2,820 vs in-place median $2,559: spread {2820/2559-1:+.1%}")
tr={'2026-03':(.0305,13),'2026-04':(.004858,26),'2026-05':(.032766,23),'2026-06':(.051478,28),'2026-07':(.035834,18),'2026-08':(.059155,23)}
print(f"Mar-Aug 2026 lease-weighted new-lease tradeout: {sum(p*n for p,n in tr.values())/sum(n for _,n in tr.values()):+.1%} on {sum(n for _,n in tr.values())} leases")
tr2={'2025-09':(-.002938,11),'2025-10':(.04985,10),'2025-11':(-.021016,14),'2025-12':(-.003027,160),'2026-01':(.000813,20),'2026-02':(-.010955,14)}
print(f"Sep25-Feb26 lease-weighted tradeout: {sum(p*n for p,n in tr2.values())/sum(n for _,n in tr2.values()):+.1%} on {sum(n for _,n in tr2.values())} leases")
EOF
python3 hanover/slides2_3_calcs.pySlide 2: Rent comps
Under the Datamart's "Andover" submarket, only 2 properties with 120+ units have been built since 2016, and both opened in 2016 . That count is too narrow for the slide. The Datamart puts four more 2018–2022 deliveries in the neighboring "Lawrence" submarket , including The Point at Merrimack River, which is in ZIP 01810. Counting them, 6 properties (1,218 units) have delivered across Andover and North Andover since 2016, with none completed since 2022 . The comp set below shows all six, with the four reassigned properties flagged.
| Property | City / ZIP | Datamart Submarket | Units | Year Built | Rent Type | 1BR In-Place | 2BR In-Place | 3BR In-Place | Avg In-Place $/SF | Occupancy |
|---|---|---|---|---|---|---|---|---|---|---|
| Andover submarket (strict criteria) | ||||||||||
| Andover 01810 | Andover | 225 | 2016 | Market & Affordable | $2,408 ($2.94/SF) | $2,963 ($2.58/SF) | $4,171 ($2.76/SF) | $2.81 | 95.6% | |
| North Andover 01845 | Andover | 196 | 2016 | Market | $2,515 ($3.18) | $2,992 ($2.80) | $3,736 ($3.05) | $2.98 | 93.4% | |
| Same towns, Datamart assigns to Lawrence submarket | ||||||||||
| Andover 01810 | Lawrence | 248 | 2018 | Market & Affordable | $2,562 ($3.12) | $3,156 ($2.64) | $3,886 ($2.66) | $2.94 | 96.8% | |
| North Andover 01845 | Lawrence | 192 | 2019 | Market | $2,512 ($2.90) | $3,176 ($2.51) | —2 | $2.71 | 95.3% | |
| North Andover 01845 | Lawrence | 221 | 2022 | Market | $2,476 ($3.17) | $2,993 ($2.58) | $4,051 ($2.72) | $2.953 | 95.0% | |
| North Andover 01845 | Lawrence | 136 | 2022 | Market | $2,800 ($3.09) | $3,248 ($2.94) | —2 | $3.263 | 86.8% | |
| Submarket Benchmark | ||||||||||
| Andover Submarket (All MF)4 | — | — | — | — | — | $2,437 ($3.03) | $2,764 ($2.49) | $3,618 ($2.72) | $2.66 | 93.2% |
What the comps show
Slide 3: ZIP 01810 leasing momentum
| Metric | Value | Context |
|---|---|---|
| Median asking rent | $2,8201 | +0.8% year over year; 10.2% above the $2,559 median in-place rent |
| Occupancy | 96.7% | 97.1% a year ago |
| Avg new-lease tradeout | +5.85% (+$159) | Last 30 days, small sample |
| Median days on market, leases signed past 30 days | 59 days | 36 leases signed |
What the numbers show
Decisions before the deck build
yes, let's use all 6, and include the footnotes, and lead with the 6-month figure
Slide 4 is Sponsor track record: Hanover commands a premium (Atlanta)
For Hanover Edgewood (Atlanta, GA) and Hanover Midtown (Atlanta, GA), show in-place rent by bedroom and per square foot versus each property's submarket in-place rent by bedroom, and median days on market for leases signed in the past 30 days versus the submarket.
mkdir -p hanover && cat > hanover/slide4_calcs.py <<'EOF'
# Slide 4: Hanover Atlanta premiums vs Datamart submarket and home ZIP. RealAI Rent Index as of 2026-09-26
B=['0','1','2','3']
props={
'Hanover Edgewood':dict(r=[1417.85,1805.72,2339.34,2870.35],psf=[2.60,2.24,2.04,1.96],avg=1989.63,apsf=2.20,occ=.8981,dom=35,
sub=dict(n='Chandler-McAfee/West Belvedere Park',r=[1437.08,1608.27,1857.82,2177.49],psf=[2.68,2.15,1.73,1.60],avg=1743.84,apsf=1.97,occ=.9395,dom=63),
zip=dict(n='30307',r=[1568.92,1850.93,2388.57,3020.42],avg=2042.00,apsf=2.26,occ=.9534,dom=54)),
'Hanover Midtown':dict(r=[1897.32,2481.14,3396.20,4202.05],psf=[3.38,2.92,2.51,2.63],avg=2739.85,apsf=2.86,occ=.8052,dom=68,
sub=dict(n='Midtown',r=[1808.11,2293.23,3022.49,4024.94],psf=[3.42,2.87,2.44,2.74],avg=2495.83,apsf=2.81,occ=.8531,dom=82),
zip=dict(n='30309',r=[1697.60,1952.32,2801.55,4007.45],avg=2266.10,apsf=2.59,occ=.9347,dom=74))}
for n,p in props.items():
for lvl in ['sub','zip']:
b=p[lvl]
rs=' | '.join(f"{'Studio' if k=='0' else k+'BR'} {p['r'][i]/b['r'][i]-1:+.1%}" for i,k in enumerate(B))
print(f"{n} vs {lvl} {b['n']}: {rs} | avg {p['avg']/b['avg']-1:+.1%} | $/SF {p['apsf']/b['apsf']-1:+.1%} | occ {(p['occ']-b['occ'])*100:+.1f} pts | DOM {p['dom']} vs {b['dom']} ({p['dom']-b['dom']:+d} days, {p['dom']/b['dom']-1:+.0%})")
b=p['sub']; print(f" {n} bedroom $/SF vs sub: "+' | '.join(f"{k}: {p['psf'][i]/b['psf'][i]-1:+.1%}" for i,k in enumerate(B)))
# economic view: rent x occupancy
for lvl in ['sub','zip']:
b=p[lvl]; print(f" {n} occupancy-adjusted revenue/unit vs {lvl}: {p['avg']*p['occ']/(b['avg']*b['occ'])-1:+.1%}")
EOF
python3 hanover/slide4_calcs.pyNoted for slides 2 and 3: all six comps with footnotes, and the +3.6% six-month tradeout as the headline figure.
Slide 4: Hanover track record in Atlanta
The "commands a premium" headline holds for Hanover Midtown and for leasing speed at both properties. It does not hold on rent for Hanover Edgewood, and both properties are less occupied than their submarkets. As written, the slide would claim more than the data supports.
| Metric | Premium | Premium | ||||
|---|---|---|---|---|---|---|
| Property | ||||||
| Units / Year built | 422 / 2022 | — | — | 421 / 2023 | — | — |
| In-place rent by bedroom | ||||||
| Studio | $1,418 ($2.60/SF) | $1,437 ($2.68/SF) | –1.3% | $1,897 ($3.38/SF) | $1,808 ($3.42/SF) | +4.9% |
| 1BR | $1,806 ($2.24) | $1,608 ($2.15) | +12.3% | $2,481 ($2.92) | $2,293 ($2.87) | +8.2% |
| 2BR | $2,339 ($2.04) | $1,858 ($1.73) | +25.9% | $3,396 ($2.51) | $3,022 ($2.44) | +12.4% |
| 3BR | $2,870 ($1.96) | $2,177 ($1.60) | +31.8% | $4,202 ($2.63) | $4,025 ($2.74) | +4.4% |
| Average | $1,9901 | $1,744 | +14.1% | $2,7401 | $2,496 | +9.8% |
| Average $/SF | $2.20 | $1.97 | +11.7% | $2.86 | $2.81 | +1.8% |
| Leasing | ||||||
| Median days on market, leases signed past 30 days | 352 | 63 | 28 days faster | 68 | 82 | 14 days faster |
| Leases signed past 30 days | 23 | 197 | — | 46 | 188 | — |
| Occupancy | 89.8%3 | 94.0% | –4.1 pts | 80.5%3 | 85.3% | –4.8 pts |
What holds up
What investors will push back on
Suggested headline: "Hanover product leases 14–28 days faster than its submarket and earns a 10% rent premium in Midtown."
The occupancy gap and Edgewood's ZIP comparison would go in the footnotes. Ideally we'd add each property's lease-up history, since stabilization date is what explains the occupancy. I can pull the monthly occupancy series for both before the deck build if you want it.
great, let's put this together in the pitch book. Here is Hanover's logo, and use whatever branding/colors you found on the website and research to help you out.
cd hanover; head -c 1500 logo.svg; echo; grep -oE '#[0-9A-Fa-f]{6}|fill:[^;"]+' logo.svg | sort | uniq -c; python3 -c "import pptx;print(pptx.__version__)"; which rsvg-convert inkscape; python3 -c "import cairosvg" 2>&1; python3 -c "import matplotlib;print('mpl')"cd hanover && sed 's/#115740/#FFFFFF/g; s/#231f20/#FFFFFF/g' logo.svg > logo_white.svg && (convert -background none -density 600 logo.svg logo.png && convert -background none -density 600 logo_white.svg logo_white.png) 2>&1 | head -5; ls -la *.png; identify logo.png logo_white.png
"""Hanover | 300 Minuteman Road pitch book. All figures from RealAI Datamart pulls (as of 2026-09-26 rent index;
2026-08-15 SuperCensus) and sandbox calcs in demand_calcs_01810.py, slides2_3_calcs.py, slide4_calcs.py."""
from pptx import Presentation
from pptx.util import Inches, Pt, Emu
from pptx.dml.color import RGBColor
from pptx.enum.text import PP_ALIGN, MSO_ANCHOR
from pptx.chart.data import CategoryChartData
from pptx.enum.chart import XL_CHART_TYPE, XL_LEGEND_POSITION
from pptx.enum.shapes import MSO_SHAPE
GREEN = RGBColor(0x11, 0x57, 0x40)
DARK = RGBColor(0x23, 0x1F, 0x20)
GRAY = RGBColor(0x6B, 0x6B, 0x6B)
LIGHT = RGBColor(0xE8, 0xF0, 0xEC)
MID = RGBColor(0x7F, 0xA8, 0x98)
WHITE = RGBColor(0xFF, 0xFF, 0xFF)
RED = RGBColor(0xA3, 0x3B, 0x2E)
FONT = "Arial"
prs = Presentation()
prs.slide_width, prs.slide_height = Inches(13.333), Inches(7.5)
BLANK = prs.slide_layouts[6]
W = prs.slide_width
def tb(slide, x, y, w, h, text="", size=12, bold=False, color=DARK, align=PP_ALIGN.LEFT, anchor=MSO_ANCHOR.TOP, italic=False):
box = slide.shapes.add_textbox(x, y, w, h)
tf = box.text_frame
tf.word_wrap = True
tf.margin_left = tf.margin_right = Inches(0.05)
tf.margin_top = tf.margin_bottom = Inches(0.03)
tf.vertical_anchor = anchor
lines = text if isinstance(text, list) else [text]
for i, ln in enumerate(lines):
p = tf.paragraphs[0] if i == 0 else tf.add_paragraph()
p.alignment = align
runs = ln if isinstance(ln, list) else [(ln, bold, color)]
for r in runs:
t, b, c = (r + (color,))[:3] if len(r) == 2 else r
run = p.add_run()
run.text = t
run.font.name = FONT
run.font.size = Pt(size)
run.font.bold = b
run.font.italic = italic
run.font.color.rgb = c
return box
def rect(slide, x, y, w, h, fill, line=None):
s = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, x, y, w, h)
s.fill.solid()
s.fill.fore_color.rgb = fill
if line is None:
s.line.fill.background()
else:
s.line.color.rgb = line
s.shadow.inherit = False
return s
def header(slide, kicker, title, num):
rect(slide, 0, 0, W, Inches(0.12), GREEN)
tb(slide, Inches(0.5), Inches(0.3), Inches(10.5), Inches(0.3), kicker.upper(), 11, True, GREEN)
tb(slide, Inches(0.5), Inches(0.58), Inches(11.2), Inches(0.9), title, 22, True, DARK)
slide.shapes.add_picture("logo.png", Inches(11.35), Inches(0.3), height=Inches(0.55))
ln = slide.shapes.add_connector(1, Inches(0.5), Inches(1.45), W - Inches(0.5), Inches(1.45))
ln.line.color.rgb = MID
ln.line.width = Pt(0.75)
# footer
tb(slide, Inches(0.5), Inches(7.1), Inches(9), Inches(0.3),
"300 Minuteman Road | Andover, MA | Confidential – For Discussion Purposes Only", 8, False, GRAY)
tb(slide, W - Inches(1.2), Inches(7.1), Inches(0.7), Inches(0.3), str(num), 8, True, GREEN, PP_ALIGN.RIGHT)
def table(slide, x, y, w, rows, col_w, font=10, header_fill=GREEN, row_h=0.32, bold_rows=(), shade_rows=(), section_rows=()):
nr, nc = len(rows), len(rows[0])
shp = slide.shapes.add_table(nr, nc, x, y, w, Inches(row_h * nr))
t = shp.table
tblPr = shp._element.graphic.graphicData.tbl.tblPr
# remove default style banding look
tblPr.set("bandRow", "0"); tblPr.set("firstRow", "0")
for j, cw in enumerate(col_w):
t.columns[j].width = Inches(cw)
for i, r in enumerate(rows):
t.rows[i].height = Inches(row_h)
for j, val in enumerate(r):
c = t.cell(i, j)
c.margin_left = c.margin_right = Inches(0.06)
c.margin_top = c.margin_bottom = Inches(0.02)
c.vertical_anchor = MSO_ANCHOR.MIDDLE
tf = c.text_frame
tf.word_wrap = True
p = tf.paragraphs[0]
p.alignment = PP_ALIGN.LEFT if j == 0 else PP_ALIGN.CENTER
run = p.add_run()
run.text = str(val)
run.font.name = FONT
run.font.size = Pt(font)
c.fill.solid()
if i == 0:
c.fill.fore_color.rgb = header_fill
run.font.bold = True
run.font.color.rgb = WHITE
elif i in section_rows:
c.fill.fore_color.rgb = MID
run.font.bold = True
run.font.color.rgb = WHITE
elif i in shade_rows:
c.fill.fore_color.rgb = LIGHT
run.font.bold = True
run.font.color.rgb = DARK
else:
c.fill.fore_color.rgb = WHITE if i % 2 else RGBColor(0xF6, 0xF8, 0xF7)
run.font.color.rgb = DARK
run.font.bold = i in bold_rows
if section_rows:
for i in section_rows:
t.cell(i, 0).merge(t.cell(i, nc - 1))
return t
def kpi(slide, x, y, w, h, value, label, sub=""):
rect(slide, x, y, w, h, LIGHT)
rect(slide, x, y, Inches(0.08), h, GREEN)
tb(slide, x + Inches(0.2), y + Inches(0.1), w - Inches(0.3), Inches(0.6), value, 24, True, GREEN)
tb(slide, x + Inches(0.2), y + Inches(0.68), w - Inches(0.3), Inches(0.35), label, 11, True, DARK)
if sub:
tb(slide, x + Inches(0.2), y + Inches(1.0), w - Inches(0.3), h - Inches(1.05), sub, 9, False, GRAY)
def bullets(slide, x, y, w, h, items, size=12, gap=6):
box = slide.shapes.add_textbox(x, y, w, h)
tf = box.text_frame
tf.word_wrap = True
for i, (head, body) in enumerate(items):
p = tf.paragraphs[0] if i == 0 else tf.add_paragraph()
p.space_after = Pt(gap)
r0 = p.add_run(); r0.text = "■ "; r0.font.size = Pt(size - 3); r0.font.color.rgb = GREEN; r0.font.name = FONT
r1 = p.add_run(); r1.text = head + " "; r1.font.bold = True; r1.font.size = Pt(size); r1.font.color.rgb = DARK; r1.font.name = FONT
r2 = p.add_run(); r2.text = body; r2.font.size = Pt(size); r2.font.color.rgb = DARK; r2.font.name = FONT
return box
def footnote(slide, lines, y=6.35, h=0.75):
tb(slide, Inches(0.5), Inches(y), Inches(12.3), Inches(h), lines, 7.5, False, GRAY)
# ---------------- Slide 1: Cover ----------------
s = prs.slides.add_slide(BLANK)
rect(s, 0, 0, W, prs.slide_height, GREEN)
rect(s, 0, Inches(5.9), W, Inches(1.6), DARK)
s.shapes.add_picture("logo_white.png", Inches(0.8), Inches(0.8), height=Inches(1.1))
tb(s, Inches(0.8), Inches(2.55), Inches(11), Inches(0.4), "EQUITY OFFERING | INVESTOR PRESENTATION", 14, True, MID)
tb(s, Inches(0.8), Inches(3.0), Inches(11.5), Inches(1.0), "300 Minuteman Road", 44, True, WHITE)
tb(s, Inches(0.8), Inches(3.9), Inches(11.5), Inches(0.6), "334-Unit Market-Rate Community | Andover, Massachusetts", 22, False, WHITE)
tb(s, Inches(0.8), Inches(6.2), Inches(8), Inches(0.4), "September 2026", 14, True, WHITE)
tb(s, Inches(0.8), Inches(6.6), Inches(11.5), Inches(0.5),
"Confidential – For discussion purposes only. Not an offer to sell or a solicitation of an offer to buy securities.", 9, False, MID)
# ---------------- Slide 2: Demand ----------------
s = prs.slides.add_slide(BLANK)
header(s, "Market Demand | ZIP 01810 (Andover, MA)",
"An affluent, highly educated submarket whose new arrivals out-earn those leaving by $45K", 2)
rows = [["Metric", "ZIP 01810", "Boston MSA", "01810 Advantage"],
["Median household income", "$180,672", "$124,803", "+44.8%"],
["Renter median household income", "$95,223", "$74,832", "+27.2%"],
["Households earning $200K+ (ACS)¹", "44%", "—", "—"],
["Median household net worth", "$1M – $2M", "$500K – $750K", "Two tiers higher"],
["Households with net worth $1M+", "63.7%", "36.2%", "+27.5 pts"],
["In-mover median HHI", "$297,090", "$147,010", "—"],
["Out-mover median HHI", "$251,631", "$166,331", "—"],
["Migration income delta (in – out)²", "+$45,459", "–$19,321", "01810 gains; MSA loses"]]
table(s, Inches(0.5), Inches(1.7), Inches(7.0), rows, [2.7, 1.3, 1.35, 1.65], font=10.5, row_h=0.46, shade_rows=(8,))
tb(s, Inches(7.9), Inches(1.65), Inches(4.9), Inches(0.4), "INVESTMENT HIGHLIGHTS", 12, True, GREEN)
bullets(s, Inches(7.9), Inches(2.05), Inches(4.95), Inches(4.3), [
("Wealthy, high-earning submarket.",
"Median household income of $180.7K is ~45% above the Boston MSA, and the median household's net worth "
"is $1M–$2M, two tiers above the metro. Nearly two-thirds of households have a net worth of $1M or more, vs. about one-third across the MSA."),
("Newcomers are raising the income base.",
"In-movers earn a median $297K, $45K (18%) more than out-movers, while the Boston MSA loses $19K of income through migration. "
"In-movers are 11 years younger (41 vs. 52) and still building wealth, the renter-by-choice profile a new market-rate community is built for."),
("Well-off renters.",
"Andover renters earn a median $95K, 27% above metro renters. Renters are only 20% of households, "
"and 44% of all households earn over $200K."),
], size=11, gap=9)
footnote(s, ["¹ ACS 2024 5-year via Census Reporter. The ACS figure is shown instead of the Datamart income brackets, which don't match the ZIP's reported median. "
"² Median household income of households tracked moving in vs. out, Aug 2024 – Jul 2026 (1,163 in / 1,284 out).",
"Note: Net migration is roughly flat (–121 people, –0.5%); the story is the quality of new residents, not how many are arriving. "
"Source: RealAI SuperCensus (as of Aug 15, 2026); US Census ACS."], y=6.4)
# ---------------- Slide 3: Rent comps ----------------
s = prs.slides.add_slide(BLANK)
header(s, "Competitive Set | 120+ Unit Communities Built 2016+",
"Six communities built in nine years, none since 2022 – new product earns a premium on 2BRs and 3BRs", 3)
rows = [["Property", "City / ZIP", "Units", "Built", "Rent Type", "1BR", "2BR", "3BR", "Avg $/SF", "Occ."],
["Andover submarket", "", "", "", "", "", "", "", "", ""],
["The Slate at Andover", "Andover 01810", "225", "2016", "Mkt & Aff.", "$2,408", "$2,963", "$4,171", "$2.81", "95.6%"],
["Berry Farms", "N. Andover 01845", "196", "2016", "Market", "$2,515", "$2,992", "$3,736", "$2.98", "93.4%"],
["Same towns, Datamart \"Lawrence\" submarket¹", "", "", "", "", "", "", "", "", ""],
["The Point at Merrimack River", "Andover 01810", "248", "2018", "Mkt & Aff.", "$2,562", "$3,156", "$3,886", "$2.94", "96.8%"],
["Haven North Andover", "N. Andover 01845", "192", "2019", "Market", "$2,512", "$3,176", "—", "$2.71", "95.3%"],
["Avalon North Andover", "N. Andover 01845", "221", "2022", "Market", "$2,476", "$2,993", "$4,051", "$2.95", "95.0%"],
["505 Sutton", "N. Andover 01845", "136", "2022", "Market", "$2,800", "$3,248", "—", "$3.26", "86.8%"],
["Comp set (6)²", "", "1,218", "", "", "$2,546", "$3,088", "$3,961", "", "94.3%"],
["Andover submarket in-place", "", "", "", "", "$2,437", "$2,764", "$3,618", "$2.66", "93.2%"],
["Comp premium vs. submarket", "", "", "", "", "+4.5%", "+11.7%", "+9.5%", "", ""]]
table(s, Inches(0.5), Inches(1.65), Inches(8.6), rows,
[2.35, 1.3, 0.55, 0.5, 0.85, 0.65, 0.65, 0.65, 0.6, 0.5], font=9, row_h=0.36,
section_rows=(1, 4), shade_rows=(9, 10, 11))
tb(s, Inches(9.4), Inches(1.6), Inches(3.5), Inches(0.4), "KEY TAKEAWAYS", 12, True, GREEN)
bullets(s, Inches(9.4), Inches(2.0), Inches(3.45), Inches(4.4), [
("Supply is limited.", "No 120+ unit community has delivered in Andover or North Andover since 2022. Andover permitted only 9 new homes in 2023."),
("The premium is in larger units.", "Compared with submarket in-place rents, comp 2BRs run +11.7% and 3BRs +9.5%. 1BRs run only +4.5%."),
("Rents cluster tightly.", "Comp average in-place rents range from $2,719 to $2,855. The unit-weighted average is $2,805, with 94.3% occupancy."),
("Where rents top out.", "505 Sutton gets the highest rent at $3.26/SF but is only 86.8% occupied."),
], size=10.5, gap=8)
footnote(s, ["¹ Under strict criteria (Datamart \"Andover\" submarket, 120+ units, built 2016+), only The Slate and Berry Farms qualify. The Datamart assigns four "
"same-town 2018–2022 deliveries, including The Point in ZIP 01810, to the adjacent Lawrence submarket; they are included here as direct competitors. "
"² Rent by bedroom is a simple average of the properties reporting that unit type (3BR n = 4); occupancy is weighted by units.",
"In-place rent = average rent on leased units. Studios omitted (reported only by Avalon, $2,123, and 505 Sutton, $2,225). "
"The subject is not yet in the dataset. Source: RealAI Rent Index (as of Sep 26, 2026); Andover MA News; Town of North Andover."],
y=6.35)
# ---------------- Slide 4: Leasing momentum ----------------
s = prs.slides.add_slide(BLANK)
header(s, "Leasing Momentum | ZIP 01810",
"Tight occupancy and new-lease pricing turning upward – new leases up 3.6% over prior rents since March", 4)
cd = CategoryChartData()
cd.categories = ["Sep-25", "Oct-25", "Nov-25", "Dec-25", "Jan-26", "Feb-26", "Mar-26", "Apr-26", "May-26", "Jun-26", "Jul-26", "Aug-26"]
cd.add_series("Median asking rent", (2732, 2750, 2620, 2590, 2565, 2628, 2640.5, 2640, 2800, 2824, 2700, 2621))
cd.add_series("Median in-place rent", (2550, 2545, 2544, 2540, 2540.5, 2541, 2545, 2530, 2541, 2545, 2550, 2559))
gf = s.shapes.add_chart(XL_CHART_TYPE.LINE_MARKERS, Inches(0.5), Inches(1.95), Inches(7.6), Inches(4.2), cd)
ch = gf.chart
ch.has_legend = True
ch.legend.position = XL_LEGEND_POSITION.BOTTOM
ch.legend.include_in_layout = False
ch.legend.font.size = Pt(10); ch.legend.font.name = FONT
va = ch.value_axis
va.minimum_scale, va.maximum_scale, va.major_unit = 2400, 2900, 100
va.tick_labels.font.size = Pt(9); va.tick_labels.number_format = '"$"#,##0'; va.tick_labels.number_format_is_linked = False
va.major_gridlines.format.line.color.rgb = RGBColor(0xDD, 0xDD, 0xDD)
va.format.line.fill.background()
ca = ch.category_axis
ca.tick_labels.font.size = Pt(9)
for ser, col in zip(ch.plots[0].series, [GREEN, GRAY]):
ser.format.line.color.rgb = col
ser.format.line.width = Pt(2.5)
ser.smooth = False
ser.marker.format.fill.solid(); ser.marker.format.fill.fore_color.rgb = col
ser.marker.format.line.color.rgb = col
tb(s, Inches(0.5), Inches(1.6), Inches(7.6), Inches(0.35), "Median asking vs. in-place rent, trailing 12 months (monthly)", 11, True, DARK)
kx, kw, kh = Inches(8.5), Inches(2.1), Inches(1.95)
kpi(s, kx, Inches(1.65), kw, kh, "$2,820", "Median asking rent", "Current; +0.8% year over year; 10.2% above the $2,559 in-place median")
kpi(s, kx + Inches(2.25), Inches(1.65), kw, kh, "96.7%", "Occupancy", "97.1% a year ago")
kpi(s, kx, Inches(3.75), kw, kh, "+3.6%", "New-lease tradeout", "Mar–Aug 2026, 131 leases (–0.2% in the prior 6 mo.). Latest 30 days: +5.85%")
kpi(s, kx + Inches(2.25), Inches(3.75), kw, kh, "59 days", "Median days on market", "Leases signed in the past 30 days (36 leases)")
tb(s, Inches(8.5), Inches(5.85), Inches(4.35), Inches(0.5),
"Rents are seasonal: lowest in January ($2,565), highest in June ($2,824).", 9.5, True, GREEN)
footnote(s, ["Tradeout = change in rent on a newly signed lease vs. the prior tenant's lease on the same unit, weighted by leases signed each month. "
"The latest 30-day +5.85% (+$159) rests on 23 leases. Monthly asking medians rest on 69–121 listed units and are directionally reliable.",
"Coverage: ~1,213 tracked multifamily units in ZIP 01810. Source: RealAI Rent Index (current snapshot as of Sep 26, 2026; monthly series through Aug 2026)."],
y=6.45, h=0.6)
# ---------------- Slide 5: Sponsor track record ----------------
s = prs.slides.add_slide(BLANK)
header(s, "Sponsor Track Record | Atlanta, GA",
"Hanover product leases 14–28 days faster than its submarket and earns a 10% rent premium in Midtown", 5)
rows = [["Metric", "Hanover Edgewood", "Submarket¹", "Premium", "Hanover Midtown", "Midtown Submarket", "Premium"],
["Units / Year built", "422 / 2022", "—", "—", "421 / 2023", "—", "—"],
["Studio", "$1,418 ($2.60/SF)", "$1,437 ($2.68)", "–1.3%", "$1,897 ($3.38/SF)", "$1,808 ($3.42)", "+4.9%"],
["1BR", "$1,806 ($2.24)", "$1,608 ($2.15)", "+12.3%", "$2,481 ($2.92)", "$2,293 ($2.87)", "+8.2%"],
["2BR", "$2,339 ($2.04)", "$1,858 ($1.73)", "+25.9%", "$3,396 ($2.51)", "$3,022 ($2.44)", "+12.4%"],
["3BR", "$2,870 ($1.96)", "$2,177 ($1.60)", "+31.8%", "$4,202 ($2.63)", "$4,025 ($2.74)", "+4.4%"],
["Average in-place rent", "$1,990", "$1,744", "+14.1%", "$2,740", "$2,496", "+9.8%"],
["Average $/SF", "$2.20", "$1.97", "+11.7%", "$2.86", "$2.81", "+1.8%"],
["Median days on market²", "35 days", "63 days", "28 days faster", "68 days", "82 days", "14 days faster"],
["Occupancy", "89.8%", "94.0%", "–4.1 pts", "80.5%", "85.3%", "–4.8 pts"]]
table(s, Inches(0.5), Inches(1.65), Inches(8.9), rows, [1.85, 1.35, 1.2, 1.05, 1.35, 1.2, 0.9], font=9.5, row_h=0.4, shade_rows=(8,))
tb(s, Inches(9.7), Inches(1.6), Inches(3.2), Inches(0.4), "WHAT IT SHOWS", 12, True, GREEN)
bullets(s, Inches(9.7), Inches(2.0), Inches(3.15), Inches(4.3), [
("Faster leasing.", "Units at both communities lease 17–44% faster than their submarkets, a consistent result across two different neighborhoods."),
("Midtown premium.", "Rents run 9.8% above the Midtown submarket and higher in every unit type. Larger units (1,066 SF average) account for most of it; $/SF is +1.8%."),
("Edgewood 2BR/3BR premium.", "+26% to +32% vs. the assigned submarket; see footnote 1 for the ZIP comparison."),
], size=10.5, gap=9)
footnote(s, ["¹ Edgewood's Datamart-assigned submarket is Chandler-McAfee/West Belvedere Park. Against its own ZIP (30307), Edgewood rents 2.6% below average ($1,990 vs. $2,042). "
"² Median days on market for leases signed in the past 30 days (Edgewood: 23 leases; Midtown: 46).",
"Both communities are less occupied than their submarkets (see table); adjusted for occupancy, revenue per unit is +9.1% (Edgewood) and +3.6% (Midtown) vs. submarket. "
"In-place rent = average rent on leased units; figures in parentheses are $/SF. Source: RealAI Rent Index (as of Sep 26, 2026)."],
y=6.3, h=0.8)
# ---------------- Slide 6: Sources & disclaimer ----------------
s = prs.slides.add_slide(BLANK)
header(s, "Appendix", "Sources, Definitions & Important Information", 6)
bullets(s, Inches(0.5), Inches(1.75), Inches(12.3), Inches(4.8), [
("RealAI SuperCensus (Aug 15, 2026):", "Household income (median, renter, owner), net worth distribution and tiers, and migration cohorts "
"(tracked households moving into / out of ZIP 01810, Aug 2024 – Jul 2026; 1,163 in / 1,284 out)."),
("RealAI Rent Index (Sep 26, 2026):", "Property and geography in-place and asking rents by bedroom and per SF, physical occupancy, new-lease tradeout, "
"and days on market. Monthly series through Aug 2026."),
("US Census ACS 2024 5-year (via Census Reporter):", "Household income distribution for ZCTA 01810 (44% of households earn over $200K; median $172,606)."),
("Supply context:", "Andover MA News (Sep 16, 2025) on Andover housing starts; Town of North Andover Master Plan Implementation (housing) on 2023 corridor deliveries."),
("Definitions:", "In-place rent = average rent on leased units. Asking rent = rent on units currently listed. Tradeout = change from the prior tenant's rent to the new lease on the same unit. "
"Premium = property ÷ benchmark – 1. Submarkets follow RealAI Datamart boundaries."),
("Important information:", "This presentation is for discussion purposes only and does not constitute an offer to sell or a solicitation of an offer to buy any security. "
"Market data are third-party estimates and may be revised; past performance of Hanover communities is not indicative of future results. "
"Ownership entities shown in public records for Hanover Edgewood and Hanover Midtown should be confirmed against Hanover's own records."),
], size=11, gap=10)
prs.save("Hanover_300_Minuteman_Road_Pitch_Book.pptx")
print("saved")
cd hanover && python3 build_deck.py && libreoffice --headless --convert-to pdf Hanover_300_Minuteman_Road_Pitch_Book.pptx --outdir qa >/dev/null 2>&1; ls qa && pdftoppm -r 55 -png qa/Hanover_300_Minuteman_Road_Pitch_Book.pdf qa/s && montage qa/s-*.png -tile 2x3 -geometry +4+4 qa/contact.png && ls qa
cd hanover/qa && python3 - <<'EOF'
import re,subprocess
subprocess.run(["pdftotext","-bbox","Hanover_300_Minuteman_Road_Pitch_Book.pdf","b.html"])
h=open("b.html").read()
pages=h.split("<page ")[1:]
for i,p in enumerate(pages,1):
W,H=[float(x) for x in re.findall(r'width="([\d.]+)" height="([\d.]+)"',p)[0]]
words=re.findall(r'xMin="([\d.]+)" yMin="([\d.]+)" xMax="([\d.]+)" yMax="([\d.]+)">([^<]*)<',p)
over=[w for w in words if float(w[2])>W-2 or float(w[3])>H-2]
print(f"page {i} {W}x{H} words={len(words)} overflow={[w[4] for w in over][:10]}")
# key markers
for key in ["Comp","premium","Occupancy","¹","Source:","Note:","Rents","Coverage:","Important"]:
ys=[round(float(w[1])/H*7.5,2) for w in words if w[4].startswith(key)]
if ys: print(" ",key,ys[:6])
EOFcd hanover/qa && python3 - <<'EOF'
import re
h=open("b.html").read()
pages=h.split("<page ")[1:]
for i,p in enumerate(pages,1):
W,H=[float(x) for x in re.findall(r'width="([\d.]+)" height="([\d.]+)"',p)[0]]
words=[(float(a)/W*13.333,float(b)/H*7.5,float(c)/W*13.333,float(d)/H*7.5,t) for a,b,c,d,t in re.findall(r'xMin="([\d.]+)" yMin="([\d.]+)" xMax="([\d.]+)" yMax="([\d.]+)">([^<]*)<',p)]
title=[w for w in words if 0.55<w[1]<1.6 and w[0]<11.3]
if title: print(i,"title bottom",round(max(w[3] for w in title),2))
right=[w for w in words if w[0]>7.8 and 1.9<w[1]<6.4]
if right: print(i,"right col bottom",round(max(w[3] for w in right),2), "right edge", round(max(w[2] for w in right),2))
left=[w for w in words if w[0]<7.8 and 1.6<w[1]<6.4]
if left: print(i,"left bottom",round(max(w[3] for w in left),2),"left right-edge",round(max(w[2] for w in left),2))
foot=[w for w in words if w[1]>6.3]
if foot: print(i,"foot bottom",round(max(w[3] for w in foot),2))
EOFcd hanover/qa && python3 - <<'EOF'
import re
h=open("b.html").read()
for i,p in enumerate(h.split("<page ")[1:],1):
W,H=[float(x) for x in re.findall(r'width="([\d.]+)" height="([\d.]+)"',p)[0]]
words=[(float(a)/W*13.333,float(b)/H*7.5,float(c)/W*13.333,float(d)/H*7.5,t) for a,b,c,d,t in re.findall(r'xMin="([\d.]+)" yMin="([\d.]+)" xMax="([\d.]+)" yMax="([\d.]+)">([^<]*)<',p)]
body=[w for w in words if 1.5<w[1]<6.3]
fn=[w for w in words if 6.3<=w[1]<7.08]
ft=[w for w in words if w[1]>=7.08]
if body: print(i,"body max y",round(max(w[3] for w in body),2))
if fn: print(i,"footnote y",round(min(w[1] for w in fn),2),"-",round(max(w[3] for w in fn),2))
if ft: print(i,"footer",round(min(w[1] for w in ft),2))
EOFcd hanover && convert logo.png -alpha extract -format "%[fx:mean]" info:; echo; convert qa/s-1.png -format "%[fx:mean]" info:; echo; convert qa/s-3.png -crop 120x40+820+10 -format "%[fx:mean]" info:
The Hanover pitch book is built: six slides in Hanover's branding, with the decisions from our earlier rounds applied.
| # | Slide | Headline |
|---|---|---|
| 1 | Cover | 300 Minuteman Road: 334-unit market-rate community, Andover, MA |
| 2 | Market demand | An affluent, highly educated submarket whose new arrivals out-earn those leaving by $45K |
| 3 | Rent comps | Six communities built in nine years, none since 2022; new product earns a premium on 2- and 3-bedrooms |
| 4 | Leasing momentum | Tight occupancy and new-lease pricing turning upward; new leases up 3.6% over prior rents since March |
| 5 | Sponsor track record | Hanover product leases 14–28 days faster than its submarket and earns a 10% rent premium in Midtown |
| 6 | Appendix | Sources, definitions and the offering disclaimer |
Branding. The green (#115740) and near-black come straight from your logo file. The logo is full-color on content slides and white on the green cover.
What changed from our drafts
Before it goes to investors
Attached is an actual pitch book from Hanover, for a property called Sugarloaf. I would like you to recreate the presentation for 300 Minuteman in Hanover's style. You will need to go out to the web to source photos of the property and conduct further research on what makes the surrounding area appealing. Generate a map with areas of interest and any other visuals you can source from the web. Leave a placeholder for cost assumptions and operational assumptions, as you do not have this in-hand. Ask me any questions you have.
mkdir -p hanover/ref && cp scratch/inputs/library/01a10d80-4a50-7570-abe3-93d9af7f1cfc/sugarloaf.pdf hanover/ref/ && cd hanover/ref && pdfinfo sugarloaf.pdf | head -20 && python3 -c "
import pdfplumber
with pdfplumber.open('sugarloaf.pdf') as pdf:
for i,p in enumerate(pdf.pages,1):
t=(p.extract_text() or '').replace('\n',' | ')
print(f'=== PAGE {i} === {t[:400]}')
"cd hanover/ref && pdftoppm -r 40 -png sugarloaf.pdf p && montage p-0[1-9].png p-1[0-2].png -tile 4x3 -geometry +3+3 m1.png && montage p-1[3-9].png p-2[0-4].png -tile 4x3 -geometry +3+3 m2.png && montage p-2[5-9].png p-3*.png -tile 4x3 -geometry +3+3 m3.png && ls m*.png
Before I start the rebuild, a quick search turned up facts about the project that change the deck. The biggest: the earlier slides call this a market-rate community, and Hanover's public filings say it isn't.
I've copied the Sugarloaf book to the saved workspace (hanover/ref/sugarloaf.pdf, file 01a10d80-4a50-7570-abe3-93d9af7f1cfc) and studied its layout. The deck will follow it: white pages, navy serif headings, a navy title page, a map-led site aerial, financial and assumptions pages, points of interest, demographics, supply pipeline, rent comps with photos, sale comps and key contacts. A few questions before I build:
curl -s -o /dev/null -w "%{http_code}\n" -m 10 https://tile.openstreetmap.org/12/1238/1514.png -A "Mozilla/5.0 RealAI"; curl -s -o /dev/null -w "%{http_code}\n" -m 10 https://upload.wikimedia.org/ ; curl -s -o /dev/null -w "%{http_code}\n" -m 10 https://www.hanoverco.com/; python3 -c "import staticmap" 2>&1 | tail -1; pip list 2>/dev/null | grep -i -E "contextily|geopandas|folium|staticmap|playwright|selenium|requests|Pillow"cd hanover/ref && for p in 1 4 5 6 12 20 26 34; do pdftoppm -r 60 -png -f $p -l $p sugarloaf.pdf v$p; done; ls v*; montage v1-01.png v4-04.png v5-05.png v6-06.png -tile 2x2 -geometry +4+4 view1.png; montage v12-12.png v20-20.png v26-26.png v34-34.png -tile 2x2 -geometry +4+4 view2.png
cd hanover/ref && pdftoppm -r 100 -png -f 4 -l 6 sugarloaf.pdf c && python3 - <<'EOF'
from PIL import Image
from collections import Counter
for f in ["c-04.png","c-05.png","c-06.png"]:
im=Image.open(f).convert("RGB").resize((400,310))
cnt=Counter(im.getdata())
print(f,[('#%02x%02x%02x'%c,n) for c,n in cnt.most_common(12)])
EOF
pdffonts -f 1 -l 6 sugarloaf.pdf | head -20; pdftotext -f 34 -l 34 sugarloaf.pdf - | head -60GOAL: Build the "Why Andover / Minuteman Park" and "Nearby Points of Interest" content for an institutional multifamily development investment book (Hanover Company style) for "Hanover Minuteman Park", a proposed 334-unit apartment community (250 market-rate + 84 affordable at 80% AMI under a Local Initiative Program / friendly 40B) on a vacant 20.24-acre site at 300 Minuteman Road, Andover, MA 01810, inside Minuteman Park (a ~1M SF office/lab park off River Road near I-93 and I-495). Investors need to understand what makes the location appealing: employers, highway/transit access, retail/amenities, recreation, schools, and Boston proximity.
TASKS
identify (must be a real image, ≥1200px wide). Record credits.RULES: Use web_search/web_fetch for facts; never invent numbers, employee counts, or distances — if unverifiable, say so. Flag conflicts between sources. Don't use paywalled content you can't read.
OUTPUT (write to subagents/area/):
All files are in subagents/area/: poi.json, narrative.md, schools.json, photo_credits.json, photos/ (7 JPGs). Scripts: build_poi.py (distances) and fetch_photos.py (photo download).
Subject. 300 Minuteman Rd, Andover MA 01810 geocodes to lat 42.69317, lon -71.20855 (Google Places ID ChIJN6N0CxGo44kRm-1IqiHNW-I).
POI list (22 plus the subject).
Stat callouts (sources; details in narrative.md).
Master-plan and overlay findings.
Photos (all verified with identify as JPEG, at least 2448 px wide; credits in photo_credits.json).
| File | Subject | Author | License |
|---|---|---|---|
| addison_gallery_phillips_academy_andover.jpg | Addison Gallery | Daderot | Public domain |
| phillips_academy_memorial_bell_tower.jpg | Phillips Academy bell tower | Daderot | Public domain |
| harold_parker_state_forest_berry_pond.jpg | Harold Parker SF, Berry Pond | John Phelan | CC BY-SA 4.0 |
| harold_parker_state_forest_stearns_pond.jpg | Harold Parker SF, Stearns Pond | John Phelan | CC BY-SA 3.0 |
| andover_mbta_commuter_rail_station.jpg | Andover MBTA station | John Phelan | CC BY-SA 3.0 |
| downtown_andover_main_street.jpg | Downtown Main Street | John Phelan | CC BY 3.0 |
| i495_bridge_merrimack_river_lawrence.jpg | I-495 bridge over the Merrimack, Lawrence | John Phelan | CC BY 3.0 |
Gaps and conflicts.
ahs_supplement: Andover HS 10, Andover West Middle 8, West Elementary 8.GOAL: Supply the "Supply Pipeline", "Asset Sale Comps", and affordable-rent inputs for an institutional investment book for "Hanover Minuteman Park", a proposed 334-unit apartment community (250 market-rate + 84 units affordable at 80% AMI via Massachusetts Local Initiative Program / friendly 40B) at 300 Minuteman Road, Andover, MA 01810 (approx. lat 42.66, lon -71.20; near I-93/I-495). Investors use these pages to judge competing new supply and exit value.
TASK A — SUPPLY PIPELINE (within ~5–7 miles: Andover, North Andover, Tewksbury, Wilmington, Methuen, Lawrence, Lowell east side, North Reading, Dracut): find multifamily rental projects of ~50+ units that are under construction, approved/permitted, or proposed (2024–2026 news), plus anything delivered 2024–2026 still in lease-up. For each: name, address, developer, units, status (UC / approved / proposed / lease-up), expected delivery date, affordability (40B? % affordable), lat/lon (use google_places_search_text or geocode the address), and source URL. Search local outlets (andovermanews.com, andoverledger.com, eagletribune.com, lowellsun.com, bisnow.com/boston, bldup.com, bizjournals) and town planning/ZBA pages. Note MBTA Communities zoning overlays adopted in Andover/North Andover/Tewksbury/Wilmington and any known projects under them. Known context: Andover 2023 housing starts were 9 (andovermanews); Avalon North Andover (221u, 2022) and 505 Sutton (136u, 2022) in North Andover are recent deliveries. Also check the Datamart: use explore_data then query_data on entity property_mfr for properties in submarket_id IN ["0eb2cbd1c19f4cd0024a0390441e62b9","7bbccbf1e9cfa562703d937cbbe215ab"] with mf_property_attributes.year_built >= 2024 to catch lease-ups.
TASK B — ASSET SALE COMPS: find 4–6 multifamily sales (2022–2026) of 100+ unit properties built 2000 or later in the Merrimack Valley / I-93 / I-495 north suburbs (Andover, North Andover, Tewksbury, Wilmington, Burlington, Woburn, Lowell, Methuen, Haverhill, Chelmsford, Billerica, Peabody, Danvers). For each: property, address, year built, units, sale date, price, price/unit, cap rate (if reported), buyer/seller, source URL. Sources: Datamart (explore_data on property_mfr topics, then mf_sales_history and mf_property_attributes for candidate properties — e.g., query property_mfr with address_city IN [list] and mf_property_attributes.unit_count >= 100, mf_property_attributes.year_built >= 2000, then check mf_sales_history), plus web (Bisnow Boston Deal Sheet, Boston Business Journal, The Real Deal, MHN, press releases). Never invent a cap rate; leave null if not reported.
TASK C — AFFORDABLE RENT LIMITS: Andover MA falls in HUD's Lawrence, MA-NH HUD Metro FMR Area (verify). Find FY2025 (and FY2026 if published) HUD 80% AMI income limits by household size and the corresponding maximum affordable rents used for LIP/40B units in Massachusetts (EOHLC/DHCD guidance: rent ≤30% of 80% AMI income, household size = bedrooms + 1 typically; utility allowance deducted). Compute max gross rent for studio/1BR/2BR/3BR in a saved python script (subagents/pipeline/affordable_rents.py) and show the inputs. Source URLs required (huduser.gov income limits page, mass.gov LIP/40B guidelines).
RULES: Every figure needs a source URL or Datamart tool reference (state the entity id and field). Flag conflicts. Do not fabricate. Save work in subagents/pipeline/.
OUTPUT FILES (subagents/pipeline/): pipeline.json, sale_comps.json, affordable_rents.json (+ the .py script), notes.md with sources and caveats. RETURN: compact report listing pipeline projects (name, units, status, delivery, distance from subject), sale comps (property, date, price, $/unit, cap rate), affordable max rents by bedroom with the income limits used, and gaps/confidence.
Subject: 300 Minuteman Rd geocodes to 42.69317, -71.20855 (Google Places); the brief's 42.66 was approximate. Status: Select Board first read 9/15/2026, second ~10/26. The plan is 3 buildings of 4–5 stories, 84 units (25%) at 80% AMI, via LIP ("friendly 40B"). Sources: andovermanews.com/andover-select-board-reviews-334-unit-minuteman-park-proposal; Bisnow deal sheet 134732 (site bought for $2.5M from Spear Street).
A. Supply pipeline (straight-line miles from subject; pipeline.json)
B. Sale comps (Datamart property_mfr mf_sales_history / latest sale fields; sale_comps.json)
| Property | Sale date | Price | $/unit | Cap rate | Buyer / seller |
|---|---|---|---|---|---|
| Residences at Crosspoint, Lowell (240u, 2020, id f62b35f22dd198f4644e2a4bb2730077) | 11/27/2024 | $85.075M | $354.5k | 5.0% (CoStar via Wronka snippet) | Seller SMC Management, CBRE listing broker; buyer not retrieved |
| Villas at Old Concord, Billerica (324u, 2004) | 9/27/2024 | $114.5M | $353.4k | null | Buyer TruAmerica (yieldpro.com/2024/09/villas-at-old-concord/) |
| Point at Merrimack Valley, Methuen (156u, 2021) | 3/28/2024 | $58.1M | $372.4k | null | not retrieved |
| Woods at Merrimack, Methuen (140u, 2019) | 11/3/2025 | $37.537M | $268.1k | null | not retrieved |
| Legacy Park, Lawrence (104u, 2009) | 6/25/2026 | $30.2M | $290.4k | null | not retrieved |
| Point at Woburn (289u, 2022) | Jan 2026 | $130.676M | $452.2k | null | Buyer Pantzer Properties; seller Toll Brothers / Carlyle affiliate (MarketScreener) |
C. Affordable rents (Lawrence, MA-NH HMFA; affordable_rents.py / .json)
| Unit type | FY2026 max gross rent | FY2025 max gross rent |
|---|---|---|
| Studio | $1,870 | $1,824 |
| 1BR | $2,003 | $1,954 |
| 2BR | $2,404 | $2,345 |
| 3BR | $2,777 | $2,709 |
Gaps:
GOAL: Gather visuals and entitlement facts for an investment book for "Hanover Minuteman Park", a proposed 334-unit apartment community by The Hanover Company at 300 Minuteman Road, Andover, MA 01810 (vacant 20.24-acre site in Minuteman Park, off River Road near I-93).
TASK 1 — HANOVER "SIMILAR PROJECT" RENDERINGS/PHOTOS: From hanoverco.com (portfolio pages, e.g., https://www.hanoverco.com/portfolio/ , property pages such as Hanover Weymouth (MA), Hanover Colony Place (Plymouth MA), Hanover Midtown, and any other suburban 4–5 story wood-frame communities), download 6–10 high-resolution exterior and amenity images (pool/courtyard, clubhouse, fitness, unit interior, exterior elevations). These will be labeled "Rendering/photo of similar Hanover project; subject project will vary." Use web_fetch to read pages and find image URLs (look for wp-content/uploads .jpg/.webp; strip size suffixes like -1024x683 to get originals), then curl them with a browser User-Agent. Convert webp to jpg with ImageMagick. Keep only images ≥1400px wide. Record the source page and project name for each.
TASK 2 — THE POINT AT MERRIMACK RIVER (30 Shattuck Rd, Andover; 248 units, built 2018 by Hanover, Chapter 40B): download 2–4 photos (property website or hanoverco.com) and gather facts: developer/owner history (did Hanover sell it? when, to whom, price?), affordability share, amenities, website. Sources required.
TASK 3 — RENT COMP PHOTOS: one good exterior photo each (property websites preferred) for: The Slate at Andover (50 Woodview Way, Andover), Berry Farms (4 Berry St, North Andover), Haven North Andover (1252 Osgood St, North Andover), Avalon North Andover (88 High St, North Andover), 505 Sutton (495 Sutton St, North Andover). Plus The Point (task 2). Record source URL.
TASK 4 — SITE / MINUTEMAN PARK IMAGES: try to find usable images of the 300 Minuteman Road site or Minuteman Park entrance/buildings (Bisnow article https://www.bisnow.com/news/boston/deal-sheet/houston-developer-acquires-andover-site-for-housing-development-boston-deal-sheet-134732 , Andover Ledger https://andoverledger.com/articles/andover-weighs-334-unit-apartment-complex-at-minuteman-park-mu4dwlit — its lead image is "Courtesy: Hanover Co." and may be a rendering or site plan; Alexandria/BGO/Optimum marketing pages for Minuteman Park lab buildings). Download and note credit lines. Also, if possible, generate an aerial image of the site: download Esri World Imagery tiles (https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}) around the site at zoom 16 and 17, stitch with Pillow into a ~2400x1600 image centered on the site (geocode the site first with google_places_search_text), and save as site_aerial_z16.jpg / site_aerial_z17.jpg with a metadata json giving the center lat/lon, zoom, and the pixel bounds→lat/lon transform (web mercator) so another script can overlay markers. Attribution: "Esri, Maxar, Earthstar Geographics".
TASK 5 — ENTITLEMENT STATUS: Build a dated timeline from public sources: 2021–2023 cGMP pharma special permit history for 300 Minuteman Rd; Hanover land purchase (May 2026, $2.5M from Spear Street Capital); Select Board LIP first readings (Apr 14, 2026 overview; Sep 15, 2026 presentation); Conservation Commission action (the Andover Ledger article says the Conservation Commission "unanimously denied a related..." — find out what exactly); scheduled second reading (~Oct 26, 2026); next steps (EOHLC project eligibility, ZBA comprehensive permit). Also capture issues raised (traffic, sewer capacity, archaeology at former Shattuck Farm, conversion of commercial land) and Hanover's commitments (sewer work, ~2 acres reserved for commercial, $1.5M/yr property tax estimate vs ~$150K today, 33–50 school children estimate). Fetch andoverledger.com, andovermanews.com, eagletribune.com, andoverma.gov meeting briefs/agendas. Every item needs a source URL and date.
RULES: Do not fabricate facts or credits. Verify every downloaded file with identify (real image, note pixel size). Save everything under subagents/images/ (subfolders: hanover_similar/, comps/, site/). Write images.json listing {filename, category, subject, source_url, credit, width, height}. Write entitlement.json (timeline items with date, event, source_url) and the_point.json.
RETURN: compact report of all saved images (filename, subject, size, source), The Point facts, the entitlement timeline, and gaps.
All files are under subagents/images/. Every image was checked with identify. Several items are gaps, listed at the end.
IMAGES All 12 Hanover images are 2400–2560 px wide. The Hanover photos are of completed projects, not renderings. Suggested label: "Rendering/photo of similar Hanover project; subject project will vary." Credit for all: The Hanover Company (hanoverco.com). I viewed them all and the subjects match the names below.
Hanover similar projects (hanover_similar/):
Comps (comps/):
Site (site/):
images.json lists filename, category, subject, source_url, credit, width and height for all 23 images.
THE POINT AT MERRIMACK RIVER (the_point.json)
ENTITLEMENT TIMELINE (entitlement.json; every item has a source URL)
Issues raised:
Hanover's commitments and claims:
GAPS
cd hanover && mkdir -p extracts && python3 - <<'EOF'
import requests, json
vars_ = ["NAME","B01003_001E","B11001_001E","B01002_001E","B19013_001E","B19025_001E","B25077_001E","B25003_001E","B25003_002E","B25003_003E","B25119_003E"] + [f"B19001_{i:03d}E" for i in range(1,18)]
out={}
for yr in ["2024","2019"]:
base=f"https://api.census.gov/data/{yr}/acs/acs5"
geos={"zip01810":"for=zip%20code%20tabulation%20area:01810" + ("&in=state:25" if yr=="2019" else ""),
"andover_town":"for=county%20subdivision:01465&in=state:25%20county:009",
"essex_county":"for=county:009&in=state:25",
"boston_msa":"for=metropolitan%20statistical%20area/micropolitan%20statistical%20area:14460"}
for g,q in geos.items():
v = vars_ if yr=="2024" else ["NAME","B01003_001E","B11001_001E"]
url=f"{base}?get={','.join(v)}&{q}"
r=requests.get(url,timeout=30)
print(yr,g,r.status_code, r.text[:150].replace("\n"," "))
if r.ok:
d=r.json(); out[f"{yr}_{g}"]=dict(zip(d[0],d[1]))
json.dump(out,open("extracts/acs_demographics.json","w"),indent=1)
EOFcd subagents && ls -R | head -80; python3 -c "
import json
p=json.load(open('area/poi.json')); print(len(p)); [print(x['name'],'|',x['category'],x['lat'],x['lon'],x.get('distance_mi')) for x in p]
print(json.dumps(json.load(open('pipeline/pipeline.json')),indent=0)[:2500])
"cd subagents/images && cat site/site_aerial_meta.json; convert site/site_aerial_z16.jpg -resize 900x site_z16_small.jpg; montage hanover_similar/*.jpg comps/*.jpg -resize 300x200 -tile 5x4 -geometry +3+3 ../imgs_contact.jpg
cd subagents && python3 -c "
import json
s=json.load(open('pipeline/sale_comps.json'))
print(json.dumps(s,indent=0)[:3000])
"; echo ----; cat area/narrative.md | head -80fc-list : family | sort -u | head -60; python3 -c "import json;d=json.load(open('subagents/pipeline/pipeline.json'));[print(p['name'],p['units'],p['status'][:40],p['lat'],p['lon'],p.get('dist_mi_from_subject_straight_line')) for p in d['projects']]; print(d.keys())"; python3 -c "import json;d=json.load(open('subagents/pipeline/sale_comps.json'));print([ (c['property'],c.get('lat'),c.get('lon')) for c in d['comps']])"fc-match "URW Gothic:bold" file; fc-match "URW Gothic" file; fc-match "C059" file; fc-match "Nimbus Sans:bold" file; fc-match "P052:bold" file
"""Aerial maps for the Hanover Minuteman Park investment book.
Esri World Imagery tiles (attribution: Esri, Maxar, Earthstar Geographics). Coordinates: Google Places / RealAI Datamart."""
import math, os, io, requests
from PIL import Image, ImageDraw, ImageFont, ImageFilter
OUT = os.path.join(os.path.dirname(__file__), "..", "assets", "maps")
CACHE = os.path.join(os.path.dirname(__file__), "..", "assets", "tilecache")
os.makedirs(OUT, exist_ok=True); os.makedirs(CACHE, exist_ok=True)
NAVY = (10, 34, 64); ORANGE = (240, 81, 36); WHITE = (255, 255, 255)
FB = "/usr/share/fonts/urw-base35/URWGothic-Demi.otf"
FR = "/usr/share/fonts/urw-base35/URWGothic-Book.otf"
FS = "/usr/share/fonts/urw-base35/C059-Roman.otf"
URL = "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"
UA = {"User-Agent": "Mozilla/5.0 (RealAI investment-book map builder)"}
def wpx(lat, lon, z):
s = 256 * 2 ** z
x = (lon + 180) / 360 * s
lr = math.radians(lat)
y = (1 - math.log(math.tan(lr) + 1 / math.cos(lr)) / math.pi) / 2 * s
return x, y
def tile(z, x, y):
p = os.path.join(CACHE, f"{z}_{x}_{y}.jpg")
if not os.path.exists(p):
r = requests.get(URL.format(z=z, x=x, y=y), headers=UA, timeout=30)
r.raise_for_status()
open(p, "wb").write(r.content)
return Image.open(p).convert("RGB")
def basemap(bbox, z):
(lat0, lon0, lat1, lon1) = bbox # south, west, north, east
x0, y0 = wpx(lat1, lon0, z); x1, y1 = wpx(lat0, lon1, z)
tx0, ty0, tx1, ty1 = int(x0 // 256), int(y0 // 256), int(x1 // 256), int(y1 // 256)
im = Image.new("RGB", ((tx1 - tx0 + 1) * 256, (ty1 - ty0 + 1) * 256))
for tx in range(tx0, tx1 + 1):
for ty in range(ty0, ty1 + 1):
im.paste(tile(z, tx, ty), ((tx - tx0) * 256, (ty - ty0) * 256))
ox, oy = tx0 * 256, ty0 * 256
im = im.crop((int(x0 - ox), int(y0 - oy), int(x1 - ox), int(y1 - oy)))
return im, (x0, y0)
def font(p, s):
return ImageFont.truetype(p, s)
def label_box(d, xy, lines, fill, scale, anchor_pt=None, sub=None):
f1 = font(FB, int(26 * scale)); f2 = font(FR, int(18 * scale))
w = max(d.textlength(lines[0], font=f1), d.textlength(sub or "", font=f2)) + 28 * scale
h = 38 * scale + (24 * scale if sub else 0)
x, y = xy
x0, y0 = x - w / 2, y - h / 2
if anchor_pt:
d.line([anchor_pt, (x, y)], fill=WHITE, width=max(2, int(3 * scale)))
r = 7 * scale
d.ellipse([anchor_pt[0] - r, anchor_pt[1] - r, anchor_pt[0] + r, anchor_pt[1] + r], fill=fill, outline=WHITE, width=max(2, int(3 * scale)))
d.rounded_rectangle([x0, y0, x0 + w, y0 + h], radius=8 * scale, fill=fill, outline=WHITE, width=max(2, int(2 * scale)))
d.text((x, y0 + 8 * scale), lines[0], font=f1, fill=WHITE, anchor="mt")
if sub:
d.text((x, y0 + 38 * scale), sub, font=f2, fill=WHITE, anchor="mt")
def subject_badge(d, pt, scale, name="MINUTEMAN PARK"):
x, y = pt
f1 = font(FS, int(34 * scale)); f2 = font(FS, int(17 * scale))
w, h = 270 * scale, 78 * scale
bx, by = x - w / 2, y - h - 34 * scale
d.polygon([(x - 14 * scale, by + h), (x + 14 * scale, by + h), (x, y - 6 * scale)], fill=NAVY)
d.rectangle([bx, by, bx + w, by + h], fill=NAVY, outline=WHITE, width=max(2, int(3 * scale)))
d.text((x, by + 10 * scale), "HANOVER", font=f1, fill=WHITE, anchor="mt")
d.line([(bx + 30 * scale, by + 50 * scale), (bx + w - 30 * scale, by + 50 * scale)], fill=WHITE, width=1)
d.text((x, by + 55 * scale), name, font=f2, fill=WHITE, anchor="mt")
r = 11 * scale
d.ellipse([x - r, y - r, x + r, y + r], fill=WHITE, outline=NAVY, width=max(2, int(4 * scale)))
def num_pin(d, pt, n, scale, fill=ORANGE):
x, y = pt; r = 24 * scale
d.ellipse([x - r, y - r, x + r, y + r], fill=fill, outline=WHITE, width=max(2, int(4 * scale)))
d.text((x, y), str(n), font=font(FB, int(28 * scale)), fill=WHITE, anchor="mm")
def make(name, bbox, z, subject, pois=(), pins=(), width=2600, darken=0.82, caption=None, rings=()):
im, (ox, oy) = basemap(bbox, z)
s = width / im.width
im = im.resize((width, int(im.height * s)), Image.LANCZOS)
im = Image.eval(im, lambda v: int(v * darken))
d = ImageDraw.Draw(im)
sc = width / 2600
P = lambda lat, lon: ((wpx(lat, lon, z)[0] - ox) * s, (wpx(lat, lon, z)[1] - oy) * s)
sp = P(*subject)
for miles in rings: # radius rings around subject
lat, lon = subject
dlat = miles / 69.0
r = abs(P(lat + dlat, lon)[1] - sp[1])
d.ellipse([sp[0] - r, sp[1] - r, sp[0] + r, sp[1] + r], outline=WHITE, width=max(2, int(3 * sc)))
d.text((sp[0], sp[1] - r - 6 * sc), f"{miles} MILE", font=font(FB, int(22 * sc)), fill=WHITE, anchor="mb")
for p in pois: # dict(lat, lon, title, sub, dx, dy)
a = P(p["lat"], p["lon"])
label_box(d, (a[0] + p.get("dx", 0) * sc, a[1] + p.get("dy", -70) * sc), [p["title"]], p.get("fill", ORANGE), sc, anchor_pt=a, sub=p.get("sub"))
for p in pins:
num_pin(d, P(p["lat"], p["lon"]), p["n"], sc, p.get("fill", ORANGE))
subject_badge(d, sp, sc)
f = font(FR, int(16 * sc))
txt = "Imagery: Esri, Maxar, Earthstar Geographics" + (f" | {caption}" if caption else "")
tw = d.textlength(txt, font=f)
d.rectangle([im.width - tw - 24 * sc, im.height - 30 * sc, im.width, im.height], fill=(0, 0, 0))
d.text((im.width - 12 * sc, im.height - 15 * sc), txt, font=f, fill=WHITE, anchor="rm")
p = os.path.join(OUT, name)
im.save(p, quality=90)
print(name, im.size)
return p
SUBJ = (42.6931748, -71.2085521)
if __name__ == "__main__":
# 1. Site aerial with points of interest (coords: Google Places via poi.json)
pois = [
dict(lat=42.6940678, lon=-71.2193602, title="PHILIPS", sub="MINUTEMAN PARK", dx=-230, dy=-40),
dict(lat=42.688225, lon=-71.215805, title="MERCURY SYSTEMS", sub="50 MINUTEMAN RD", dx=-300, dy=70),
dict(lat=42.6786052, lon=-71.1976241, title="SCHNEIDER ELECTRIC", sub="R&D CENTER", dx=60, dy=110),
dict(lat=42.7048812, lon=-71.2014754, title="MARKET BASKET", sub="1.2 MI", dx=40, dy=-120),
dict(lat=42.6978937, lon=-71.208099, title="MERRIMACK RIVER TRAIL", dx=-330, dy=-150),
dict(lat=42.6857284, lon=-71.2118393, title="THE POINT AT MERRIMACK RIVER", sub="248 UNITS | HANOVER 2018", dx=-120, dy=170, fill=NAVY),
dict(lat=42.6432881, lon=-71.1889269, title="RAYTHEON (RTX)", sub="10,000 EMPLOYEES", dx=-270, dy=-20),
dict(lat=42.6573798, lon=-71.1450279, title="ANDOVER MBTA STATION", sub="HAVERHILL LINE", dx=-60, dy=-160),
dict(lat=42.6550911, lon=-71.139565, title="DOWNTOWN ANDOVER", sub="MAIN STREET | WHOLE FOODS", dx=230, dy=-60),
dict(lat=42.6492489, lon=-71.1319695, title="PHILLIPS ACADEMY", sub="ADDISON GALLERY", dx=190, dy=70),
dict(lat=42.6571642, lon=-71.1554961, title="ANDOVER HIGH SCHOOL", sub="U.S. NEWS #38 IN MA", dx=-200, dy=110),
dict(lat=42.6726839, lon=-71.1669523, title="ANDOVER COUNTRY CLUB", dx=60, dy=-110),
dict(lat=42.6846681, lon=-71.1361867, title="NORTH ANDOVER MALL", sub="MARKET BASKET", dx=90, dy=-110),
dict(lat=42.7436586, lon=-71.1594268, title="THE LOOP", sub="METHUEN RETAIL", dx=0, dy=90),
]
make("site_aerial_poi.jpg", (42.633, -71.262, 42.752, -71.105), 15, SUBJ, pois=pois, width=2600)
# 2. Rent comps (Datamart property coordinates)
rc = [dict(n=1, lat=42.65070408582695, lon=-71.1843091249466), # Slate
dict(n=2, lat=42.63691753149041, lon=-71.074338555336), # Berry Farms
dict(n=3, lat=42.68572837114343, lon=-71.21183931827545, fill=NAVY), # The Point (Hanover)
dict(n=4, lat=42.72045224905023, lon=-71.11320912837982), # Haven
dict(n=5, lat=42.70448774099358, lon=-71.12665235996246), # Avalon
dict(n=6, lat=42.71139174699792, lon=-71.11989319324495)] # 505 Sutton
make("rent_comps_map.jpg", (42.615, -71.255, 42.745, -71.045), 14, SUBJ, pins=rc, width=2600)
# 3. Supply pipeline (coords per subagents/pipeline/pipeline.json; East Mill coordinate is a proxy)
pp = [dict(n=1, lat=42.7027, lon=-71.1304), # East Mill
dict(n=2, lat=42.7119164, lon=-71.1191918), # 946 Osgood
dict(n=3, lat=42.571006, lon=-71.1596422), # Princeton Wilmington
dict(n=4, lat=42.6081285, lon=-71.2288481), # MacLellan Tewksbury
dict(n=5, lat=42.70448774, lon=-71.12665236, fill=NAVY), # Avalon (delivered)
dict(n=6, lat=42.71139175, lon=-71.11989319, fill=NAVY)] # 505 Sutton (delivered)
make("pipeline_map.jpg", (42.555, -71.300, 42.745, -71.060), 14, SUBJ, pins=pp, width=2600, rings=(3, 5))
# 4. Sale comps (Datamart coordinates)
sc_ = [dict(n=1, lat=42.62004643678673, lon=-71.32143437862396),
dict(n=2, lat=42.541924417018976, lon=-71.28705918788911),
dict(n=3, lat=42.740423977375116, lon=-71.15863502025604),
dict(n=4, lat=42.75350242853173, lon=-71.17054402828217),
dict(n=5, lat=42.71201401948938, lon=-71.1402243375778),
dict(n=6, lat=42.51728564500817, lon=-71.13894760608675)]
make("sale_comps_map.jpg", (42.495, -71.380, 42.775, -71.090), 13, SUBJ, pins=sc_, width=2600)
# 5. Close-up site aerial (z17), subject marker only
make("site_closeup.jpg", (42.6865, -71.2215, 42.6995, -71.1957), 17, SUBJ, width=2600, darken=0.9,
caption="Marker at geocoded address; parcel boundary not shown")
cd hanover/minuteman && time python3 scripts/maps.py && cd assets/maps && for f in *.jpg; do convert $f -resize 1100x qa_$f; done; ls
cd hanover/minuteman/scripts && python3 - <<'EOF'
s=open("maps.py").read()
s=s.replace("def make(name, bbox, z, subject, pois=(), pins=(), width=2600, darken=0.82, caption=None, rings=()):",
"def box(clat, clon, height_mi, aspect=1.55):\n dlat = height_mi / 69.0 / 2\n dlon = height_mi * aspect / (69.0 * math.cos(math.radians(clat))) / 2\n return (clat - dlat, clon - dlon, clat + dlat, clon + dlon)\n\n\ndef make(name, bbox, z, subject, pois=(), pins=(), width=2600, darken=0.82, caption=None, rings=(), ls=1.45):")
s=s.replace(" sc = width / 2600\n"," sc = width / 2600 * ls\n")
s=s.replace('make("site_aerial_poi.jpg", (42.633, -71.262, 42.752, -71.105), 15,','make("site_aerial_poi.jpg", box(42.693, -71.172, 7.6), 15,')
s=s.replace('make("rent_comps_map.jpg", (42.615, -71.255, 42.745, -71.045), 14,','make("rent_comps_map.jpg", box(42.679, -71.145, 7.8), 14,')
s=s.replace('make("pipeline_map.jpg", (42.555, -71.300, 42.745, -71.060), 14,','make("pipeline_map.jpg", box(42.640, -71.175, 12.5), 14,')
s=s.replace('make("sale_comps_map.jpg", (42.495, -71.380, 42.775, -71.090), 13,','make("sale_comps_map.jpg", box(42.636, -71.225, 19.5), 13,')
s=s.replace('make("site_closeup.jpg", (42.6865, -71.2215, 42.6995, -71.1957), 17,','make("site_closeup.jpg", box(42.6925, -71.2085, 1.0), 17,')
open("maps.py","w").write(s)
EOF
grep -n "box(" maps.py | headcd hanover/minuteman && python3 scripts/maps.py && cd assets/maps && for f in site_aerial_poi rent_comps_map pipeline_map sale_comps_map site_closeup; do convert $f.jpg -resize 1100x qa_$f.jpg; done
cd hanover/minuteman/scripts && python3 - <<'EOF'
s=open("maps.py").read()
rep={
'title="THE POINT AT MERRIMACK RIVER", sub="248 UNITS | HANOVER 2018", dx=-120, dy=170':'title="THE POINT AT MERRIMACK RIVER", sub="248 UNITS | HANOVER 2018", dx=-330, dy=120',
'title="SCHNEIDER ELECTRIC", sub="R&D CENTER", dx=60, dy=110':'title="SCHNEIDER ELECTRIC", sub="R&D CENTER", dx=40, dy=150',
'title="ANDOVER COUNTRY CLUB", dx=60, dy=-110':'title="ANDOVER COUNTRY CLUB", dx=-60, dy=-80',
'title="ANDOVER MBTA STATION", sub="HAVERHILL LINE", dx=-60, dy=-160':'title="ANDOVER MBTA STATION", sub="HAVERHILL LINE", dx=250, dy=-110',
'title="DOWNTOWN ANDOVER", sub="MAIN STREET | WHOLE FOODS", dx=230, dy=-60':'title="DOWNTOWN ANDOVER", sub="MAIN STREET | WHOLE FOODS", dx=290, dy=-10',
'title="PHILLIPS ACADEMY", sub="ADDISON GALLERY", dx=190, dy=70':'title="PHILLIPS ACADEMY", sub="ADDISON GALLERY", dx=230, dy=75',
'box(42.693, -71.172, 7.6), 15':'box(42.688, -71.170, 7.8), 15',
}
for a,b in rep.items():
assert a in s, a; s=s.replace(a,b)
open("maps.py","w").write(s)
EOF
cd .. && python3 -c "
import sys; sys.path.insert(0,'scripts'); import maps
" && python3 - <<'EOF'
import sys; sys.path.insert(0,'scripts')
import runpy
EOF
python3 scripts/maps.py >/dev/null && convert assets/maps/site_aerial_poi.jpg -resize 1100x assets/maps/qa_site_aerial_poi.jpgcd hanover/minuteman/assets/maps && montage qa_rent_comps_map.jpg qa_pipeline_map.jpg qa_sale_comps_map.jpg qa_site_closeup.jpg -tile 2x2 -geometry 700x452+4+4 qa_all.jpg
cd /tmp && curl -s -m 30 "https://services1.arcgis.com/hGdibHYSPO59RG1h/arcgis/rest/services/L3_TAXPAR_POLY_ASSESS_gdb/FeatureServer/0/query?where=SITE_ADDR%20LIKE%20%27300%20MINUTEMAN%25%27&outFields=SITE_ADDR,TOWN_ID,LOC_ID,LOT_SIZE,OWNER1,USE_CODE,TOTAL_VAL&returnGeometry=false&f=json" | head -c 1500; echo; curl -s -m 30 "https://services1.arcgis.com/hGdibHYSPO59RG1h/ArcGIS/rest/services?f=json" | python3 -c "import json,sys;d=json.load(sys.stdin);print([s['name'] for s in d.get('services',[]) if 'PAR' in s['name'].upper()][:20])"cd /tmp && B="https://services1.arcgis.com/hGdibHYSPO59RG1h/arcgis/rest/services/Massachusetts_Property_Tax_Parcels/FeatureServer"; curl -s -m 30 "$B?f=json" | python3 -c "import json,sys;d=json.load(sys.stdin);print([(l['id'],l['name']) for l in d.get('layers',[])])"; curl -s -m 60 -G "$B/0/query" --data-urlencode "geometry=-71.2085521,42.6931748" --data-urlencode "geometryType=esriGeometryPoint" --data-urlencode "inSR=4326" --data-urlencode "spatialRel=esriSpatialRelIntersects" --data-urlencode "outFields=*" --data-urlencode "returnGeometry=false" --data-urlencode "f=json" | head -c 1500cd hanover/minuteman && mkdir -p extracts && B="https://services1.arcgis.com/hGdibHYSPO59RG1h/arcgis/rest/services/Massachusetts_Property_Tax_Parcels/FeatureServer/0/query"; curl -s -m 60 -G "$B" --data-urlencode "where=SITE_ADDR LIKE '300 MINUTEMAN%' AND CITY='ANDOVER'" --data-urlencode "outFields=MAP_PAR_ID,SITE_ADDR,CITY,LOT_SIZE,LOT_UNITS,OWNER1,USE_CODE,TOTAL_VAL,LS_DATE,LS_PRICE,ZONING" --data-urlencode "returnGeometry=true" --data-urlencode "outSR=4326" --data-urlencode "f=geojson" -o extracts/parcel_300_minuteman.geojson; python3 -c "
import json;d=json.load(open('extracts/parcel_300_minuteman.geojson'));print(len(d.get('features',[])), d.get('error'))
for f in d.get('features',[]): print(f['properties'], len(f['geometry']['coordinates'][0]))"cd hanover/minuteman && B="https://services1.arcgis.com/hGdibHYSPO59RG1h/arcgis/rest/services/Massachusetts_Property_Tax_Parcels/FeatureServer/0"; curl -s -m 30 "$B?f=json" | python3 -c "import json,sys;d=json.load(sys.stdin);print([f['name'] for f in d['fields']])"; curl -s -m 60 -G "$B/query" --data-urlencode "geometry=-71.2085521,42.6931748" --data-urlencode "geometryType=esriGeometryPoint" --data-urlencode "inSR=4326" --data-urlencode "distance=600" --data-urlencode "units=esriSRUnit_Meter" --data-urlencode "spatialRel=esriSpatialRelIntersects" --data-urlencode "outFields=*" --data-urlencode "returnGeometry=false" --data-urlencode "f=json" | python3 -c "
import json,sys;d=json.load(sys.stdin)
for f in d.get('features',[]): a=f['attributes']; print({k:v for k,v in a.items() if k in ('MAP_PAR_ID','LOC_ID','SITE_ADDR','ADDR_NUM','FULL_STR','LOT_SIZE','OWNER1','USE_CODE','TOTAL_VAL','LS_DATE','LS_PRICE','Shape__Area')})
print(d.get('error'))"cd hanover/minuteman && B="https://services1.arcgis.com/hGdibHYSPO59RG1h/arcgis/rest/services/Massachusetts_Property_Tax_Parcels/FeatureServer/0/query"; curl -s -m 60 -G "$B" --data-urlencode "where=LOC_ID='F_734079_3077942'" --data-urlencode "outFields=MAP_PAR_ID,LOC_ID,SITE_ADDR,CITY,LOT_SIZE,OWNER1,USE_CODE,USE_DESC,ZONING,TOTAL_VAL,LAND_VAL,FY,LS_DATE,LS_PRICE" --data-urlencode "returnGeometry=true" --data-urlencode "outSR=4326" --data-urlencode "f=geojson" -o extracts/parcel_300_minuteman.geojson; python3 -c "
import json;d=json.load(open('extracts/parcel_300_minuteman.geojson'))
for f in d['features']: print(f['properties'], f['geometry']['type'], len(f['geometry']['coordinates'][0]))"cd hanover/minuteman && cat extracts/parcel_300_minuteman.geojson | head -c 300; echo; B="https://services1.arcgis.com/hGdibHYSPO59RG1h/arcgis/rest/services/Massachusetts_Property_Tax_Parcels/FeatureServer/0/query"; curl -s -m 60 -G "$B" --data-urlencode "geometry=-71.2085521,42.6931748" --data-urlencode "geometryType=esriGeometryPoint" --data-urlencode "inSR=4326" --data-urlencode "distance=600" --data-urlencode "units=esriSRUnit_Meter" --data-urlencode "spatialRel=esriSpatialRelIntersects" --data-urlencode "outFields=MAP_PAR_ID,LOC_ID,SITE_ADDR,CITY,LOT_SIZE,OWNER1,USE_CODE,USE_DESC,ZONING,TOTAL_VAL,LAND_VAL,FY,LS_DATE,LS_PRICE" --data-urlencode "returnGeometry=true" --data-urlencode "outSR=4326" --data-urlencode "f=geojson" -o extracts/parcels_minuteman_park.geojson; python3 -c "
import json;d=json.load(open('extracts/parcels_minuteman_park.geojson'))
fs=[f for f in d['features'] if f['properties']['SITE_ADDR']=='300 MINUTEMAN RD']
print(len(d['features']),len(fs)); print(fs[0]['properties'], fs[0]['geometry']['type'])
json.dump({'type':'FeatureCollection','features':fs},open('extracts/parcel_300_minuteman.geojson','w'))"cd hanover/minuteman/scripts && python3 - <<'EOF'
s=open("maps.py").read()
s=s.replace('''def make(name, bbox, z, subject, pois=(), pins=(), width=2600, darken=0.82, caption=None, rings=(), ls=1.45):''','''import json
PARCEL = json.load(open(os.path.join(os.path.dirname(__file__), "..", "extracts", "parcel_300_minuteman.geojson")))["features"][0]["geometry"]["coordinates"][0]
PC = (sum(p[1] for p in PARCEL) / len(PARCEL), sum(p[0] for p in PARCEL) / len(PARCEL)) # parcel vertex-mean (lat, lon)
def make(name, bbox, z, subject, pois=(), pins=(), width=2600, darken=0.82, caption=None, rings=(), ls=1.45, parcel=False):''')
s=s.replace(''' for p in pins:
num_pin(d, P(p["lat"], p["lon"]), p["n"], sc, p.get("fill", ORANGE))''',''' if parcel:
pts = [P(la, lo) for lo, la in PARCEL]
ov = Image.new("RGBA", im.size, (0, 0, 0, 0)); od = ImageDraw.Draw(ov)
od.polygon(pts, fill=(240, 81, 36, 70))
im.paste(Image.alpha_composite(im.convert("RGBA"), ov).convert("RGB")); d = ImageDraw.Draw(im)
d.line(pts + [pts[0]], fill=ORANGE, width=max(3, int(5 * sc)), joint="curve")
for p in pins:
a = P(p["lat"], p["lon"])
if "off" in p:
b = (a[0] + p["off"][0] * sc, a[1] + p["off"][1] * sc)
d.line([a, b], fill=WHITE, width=max(2, int(3 * sc)))
r = 6 * sc; d.ellipse([a[0] - r, a[1] - r, a[0] + r, a[1] + r], fill=WHITE)
a = b
num_pin(d, a, p["n"], sc, p.get("fill", ORANGE))''')
s=s.replace('SUBJ = (42.6931748, -71.2085521)','SUBJ = None')
s=s.replace('if __name__ == "__main__":','if __name__ == "__main__":\n SUBJ = PC')
# pipeline recenter and offsets
s=s.replace(''' pp = [dict(n=1, lat=42.7027, lon=-71.1304), # East Mill
dict(n=2, lat=42.7119164, lon=-71.1191918), # 946 Osgood''',''' pp = [dict(n=1, lat=42.7027, lon=-71.1304, off=(-90, 60)), # East Mill
dict(n=2, lat=42.7119164, lon=-71.1191918, off=(40, -90)), # 946 Osgood''')
s=s.replace(''' dict(n=5, lat=42.70448774, lon=-71.12665236, fill=NAVY), # Avalon (delivered)
dict(n=6, lat=42.71139175, lon=-71.11989319, fill=NAVY)] # 505 Sutton (delivered)''',''' dict(n=5, lat=42.70448774, lon=-71.12665236, fill=NAVY, off=(90, 40)), # Avalon (delivered)
dict(n=6, lat=42.71139175, lon=-71.11989319, fill=NAVY, off=(110, -30))] # 505 Sutton (delivered)''')
s=s.replace('box(42.640, -71.175, 12.5), 14, SUBJ, pins=pp, width=2600, rings=(3, 5))','box(42.650, -71.180, 12.8), 14, SUBJ, pins=pp, width=2600, rings=(3, 5))')
s=s.replace('''make("site_aerial_poi.jpg", box(42.688, -71.170, 7.8), 15, SUBJ, pois=pois, width=2600)''','''make("site_aerial_poi.jpg", box(42.688, -71.170, 7.8), 15, SUBJ, pois=pois, width=2600, parcel=True)''')
s=s.replace('''make("site_closeup.jpg", box(42.6925, -71.2085, 1.0), 17, SUBJ, width=2600, darken=0.9,
caption="Marker at geocoded address; parcel boundary not shown")''','''make("site_closeup.jpg", box(PC[0] - 0.0005, PC[1], 0.95), 17, SUBJ, width=2600, darken=0.95, parcel=True,
caption="Parcel 165-4 boundary: MassGIS Property Tax Parcels (FY2026)")''')
open("maps.py","w").write(s)
EOF
cd .. && python3 scripts/maps.py && cd assets/maps && for f in site_aerial_poi pipeline_map site_closeup; do convert $f.jpg -resize 1100x qa_$f.jpg; done && montage qa_pipeline_map.jpg qa_site_closeup.jpg -tile 2x1 -geometry 700x452+4+4 qa_all.jpgcd /vercel/sandbox && python3 - <<'EOF'
import json
e=json.load(open('subagents/images/entitlement.json'))
print(json.dumps(e,indent=0)[:3500])
a=json.load(open('subagents/pipeline/affordable_rents.json')); print(json.dumps(a,indent=0)[:1500])
EOF
pdftotext -f 34 -l 34 hanover/ref/sugarloaf.pdf - | sed -n '/L EG A L/,$p'cd hanover/minuteman && sed -i 's/box(42.650, -71.180, 12.8), 14, SUBJ, pins=pp/box(42.668, -71.180, 15.5), 14, SUBJ, pins=pp/' scripts/maps.py && python3 scripts/maps.py >/dev/null && convert assets/maps/pipeline_map.jpg -resize 900x assets/maps/qa_pipeline_map.jpg && mkdir -p assets/photos && cp ../../subagents/area/photos/*.jpg ../../subagents/images/hanover_similar/*.jpg ../../subagents/images/comps/*.jpg assets/photos/ && cp ../logo.png ../logo_white.png assets/ && cp ../../subagents/pipeline/*.json ../../subagents/images/*.json ../../subagents/area/*.json extracts/ && ls assets/photos | wc -l
ls hanover/ hanover/minuteman/assets/photos | head -40
cd hanover/ref && pdfimages -list -f 1 -l 4 sugarloaf.pdf | head -20; pdftoppm -r 300 -png -f 3 -l 3 sugarloaf.pdf toc && identify toc-03.png
cd hanover/ref && pdftotext -bbox -f 3 -l 4 sugarloaf.pdf bb.html; grep -n "Hanover" bb.html | head; pdftoppm -r 300 -png -f 1 -l 1 sugarloaf.pdf cov; convert cov-01.png -resize 25% cov_small.png
cd hanover/ref && pdftoppm -r 300 -png -f 4 -l 4 sugarloaf.pdf p4 && convert p4-04.png -crop 3300x350+0+2200 -resize 50% p4_footer.png && convert p4-04.png -crop 3300x400+0+0 -resize 50% p4_header.png
cd hanover/minuteman/extracts && python3 -c "
import json
t=json.load(open('the_point.json')); print(json.dumps(t,indent=0)[:2500])
i=json.load(open('images.json')); print([ (x['filename'],x.get('credit','')[:60]) for x in (i if isinstance(i,list) else i.get('images',[]))][:30])
c=json.load(open('photo_credits.json')); print(c)
"cd hanover/minuteman && cp ../../subagents/pipeline/*.json ../../subagents/images/*.json ../../subagents/area/*.json extracts/ && cp ../../subagents/images/site/site_aerial_meta.json extracts/ && cd extracts && python3 -c "
import json
t=json.load(open('the_point.json')); print(json.dumps(t,indent=0)[:2200])
c=json.load(open('photo_credits.json')); print([(x['filename'],x['author'],x['license']) for x in c])
"Now the calc script for the derived figures, then the deck build.
"""Derived figures for the Hanover Minuteman Park investment book.
Inputs: RealAI Datamart (rent index as of 2026-09-26; SuperCensus 2026-08-15), FY2026 HUD 80% AMI limits
(Lawrence MA-NH HMFA, per extracts/affordable_rents.json), project program per Eagle-Tribune / Bisnow."""
import json, os
X = os.path.join(os.path.dirname(__file__), "..", "extracts")
# Program (Eagle-Tribune; Bisnow; Andover News)
units, aff_units, parking, acres, land = 334, 84, 515, 20.24, 2_500_000
mix = {"1BR": 201, "2BR": 110, "3BR": 23}
print(f"Market-rate units: {units - aff_units}; affordable share {aff_units/units:.1%}")
print(f"Parking ratio: {parking/units:.2f} per unit; density {units/acres:.1f} units/acre")
print(f"Land basis: ${land/units:,.0f}/unit; ${land/acres:,.0f}/acre")
print("Unit mix: " + ", ".join(f"{k} {v} ({v/units:.0%})" for k, v in mix.items()))
# Comp-set average in-place rent by bedroom (6 comps; simple average of reporting properties)
comp = {"1BR": [2408.32, 2515.42, 2561.99, 2512.29, 2475.56, 2799.66],
"2BR": [2962.80, 2991.56, 3156.15, 3176.32, 2992.63, 3248.26],
"3BR": [4171.46, 3736.07, 3886.40, 4051.36]}
avg = {k: sum(v) / len(v) for k, v in comp.items()}
# FY2026 80% AMI max gross rent, 1.5 persons/bedroom (affordable_rents.json)
aff = json.load(open(os.path.join(X, "affordable_rents.json")))["results"]["FY2026"]
for k in ["1BR", "2BR", "3BR"]:
a = aff[k]["max_gross_rent_1.5"]; inc = aff[k]["income_limit_1.5"]
print(f"{k}: comp avg in-place ${avg[k]:,.0f}; target income at 3.0x rent ${avg[k]*36:,.0f}; "
f"80% AMI max gross rent ${a:,.0f} (income limit ${inc:,.0f}); affordable discount {a/avg[k]-1:+.1%}")
renter_med, zip_med = 95223, 180672
for k in avg:
print(f" {k} target income vs 01810 renter median ${renter_med:,}: {avg[k]*36/renter_med-1:+.0%}; vs all-HH median ${zip_med:,}: {avg[k]*36/zip_med-1:+.0%}")
# Weighted market rent reference across the proposed mix (illustrative, comp averages)
w = sum(avg[k] * mix[k] for k in mix) / units
print(f"Mix-weighted comp in-place rent reference: ${w:,.0f}/mo")
# Sale comps $/unit range (Datamart latest sale)
ppu = {"Crosspoint": 354479, "Villas at Old Concord": 353395, "Point at Merrimack Valley": 372436,
"Woods at Merrimack": 268121, "Legacy Park": 290385, "Point at Woburn": 452166}
vals = sorted(ppu.values())
print(f"Sale comps $/unit: low ${vals[0]:,}, high ${vals[-1]:,}, median ${(vals[2]+vals[3])/2:,.0f}; 2020+ vintage avg "
f"${(354479+372436+452166)/3:,.0f}")
cd hanover/minuteman && python3 scripts/book_calcs.py
cd hanover/minuteman && ls scripts && python3 scripts/book_calcs.py
"""Hanover Minuteman Park — investment book in the style of Hanover's Sugarloaf book (letter landscape).
Figures: RealAI Datamart, public sources noted on each page, scripts/book_calcs.py. Placeholders marked [TBD]."""
import os
from PIL import Image
from pptx import Presentation
from pptx.util import Inches, Pt
from pptx.dml.color import RGBColor
from pptx.enum.text import PP_ALIGN, MSO_ANCHOR
from pptx.enum.shapes import MSO_SHAPE
from pptx.chart.data import CategoryChartData
from pptx.enum.chart import XL_CHART_TYPE, XL_LEGEND_POSITION, XL_LABEL_POSITION
from pptx.oxml.ns import qn
ROOT = os.path.join(os.path.dirname(__file__), "..")
A = lambda *p: os.path.join(ROOT, "assets", *p)
os.makedirs(A("crops"), exist_ok=True)
NAVY = RGBColor(0x0A, 0x22, 0x40); SLATE = RGBColor(0x47, 0x5A, 0x70); STEEL = RGBColor(0x5B, 0x7C, 0x99)
GRAY = RGBColor(0xA2, 0xAA, 0xAD); LGRAY = RGBColor(0xE8, 0xEA, 0xEB); ORANGE = RGBColor(0xF0, 0x51, 0x24)
TXT = RGBColor(0x23, 0x23, 0x23); WHITE = RGBColor(0xFF, 0xFF, 0xFF); TBDFILL = RGBColor(0xFD, 0xEE, 0xE8)
SERIF = "Garamond"; SANS = "Century Gothic"
NAME = "Hanover Minuteman Park"
prs = Presentation()
prs.slide_width, prs.slide_height = Inches(11), Inches(8.5)
W, H = prs.slide_width, prs.slide_height
BL = prs.slide_layouts[6]
I = Inches
def crop(src, w, h, fy=0.5):
im = Image.open(A("photos", src) if not os.path.isabs(src) and not src.startswith("maps") else A(src)).convert("RGB")
tr = w / h; r = im.width / im.height
if r > tr:
nw = int(im.height * tr); x0 = (im.width - nw) // 2; im = im.crop((x0, 0, x0 + nw, im.height))
else:
nh = int(im.width / tr); y0 = int((im.height - nh) * fy); im = im.crop((0, y0, im.width, y0 + nh))
if im.width > 2400: im = im.resize((2400, int(2400 / tr)), Image.LANCZOS)
out = A("crops", f"{os.path.basename(src).rsplit('.',1)[0]}_{w:.2f}x{h:.2f}.jpg"); im.save(out, quality=88)
return out
def pic(s, src, x, y, w, h, fy=0.5):
return s.shapes.add_picture(crop(src, w, h, fy), I(x), I(y), I(w), I(h))
def spacing(run, pts):
run._r.get_or_add_rPr().set("spc", str(int(pts * 100)))
def tb(s, x, y, w, h, text, size=11, color=TXT, font=SERIF, bold=False, align=PP_ALIGN.LEFT, italic=False,
anchor=MSO_ANCHOR.TOP, sp=0, line=None, caps=False):
box = s.shapes.add_textbox(I(x), I(y), I(w), I(h)); tf = box.text_frame; tf.word_wrap = True
tf.margin_left = tf.margin_right = I(0.02); tf.margin_top = tf.margin_bottom = I(0.01); tf.vertical_anchor = anchor
paras = text if isinstance(text, list) else [text]
for i, ptxt in enumerate(paras):
p = tf.paragraphs[0] if i == 0 else tf.add_paragraph(); p.alignment = align
if line: p.line_spacing = line
runs = ptxt if isinstance(ptxt, list) else [ptxt]
for r_ in runs:
if isinstance(r_, tuple):
t, kw = r_[0], r_[1]
else:
t, kw = r_, {}
r = p.add_run(); r.text = t.upper() if caps else t
f = r.font; f.name = kw.get("font", font); f.size = Pt(kw.get("size", size)); f.bold = kw.get("bold", bold)
f.italic = kw.get("italic", italic); f.color.rgb = kw.get("color", color)
if sp: spacing(r, sp)
return box
def rect(s, x, y, w, h, fill, line=None):
r = s.shapes.add_shape(MSO_SHAPE.RECTANGLE, I(x), I(y), I(w), I(h)); r.fill.solid(); r.fill.fore_color.rgb = fill
if line is None: r.line.fill.background()
else: r.line.color.rgb = line; r.line.width = Pt(0.75)
r.shadow.inherit = False
return r
def hline(s, x, y, w, color=GRAY, wt=0.75):
ln = s.shapes.add_connector(1, I(x), I(y), I(x + w), I(y)); ln.line.color.rgb = color; ln.line.width = Pt(wt); return ln
def page(title, num, sub=None):
s = prs.slides.add_slide(BL)
tb(s, 0.5, 0.32, 9.5, 0.6, title, 27, NAVY, SERIF, False, sp=1.5, caps=False)
hline(s, 0, 0.95, 5.2 if len(title) < 28 else 7.2, GRAY)
if sub: tb(s, 0.5, 1.0, 10, 0.3, sub, 10, STEEL, SANS, True, sp=0.5, caps=True)
# footer like Sugarloaf: number + wordmark + rule
even = num % 2 == 0
if even:
tb(s, 0.5, 8.05, 0.35, 0.25, str(num), 10, NAVY, SERIF, True)
tb(s, 0.85, 8.05, 1.4, 0.25, "HANOVER", 10, GRAY, SERIF, sp=2)
hline(s, 2.15, 8.17, 8.85, GRAY, 0.5)
else:
hline(s, 0, 8.17, 7.5, GRAY, 0.5)
tb(s, 7.6, 8.05, 2.6, 0.25, NAME.upper(), 10, GRAY, SERIF, align=PP_ALIGN.RIGHT, sp=1)
tb(s, 10.2, 8.05, 0.35, 0.25, str(num), 10, NAVY, SERIF, True, align=PP_ALIGN.RIGHT)
return s
def head(s, x, y, w, text, color=STEEL, size=11):
return tb(s, x, y, w, 0.3, text, size, color, SANS, True, caps=True, sp=0.3)
def body(s, x, y, w, h, paras, size=11, color=TXT):
box = tb(s, x, y, w, h, paras, size, color, SERIF, line=1.08)
for p in box.text_frame.paragraphs: p.alignment = PP_ALIGN.JUSTIFY; p.space_after = Pt(5)
return box
def bullets(s, x, y, w, h, items, size=10.5, color=TXT, gap=4):
box = s.shapes.add_textbox(I(x), I(y), I(w), I(h)); tf = box.text_frame; tf.word_wrap = True
for i, it in enumerate(items):
p = tf.paragraphs[0] if i == 0 else tf.add_paragraph(); p.space_after = Pt(gap)
hd, txt = it if isinstance(it, tuple) else (None, it)
r = p.add_run(); r.text = "• "; r.font.size = Pt(size); r.font.color.rgb = ORANGE; r.font.name = SERIF
if hd:
r = p.add_run(); r.text = hd + " "; r.font.bold = True; r.font.size = Pt(size); r.font.color.rgb = NAVY; r.font.name = SERIF
r = p.add_run(); r.text = txt; r.font.size = Pt(size); r.font.color.rgb = color; r.font.name = SERIF
return box
def src(s, text, y=7.72, h=0.3):
tb(s, 0.5, y, 10, h, text, 7, GRAY, SANS, italic=False)
def table(s, x, y, w, rows, cw, rh=0.28, size=8.5, hdr=NAVY, bold_rows=(), shade=(), tbd_cells=(), align_first=PP_ALIGN.LEFT,
section=()):
nr, nc = len(rows), len(rows[0])
gs = s.shapes.add_table(nr, nc, I(x), I(y), I(w), I(rh * nr)); t = gs.table
tp = gs._element.graphic.graphicData.tbl.tblPr; tp.set("bandRow", "0"); tp.set("firstRow", "0")
for j, c in enumerate(cw): t.columns[j].width = I(c)
for i, r in enumerate(rows):
t.rows[i].height = I(rh)
for j, v in enumerate(r):
c = t.cell(i, j); c.margin_left = c.margin_right = I(0.05); c.margin_top = c.margin_bottom = I(0.01)
c.vertical_anchor = MSO_ANCHOR.MIDDLE; tf = c.text_frame; tf.word_wrap = True
p = tf.paragraphs[0]; p.alignment = align_first if j == 0 else PP_ALIGN.CENTER
run = p.add_run(); run.text = str(v); run.font.size = Pt(size); run.font.name = SANS
c.fill.solid()
is_tbd = (i, j) in tbd_cells or str(v).startswith("[TBD")
if i == 0:
c.fill.fore_color.rgb = hdr; run.font.color.rgb = WHITE; run.font.bold = True
elif i in section:
c.fill.fore_color.rgb = SLATE; run.font.color.rgb = WHITE; run.font.bold = True
elif is_tbd:
c.fill.fore_color.rgb = TBDFILL; run.font.color.rgb = ORANGE; run.font.italic = True
elif i in shade:
c.fill.fore_color.rgb = LGRAY; run.font.color.rgb = NAVY; run.font.bold = True
else:
c.fill.fore_color.rgb = WHITE if i % 2 else RGBColor(0xF5, 0xF6, 0xF7); run.font.color.rgb = TXT
run.font.bold = i in bold_rows
for i in section: t.cell(i, 0).merge(t.cell(i, nc - 1))
return t
def stat_panel(s, x, y, w, h, stats, title=None):
rect(s, x, y, w, h, NAVY)
yy = y + 0.2
if title:
tb(s, x + 0.2, yy, w - 0.4, 0.3, title, 10, GRAY, SANS, True, caps=True, sp=0.5); yy += 0.4
step = (h - (yy - y) - 0.1) / len(stats)
for v, l in stats:
tb(s, x + 0.2, yy, w - 0.4, 0.45, v, 22, WHITE, SERIF, sp=0.5)
tb(s, x + 0.2, yy + 0.43, w - 0.4, 0.3, l, 8, GRAY, SANS, True, caps=True, sp=0.3)
yy += step
def tbd_tag(s, x, y, text="PLACEHOLDER — HANOVER TO PROVIDE"):
r = rect(s, x, y, 3.2, 0.3, ORANGE)
tb(s, x, y + 0.03, 3.2, 0.25, text, 8.5, WHITE, SANS, True, align=PP_ALIGN.CENTER, sp=0.5)
def caption(s, x, y, w, title, sub=None, dark=False):
tb(s, x, y, w, 0.25, title, 9.5, NAVY if not dark else WHITE, SANS, True, caps=True, sp=0.4)
if sub: tb(s, x, y + 0.25, w, 0.6, sub, 9, TXT if not dark else WHITE, SERIF)
# =============== 1. COVER ===============
s = prs.slides.add_slide(BL)
pic(s, "stoneham_exterior.jpg", 0, 1.95, 11, 6.55, 0.55)
rect(s, 0, 0, 11, 1.95, NAVY)
tb(s, 0, 0.28, 11, 0.7, NAME, 36, WHITE, SERIF, align=PP_ALIGN.CENTER, sp=1.5)
hline(s, 2.3, 1.0, 6.4, WHITE, 0.5)
tb(s, 0, 1.03, 11, 0.35, "334-Unit Multifamily Development Opportunity", 12, WHITE, SERIF, align=PP_ALIGN.CENTER, sp=2.5, caps=True)
hline(s, 2.3, 1.38, 6.4, WHITE, 0.5)
tb(s, 0, 1.45, 11, 0.4, "Andover | Massachusetts", 13, WHITE, SERIF, align=PP_ALIGN.CENTER, sp=3, caps=True)
tb(s, 0.45, 7.45, 4, 0.55, "HANOVER", 30, WHITE, SERIF, bold=True, sp=2)
tb(s, 0.47, 8.0, 6, 0.3, "Photo of similar Hanover project (Hanover Stoneham); subject project will vary", 9, WHITE, SERIF, italic=True)
# =============== 2. FULL-BLEED AREA PHOTO ===============
s = prs.slides.add_slide(BL)
pic(s, "downtown_andover_main_street.jpg", 0, 0, 11, 8.5, 0.6)
rect(s, 0, 7.75, 11, 0.75, NAVY)
tb(s, 0.5, 7.92, 6, 0.4, "Downtown Andover — Main Street", 13, WHITE, SERIF, sp=1.5, caps=True)
tb(s, 8.3, 7.86, 2.3, 0.45, "HANOVER", 22, WHITE, SERIF, bold=True, align=PP_ALIGN.RIGHT, sp=2)
tb(s, 0.5, 7.5, 6, 0.25, "Photo: John Phelan, CC BY 3.0, via Wikimedia Commons", 7, WHITE, SANS)
# =============== 3. TABLE OF CONTENTS ===============
s = page("Table of Contents", 3)
pic(s, "phillips_academy_memorial_bell_tower.jpg", 6.6, 0, 4.4, 8.0, 0.35)
toc = [("Executive Summary", 4), ("Site Aerial", 5), ("Financial Summary", 6), ("Cost & Operations Assumptions", 8),
("Why Minuteman Park", 10), ("Nearby Points of Interest", 11), ("Site Overview", 12), ("Building Summary & Entitlement", 13),
("Sample Renderings", 14), ("Demographics", 16), ("Supply Pipeline", 18), ("Rent Comps", 19), ("Leasing Momentum", 23),
("Asset Sale Comps", 24), ("Sponsor Track Record", 25), ("Key Contacts", 26)]
for i, (t, n) in enumerate(toc):
y = 1.35 + i * 0.39
tb(s, 0.7, y, 4.6, 0.32, t, 14, NAVY, SERIF)
tb(s, 5.3, y, 0.8, 0.32, str(n), 14, SLATE, SERIF, align=PP_ALIGN.RIGHT)
hline(s, 0.7, y + 0.34, 5.4, LGRAY, 0.5)
tb(s, 6.75, 7.65, 4, 0.3, "Phillips Academy Memorial Bell Tower. Photo: Daderot, public domain", 7, WHITE, SANS)
# =============== 4. EXECUTIVE SUMMARY ===============
s = page("Executive Summary", 4)
head(s, 0.5, 1.15, 3.5, "Overview")
body(s, 0.5, 1.42, 3.45, 6.2, [
"Hanover Company (\u201cHanover\u201d) acquired an approximately 20.24-acre vacant site at 300 Minuteman Road in Andover, "
"Massachusetts (the \u201cSite\u201d) in May 2026 for $2.5 million. Hanover intends to develop a 334-unit, Class A "
"multifamily community (the \u201cProject\u201d) across three four- and five-story buildings with 515 parking spaces.",
"The Project is proposed through the state\u2019s Local Initiative Program (\u201cfriendly 40B\u201d), with 84 units (25%) "
"reserved for households earning up to 80% of area median income and 250 market-rate units. Roughly two acres are "
"reserved for future commercial development.",
"The Site sits inside Minuteman Park, a 1M+ SF office, R&D and lab campus directly off I-93, 25 miles north of Boston, "
"next to the town\u2019s MBTA Multifamily Overlay District. It was previously approved for a 224,500 SF pharmaceutical "
"manufacturing facility that did not advance."])
head(s, 4.2, 1.15, 3.4, "Location & Market")
body(s, 4.2, 1.42, 3.45, 6.2, [
"Andover pairs one of Greater Boston\u2019s strongest employment bases (Raytheon alone employs ~10,000 in town) with a "
"high-income renter pool. ZIP 01810\u2019s median household income is $180,672, about 45% above the Boston MSA, and "
"households moving in earn $45K more than those moving out.",
"Supply is limited. No 120+ unit community has delivered in Andover or North Andover since 2022, and Andover permitted just "
"nine new homes in 2023. New product in the comp set earns 9–12% premiums on two- and three-bedroom units, and ZIP occupancy "
"is 96.7%.",
"Hanover built The Point at Merrimack River (248 units, 2018) about half a mile from the Site. It is 96.8% occupied."])
stat_panel(s, 7.95, 1.15, 2.55, 6.45, [("334", "Total units"), ("84 / 25%", "Affordable at ≤80% AMI"), ("20.24 AC", "Site area"),
("$2.5M", "Land basis ($7,485 / unit)"), ("515", "Parking spaces (1.54 / unit)"),
("$1.5M", "Est. annual property tax")], "Project at a Glance")
src(s, "Sources: Bisnow (May 2026); Andover News & Eagle-Tribune (Sep 2026); Andover Ledger; Newmark; RealAI Datamart (SuperCensus Aug 2026; Rent Index Sep 26, 2026). "
"Property tax figure is Hanover's estimate as presented to the Select Board.")
# =============== 5. SITE AERIAL ===============
s = page("Site Aerial", 5)
s.shapes.add_picture(A("maps", "site_aerial_poi.jpg"), I(0.5), I(1.15), I(10.0), I(6.45))
src(s, "Imagery: Esri, Maxar, Earthstar Geographics. Site outline: MassGIS Property Tax Parcels (parcel 165-4). Locations: Google Places; RealAI Datamart. "
"Raytheon employment per Andover News (2024); Andover High ranking per U.S. News.", y=7.65)
# =============== 6. FINANCIAL SUMMARY (PLACEHOLDER) ===============
s = page("Financial Summary", 6)
tbd_tag(s, 7.3, 0.45)
head(s, 0.5, 1.15, 4, "Project Summary")
rows = [["Item", "Value"], ["Product Type", "Suburban garden / mid-rise"], ["Construction Type", "[TBD]"],
["Levels", "4 and 5 stories (3 buildings)"], ["Units", "334 (250 market / 84 affordable)"], ["Site Area", "20.24 acres (16.5 units / acre)"],
["NRSF", "[TBD]"], ["Average Unit Size", "[TBD]"], ["Going-In Rent (PSF / Nominal)", "[TBD]"],
["Parking", "515 spaces / 1.54 per unit"], ["Land Closing", "May 2026 (closed)"], ["Construction Start", "[TBD]"],
["Construction Duration", "[TBD]"], ["First Units Delivered", "[TBD]"], ["Stabilization", "[TBD]"]]
table(s, 0.5, 1.45, 4.6, rows, [2.3, 2.3], rh=0.36, size=9)
head(s, 5.5, 1.15, 5, "Returns")
for i, (lbl) in enumerate(["Going-In Return on Cost", "Stabilized Return on Cost", "Unlevered Project IRR", "Levered Project IRR",
"Equity Multiple", "Total Project Cost"]):
x = 5.5 + (i % 2) * 2.55; y = 1.45 + (i // 2) * 1.35
rect(s, x, y, 2.4, 1.2, LGRAY)
tb(s, x, y + 0.18, 2.4, 0.5, "[TBD]", 24, ORANGE, SERIF, align=PP_ALIGN.CENTER, italic=True)
tb(s, x, y + 0.75, 2.4, 0.3, lbl, 8.5, NAVY, SANS, True, align=PP_ALIGN.CENTER, caps=True)
head(s, 5.5, 5.6, 5, "Market Reference Points (Not Underwriting)")
bullets(s, 5.5, 5.88, 5.0, 1.8, [
("Comp in-place rent:", "$2,822/mo average, weighted to the proposed unit mix (six 2016–2022 Andover/North Andover comps)."),
("80% AMI rent caps (FY2026):", "1BR $2,003, 2BR $2,404, 3BR $2,777 gross, before utility allowance."),
("Sale comps:", "$268K–$452K/unit across six 2024–2026 trades. 2020+ vintages average $393K/unit.")], size=9.5)
src(s, "[TBD] = to be provided from Hanover's underwriting model. Program per Eagle-Tribune / Bisnow; reference points from RealAI Datamart and HUD FY2026 income limits (Lawrence, MA-NH HMFA).")
# =============== 7. UNIT MIX + SOURCES & USES (PLACEHOLDER) ===============
s = page("Financial Summary", 7, "Unit Mix | Sources & Uses")
tbd_tag(s, 7.3, 0.45)
rows = [["Unit Mix", "Units", "% Total", "Market", "Affordable", "Avg SF"],
["One Bedrooms", "201", "60%", "[TBD]", "[TBD]", "[TBD]"], ["Two Bedrooms", "110", "33%", "[TBD]", "[TBD]", "[TBD]"],
["Three Bedrooms", "23", "7%", "[TBD]", "[TBD]", "[TBD]"], ["Total", "334", "100%", "250", "84", "[TBD]"]]
table(s, 0.5, 1.5, 6.2, rows, [1.7, 0.8, 0.8, 0.95, 0.95, 1.0], rh=0.34, size=9, shade=(4,))
uses = [["Uses", "Total", "Per Unit", "Per NRSF"], ["Land Costs", "$2,500,000", "$7,485", "[TBD]"], ["Hard Costs", "[TBD]", "[TBD]", "[TBD]"],
["Soft Costs", "[TBD]", "[TBD]", "[TBD]"], ["Developer Fee", "[TBD]", "[TBD]", "[TBD]"], ["Contingency", "[TBD]", "[TBD]", "[TBD]"],
["Financing Costs", "[TBD]", "[TBD]", "[TBD]"], ["Total Uses", "[TBD]", "[TBD]", "[TBD]"]]
table(s, 0.5, 3.55, 4.9, uses, [1.6, 1.15, 1.05, 1.1], rh=0.34, size=9, shade=(7,))
srcs = [["Sources", "Total", "Per Unit", "% Total"], ["Limited Partner", "[TBD]", "[TBD]", "[TBD]"], ["Hanover (GP)", "[TBD]", "[TBD]", "[TBD]"],
["Construction Loan", "[TBD]", "[TBD]", "[TBD]"], ["Total Sources", "[TBD]", "[TBD]", "[TBD]"]]
table(s, 5.65, 3.55, 4.85, srcs, [1.6, 1.15, 1.05, 1.05], rh=0.34, size=9, shade=(4,))
src(s, "Unit mix and affordable count per Eagle-Tribune (Sep 2026); the split of affordable units by bedroom is not public. Land cost per Bisnow / public records ($2.5M, May 2026).")
# =============== 8. COST ASSUMPTIONS (PLACEHOLDER) ===============
s = page("Cost Assumptions", 8)
tbd_tag(s, 7.3, 0.45)
blocks = [("Project Name & Location", [["Project Name", NAME], ["Project Location", "Andover, MA"], ["Land Closing", "May 2026"],
["Construction Start", "[TBD]"], ["Construction Duration", "[TBD]"]]),
("Operating & Sale Assumptions", [["Residential Cap Rate", "[TBD]"], ["Retail / Commercial Cap Rate", "[TBD]"], ["Income Growth", "[TBD]"],
["Expense Growth", "[TBD]"], ["Closing Costs on Sale", "[TBD]"]]),
("Equity Assumptions", [["LP / GP Contributions", "[TBD]"], ["Total Equity", "[TBD]"], ["Preferred Return", "[TBD]"],
["Promote Structure", "[TBD]"], ["Equity Timing", "[TBD]"]]),
("Debt & Additional Inputs", [["Construction Loan LTC", "[TBD]"], ["Interest Rate / Index", "[TBD]"], ["Loan Term", "[TBD]"],
["Hard Cost Contingency", "[TBD]"], ["Developer Fee", "[TBD]"]])]
for k, (t, rws) in enumerate(blocks):
x = 0.5 + (k % 2) * 5.1; y = 1.25 + (k // 2) * 3.0
head(s, x, y, 4.8, t)
table(s, x, y + 0.32, 4.9, [["Input", "Assumption"]] + rws, [2.5, 2.4], rh=0.36, size=9)
src(s, "All [TBD] inputs to be populated from Hanover's underwriting model; none of these figures have been sourced.")
# =============== 9. OPERATIONS ASSUMPTIONS (PLACEHOLDER) ===============
s = page("Operations Assumptions", 9)
tbd_tag(s, 7.3, 0.45)
head(s, 0.5, 1.15, 6, "Market Unit Mix")
rows = [["Unit Type", "Units", "Avg SF", "Comp In-Place Rent¹", "80% AMI Max Gross Rent²", "Underwritten Rent", "Underwritten $/SF"],
["1BR", "201", "[TBD]", "$2,546", "$2,003", "[TBD]", "[TBD]"], ["2BR", "110", "[TBD]", "$3,088", "$2,404", "[TBD]", "[TBD]"],
["3BR", "23", "[TBD]", "$3,961", "$2,777", "[TBD]", "[TBD]"], ["Blended", "334", "[TBD]", "$2,822", "—", "[TBD]", "[TBD]"]]
table(s, 0.5, 1.45, 10.0, rows, [1.2, 0.8, 0.9, 1.75, 1.95, 1.7, 1.7], rh=0.36, size=9, shade=(4,))
head(s, 0.5, 3.45, 6, "Annual Operating Expenses")
ox = [["Expense Category", "Input Type", "Total", "Per Unit", "Per SF"]] + [[c, "[TBD]", "[TBD]", "[TBD]", "[TBD]"] for c in
["Controllable (payroll, R&M, turnover, marketing, G&A)", "Management Fee", "Real Estate Taxes", "Insurance", "Utilities", "Replacement Reserves"]] + \
[["Total Operating Expenses", "", "[TBD]", "[TBD]", "[TBD]"]]
table(s, 0.5, 3.75, 6.6, ox, [2.9, 0.9, 0.95, 0.95, 0.9], rh=0.34, size=8.5, shade=(7,))
head(s, 7.4, 3.45, 3, "Other Income")
table(s, 7.4, 3.75, 3.1, [["Item", "Assumption"], ["Parking", "[TBD]"], ["Amenity Fee", "[TBD]"], ["Storage", "[TBD]"], ["Pet / Other", "[TBD]"],
["Vacancy & Credit Loss", "[TBD]"]], [1.7, 1.4], rh=0.34, size=8.5)
src(s, "¹ Simple average in-place rent of six 2016–2022 comps (RealAI Rent Index, Sep 26, 2026); the blended figure is weighted to the proposed mix. ² FY2026 HUD 80% AMI limits, "
"Lawrence MA-NH HMFA, 30% of income, 1.5 persons/bedroom, gross before utility allowance; confirm against EOHLC LIP guidelines.", y=7.55, h=0.45)
# =============== 10. WHY MINUTEMAN PARK ===============
s = page("Why Minuteman Park?", 10)
pic(s, "andover_mbta_commuter_rail_station.jpg", 7.15, 1.15, 3.35, 2.4)
tb(s, 7.15, 3.57, 3.35, 0.25, "Andover MBTA station (Haverhill Line). Photo: John Phelan, CC BY-SA 3.0", 7, GRAY, SANS)
body(s, 0.5, 1.15, 6.4, 3.6, [
[("Economic base. ", {"bold": True, "color": NAVY}), "Andover is a diversified employment hub where two interstates meet. Raytheon is the largest "
"employer, with about 10,000 employees in 2024 (nearly a third of the town\u2019s private-sector jobs), followed by Pfizer and Smith & Nephew. "
"Minuteman Park is 1M+ SF of office, R&D and lab space; eight of its assets traded for $341 million in 2022. Philips and Mercury Systems sit within a mile of the Site."],
[("Access. ", {"bold": True, "color": NAVY}), "The Site is directly off I-93, five miles south of the New Hampshire border and 25 miles north of Boston, "
"with I-495 just to the south. Two MBTA Commuter Rail stations serve Andover, with scheduled trips to North Station of about 52 minutes."],
[("Quality of life. ", {"bold": True, "color": NAVY}), "Andover High ranks #38 in Massachusetts (U.S. News, 97% graduation rate). Phillips Academy "
"and the Addison Gallery anchor the town\u2019s cultural life. Market Basket is 1.2 miles away and the Merrimack River trail system less than half a mile."]], size=11)
stats = [("~10,000", "Raytheon employees in Andover"), ("25 mi", "To Boston via I-93"), ("#38", "Andover High, U.S. News MA ranking"),
("$341M", "2022 Minuteman Park asset trade"), ("$180.7K", "ZIP 01810 median HH income"), ("10x", "Projected tax revenue vs. today")]
for i, (v, l) in enumerate(stats):
x = 0.5 + (i % 3) * 3.35; y = 4.95 + (i // 3) * 1.25
rect(s, x, y, 3.15, 1.1, NAVY if i % 2 == 0 else SLATE)
tb(s, x + 0.15, y + 0.12, 2.9, 0.5, v, 24, WHITE, SERIF)
tb(s, x + 0.15, y + 0.68, 2.9, 0.35, l, 8.5, WHITE, SANS, True, caps=True)
src(s, "Sources: Andover News (Andover by the Numbers: employers; top taxpayers); Newmark (2022); U.S. News Best High Schools; MBTA timetable via Transit app; Andover Ledger (Sep 2026; "
"$1.5M vs ~$150K tax); RealAI SuperCensus. Distances approximate.", y=7.5, h=0.45)
# =============== 11. POINTS OF INTEREST ===============
s = page("Nearby Points of Interest", 11)
pois = [("downtown_andover_main_street.jpg", "Downtown Andover", "Main Street dining and shops, Whole Foods and the commuter rail station, ~5.7 mi.", "John Phelan, CC BY 3.0"),
("addison_gallery_phillips_academy_andover.jpg", "Phillips Academy & Addison Gallery", "Phillips Academy and its free Addison Gallery of American Art, ~6.4 mi.", "Daderot, public domain"),
("harold_parker_state_forest_berry_pond.jpg", "Harold Parker State Forest", "3,000+ acres of trails and ponds, reached via I-93/I-495.", "John Phelan, CC BY-SA 4.0"),
("i495_bridge_merrimack_river_lawrence.jpg", "Merrimack River & I-495", "The river borders Minuteman Park. The Merrimack River Trail is ~0.4 mi from the Site.", "John Phelan, CC BY 3.0")]
for i, (f, t, d, cr) in enumerate(pois):
x = 0.5 + (i % 2) * 5.1; y = 1.15 + (i // 2) * 3.25
pic(s, f, x, y, 4.9, 2.35)
caption(s, x, y + 2.4, 4.9, t, d)
tb(s, x, y + 2.95, 4.9, 0.2, "Photo: " + cr + ", via Wikimedia Commons", 6.5, GRAY, SANS)
src(s, "Distances are approximate straight-line × 1.3 drive factor from the Site. Harold Parker State Forest acreage per Massachusetts DCR (confirm). Photo locations per Wikimedia Commons metadata.")
# =============== 12. SITE OVERVIEW ===============
s = page("Site Overview", 12)
s.shapes.add_picture(A("maps", "site_closeup.jpg"), I(0.5), I(1.15), I(6.6), I(4.26))
head(s, 7.35, 1.15, 3.2, "Site Facts")
rows = [["Item", "Detail"], ["Address", "300 Minuteman Rd, Andover MA"], ["Parcel", "Map 165, Lot 4"], ["Site Area", "20.24 acres"],
["Zoning", "ID2 (Industrial)"], ["Current Use", "Vacant, developable industrial land"], ["Assessed Value (FY26)", "$6,344,300"],
["Acquisition", "May 2026, $2.5M (Spear Street Capital)"], ["Prior Approval", "224,500 SF cGMP facility (2023)"],
["Adjacency", "MBTA Multifamily Overlay District"]]
table(s, 7.35, 1.45, 3.15, rows, [1.25, 1.9], rh=0.395, size=8)
head(s, 0.5, 5.6, 6, "Site Highlights")
bullets(s, 0.5, 5.88, 10.0, 1.6, [
("Infrastructure in place:", "Site work was originally built for a 320,000 SF office building. Hanover expects to complete the sewer work required of the prior project."),
("Highway frontage:", "The parcel sits along I-93 at the River Road interchange, next to Philips, Mercury Systems and the Alexandria lab campus."),
("Catalyst for Minuteman Park:", "Hanover's preliminary park-wide vision adds housing, new roads and grocery-anchored retail. Only this Project is before the town.")], size=10)
src(s, "Sources: MassGIS Property Tax Parcels (FY2026 assessor data; owner of record not yet updated after the May 2026 sale); Bisnow; Eagle-Tribune; Andover Planning Board minutes (Sep 14, 2021); Andover News (Sep 2026).")
# =============== 13. BUILDING SUMMARY & ENTITLEMENT ===============
s = page("Building Summary & Entitlement", 13)
head(s, 0.5, 1.15, 4.5, "Project Summary")
bullets(s, 0.5, 1.45, 4.6, 3.6, [
("Buildings.", "Three elevator-served buildings (two five-story, one four-story) totaling 334 units: 201 one-bedroom, 110 two-bedroom and 23 three-bedroom."),
("Affordability.", "84 units (25%) restricted to households at or below 80% AMI under a Local Initiative Program comprehensive permit. 250 units at market rents."),
("Parking.", "515 spaces (1.54 per unit). Garage vs. surface split [TBD]."),
("Amenities.", "Fitness center and coworking space per public presentations. Full amenity program, construction type and unit sizes [TBD]."),
("Commercial.", "~2 acres reserved for future commercial / retail development.")], size=10)
head(s, 5.4, 1.15, 5, "Entitlement Timeline")
tl = [("2021–2023", "Prior owner permits a 224,500 SF cGMP pharmaceutical facility (approved 2023; did not advance)."),
("Apr 14, 2026", "Select Board overview: multifamily as phase one of a multi-phase Minuteman Park redevelopment."),
("Apr 21, 2026", "Conservation Commission denies a procedural amendment to the prior Order of Conditions (timing of a conservation restriction). It does not rule on the housing project."),
("May 2026", "Hanover acquires the Site for $2.5M."),
("Sep 15, 2026", "LIP first reading before the Select Board. No vote; board to submit questions."),
("Oct 26, 2026", "Second reading targeted (expected)."),
("Next", "State (EOHLC) project eligibility, then a Zoning Board of Appeals comprehensive permit hearing.")]
y = 1.48
for d, e in tl:
rect(s, 5.4, y + 0.05, 0.12, 0.12, ORANGE)
tb(s, 5.62, y - 0.02, 1.25, 0.3, d, 9, NAVY, SANS, True)
tb(s, 6.9, y - 0.02, 3.6, 0.6, e, 9.5, TXT, SERIF)
y += 0.62
head(s, 0.5, 5.3, 4.6, "Issues Raised by the Town")
bullets(s, 0.5, 5.58, 4.6, 1.9, ["Traffic on River Road and nearby interchanges", "Updated sewer-capacity analysis requested by DPW",
"Archaeological review of the former Shattuck Farm", "Conversion of commercial and industrial land to housing"], size=9.5, gap=2)
rect(s, 5.4, 5.95, 5.1, 1.45, LGRAY)
tb(s, 5.55, 6.02, 4.8, 1.35, [[("Tax impact. ", {"bold": True, "color": NAVY}),
"Hanover estimates the Project would generate ~$1.5M a year in property taxes, about 10x the ~$150K the vacant parcel pays today. "
"It also estimates 33–50 school-age children; town counsel noted enrollment is generally not a ZBA consideration."]], 9.5, TXT, SERIF)
src(s, "Sources: Andover News & Andover Ledger (Sep 16, 2026); Eagle-Tribune; Bisnow; Andover Conservation Commission minutes (Apr 21, 2026); Andover Planning Board minutes (Sep 14, 2021). "
"The Sep 15 date is per the Ledger; other outlets say \u201cMonday.\u201d")
# =============== 14. SAMPLE RENDERINGS — INTERIOR ===============
s = page("Sample Renderings: Interior Amenities", 14)
pic(s, "colony_place_clubhouse.jpg", 0.5, 1.15, 6.3, 6.2)
pic(s, "colony_place_fitness.jpg", 6.95, 1.15, 3.55, 3.05)
pic(s, "weymouth_fitness.jpg", 6.95, 4.3, 3.55, 3.05)
tb(s, 0.5, 7.42, 10, 0.25, "Note: Photos of similar Hanover projects (Hanover Colony Place, Plymouth MA; Hanover Weymouth, Weymouth MA); subject project will vary.", 8.5, SLATE, SERIF, italic=True)
# =============== 15. SAMPLE RENDERINGS — EXTERIOR ===============
s = page("Sample Renderings: Exterior Amenities", 15)
pic(s, "colony_place_pool_dusk.jpg", 0.5, 1.15, 10.0, 3.9, 0.55)
pic(s, "weymouth_pool.jpg", 0.5, 5.15, 3.25, 2.2)
pic(s, "colony_place_firepit_courtyard.jpg", 3.875, 5.15, 3.25, 2.2)
pic(s, "stoneham_courtyard_dusk.jpg", 7.25, 5.15, 3.25, 2.2)
tb(s, 0.5, 7.42, 10, 0.25, "Note: Photos of similar Hanover projects (Hanover Colony Place, Hanover Weymouth, Hanover Stoneham); subject project will vary.", 8.5, SLATE, SERIF, italic=True)
# =============== 16. DEMOGRAPHICS ===============
s = page("Demographics", 16)
rows = [["", "ZIP 01810", "Essex County", "Boston MSA"],
["Population", "35,648", "823,938", "5,025,517"], ["Households", "13,298", "314,816", "1,970,240"],
["5-Year Population Growth", "–0.5%", "+5.1%", "+4.0%"], ["Median Household Income", "$180,672", "$109,581", "$124,803"],
["Average Household Income", "$211,050", "$174,660", "$196,182"], ["Renter Median Household Income", "$95,223", "$58,138", "$74,832"],
["Bachelor's Degree or Higher", "96.3%", "57.9%", "68.1%"], ["Renter-Occupied Housing", "20.1%", "37.6%", "39.0%"],
["Median Home Value", "$1,013,410", "$714,736", "$728,727"], ["5-Year Home Value Change", "+38.8%", "+33.1%", "+31.3%"],
["Median Net Worth", "$1M – $2M", "—", "$500K – $750K"]]
table(s, 0.5, 1.2, 6.0, rows, [2.4, 1.2, 1.2, 1.2], rh=0.37, size=9)
head(s, 6.85, 1.15, 3.7, "Household Income — ZIP 01810")
cd = CategoryChartData(); cd.categories = ["< $50K", "$50K–$100K", "$100K–$200K", "> $200K"]; cd.add_series("Share of households", (0.13, 0.14, 0.30, 0.44))
ch = s.shapes.add_chart(XL_CHART_TYPE.COLUMN_CLUSTERED, I(6.85), I(1.45), I(3.65), I(2.9), cd).chart
ch.has_legend = False; pl = ch.plots[0]; pl.gap_width = 60; pl.has_data_labels = True
dl = pl.data_labels; dl.number_format = '0%'; dl.number_format_is_linked = False; dl.font.size = Pt(9); dl.font.name = SANS; dl.position = XL_LABEL_POSITION.OUTSIDE_END
pl.series[0].format.fill.solid(); pl.series[0].format.fill.fore_color.rgb = NAVY
ch.value_axis.visible = False; ch.value_axis.has_major_gridlines = False; ch.value_axis.maximum_scale = 0.55
ch.category_axis.tick_labels.font.size = Pt(8); ch.category_axis.tick_labels.font.name = SANS
rect(s, 6.85, 4.55, 3.65, 2.85, NAVY)
tb(s, 7.05, 4.68, 3.3, 0.3, "Migration Cohort (Aug 2024 – Jul 2026)", 9, GRAY, SANS, True, caps=True)
tb(s, 7.05, 5.05, 3.3, 0.5, "$297,090", 24, WHITE, SERIF); tb(s, 7.05, 5.52, 3.3, 0.3, "Median HHI of households moving in", 8, GRAY, SANS, True, caps=True)
tb(s, 7.05, 5.9, 3.3, 0.5, "$251,631", 24, WHITE, SERIF); tb(s, 7.05, 6.37, 3.3, 0.3, "Median HHI of households moving out", 8, GRAY, SANS, True, caps=True)
tb(s, 7.05, 6.75, 3.3, 0.55, "+$45K in-mover advantage, vs. –$19K for the Boston MSA. In-movers are younger (41 vs. 52).", 9, WHITE, SERIF)
tb(s, 0.5, 5.75, 6.0, 1.6, [[("Takeaway. ", {"bold": True, "color": NAVY}),
"Andover's resident base is among the wealthiest in Greater Boston, and the households arriving out-earn those leaving. "
"Renters are only 20% of households, so new rental supply serves younger, high-earning newcomers who are still building wealth. "
"Population is roughly flat, so the case rests on the quality of new residents rather than how many are arriving."]], 10, TXT, SERIF)
src(s, "Sources: RealAI SuperCensus (Aug 15, 2026), US Census ACS 2024 (population, households, tenure), municipal deed/AVM data (home values, Aug 2026). Income distribution: ACS 2024 5-year "
"via Census Reporter (shares sum to 101% from rounding). Bachelor's share is SuperCensus adults. Migration = tracked households (1,163 in / 1,284 out).", y=7.5, h=0.45)
# =============== 17. TARGET INCOME / AFFORDABILITY ===============
s = page("Demographics", 17, "Target Income | Affordable Program")
rows = [["Unit Type", "Comp In-Place Rent", "Target Income (3.0x Rent)", "vs. 01810 Renter Median", "80% AMI Income Limit", "80% AMI Max Gross Rent", "Affordable Discount"],
["1BR", "$2,546", "$91,639", "–4%", "$80,125", "$2,003", "–21%"],
["2BR", "$3,088", "$111,166", "+17%", "$96,150", "$2,404", "–22%"],
["3BR", "$3,961", "$142,608", "+50%", "$111,075", "$2,777", "–30%"]]
table(s, 0.5, 1.45, 10.0, rows, [1.0, 1.4, 1.6, 1.6, 1.5, 1.5, 1.4], rh=0.42, size=9)
cd = CategoryChartData(); cd.categories = ["1BR target", "2BR target", "3BR target", "01810 renter median", "01810 all-HH median", "In-mover median"]
cd.add_series("Annual household income", (91639, 111166, 142608, 95223, 180672, 297090))
gf = s.shapes.add_chart(XL_CHART_TYPE.BAR_CLUSTERED, I(0.5), I(3.4), I(6.3), I(3.95), cd); ch = gf.chart
ch.has_legend = False; pl = ch.plots[0]; pl.gap_width = 45; pl.has_data_labels = True
pl.data_labels.number_format = '"$"#,##0'; pl.data_labels.number_format_is_linked = False; pl.data_labels.font.size = Pt(8.5); pl.data_labels.font.name = SANS
ser = pl.series[0]; ser.format.fill.solid(); ser.format.fill.fore_color.rgb = NAVY
for idx in (3, 4, 5):
pt = ser.points[idx]; pt.format.fill.solid(); pt.format.fill.fore_color.rgb = ORANGE
ch.value_axis.visible = False; ch.value_axis.has_major_gridlines = False; ch.value_axis.maximum_scale = 340000
ch.category_axis.tick_labels.font.size = Pt(8.5); ch.category_axis.tick_labels.font.name = SANS; ch.category_axis.reverse_order = True
head(s, 7.1, 3.4, 3.4, "What It Means")
bullets(s, 7.1, 3.7, 3.4, 3.7, [
("1BR rents fit the existing renter pool.", "The 1BR target income ($91.6K) is just below the 01810 renter median ($95.2K)."),
("2BR and 3BR units depend on high earners.", "They need $111K–$143K incomes, well below the ZIP's all-household median ($180.7K) and the in-mover median ($297K)."),
("The affordable units price 21–30% below market.", "That gives them a deep waiting-list pool. Rents are capped by HUD's 80% AMI limits, which rose for FY2026.")], size=9.5)
src(s, "Target income = 36x monthly rent (rent at 33% of income), the convention in Hanover's Sugarloaf book. Comp rents: RealAI Rent Index (Sep 26, 2026), six-comp simple average. 80% AMI: HUD FY2026, "
"Lawrence MA-NH HMFA, 1.5 persons/bedroom, 30% of income, gross before utility allowance. Calculations: book_calcs.py.", y=7.5, h=0.45)
# =============== 18. SUPPLY PIPELINE ===============
s = page("Supply Pipeline", 18)
s.shapes.add_picture(A("maps", "pipeline_map.jpg"), I(0.5), I(1.15), I(5.6), I(3.61))
rows = [["#", "Project", "Units", "Status", "Est. Delivery", "Dist."],
["1", "Zero Prescott St (East Mill), N. Andover", "296", "Proposed 40B — ZBA review", "TBD", "~4 mi"],
["2", "946 Osgood St, N. Andover", "108", "Proposed LIP / 40B", "TBD", "~5 mi"],
["3", "Princeton Wilmington, Wilmington", "108", "Lease-up (from Aug 2026)", "2026", "~9 mi"],
["4", "MacLellan Oil site, Tewksbury", "24", "Approved; site clearing", "TBD", "~6 mi"],
["5", "Avalon North Andover", "221", "Delivered", "2022", "~4 mi"],
["6", "505 Sutton, N. Andover", "136", "Delivered", "2022", "~5 mi"]]
table(s, 6.3, 1.15, 4.2, rows, [0.25, 1.55, 0.45, 1.0, 0.55, 0.4], rh=0.5, size=7.5)
head(s, 0.5, 4.95, 10, "Pipeline Takeaways")
bullets(s, 0.5, 5.25, 10.0, 2.2, [
("No competing units under construction within 5 miles.", "The two nearest proposals (East Mill and 946 Osgood) are still in comprehensive-permit or town review, so delivery dates are unknown."),
("Andover itself is barely building.", "The town permitted 9 new homes in 2023 and 21 in 2024. North Andover's 2022 deliveries (357 units) were 95.0% and 86.8% occupied as of September 2026."),
("Zoning is opening up near transit.", "Andover's MBTA Multifamily Overlay (2024) and North Andover's sub-districts could add supply over time. No overlay projects have been confirmed yet.")], size=10)
src(s, "Sources: North Andover ZBA records & Patch (East Mill); Town of North Andover (946 Osgood, search listing); National Development / Princeton Properties; Tewksbury Town Crier; RealAI Datamart; "
"Andover News (housing starts); Boston Foundation 2025 Housing Report Card. Distances are straight-line. Market-rate projects in Methuen, Lawrence and Lowell were not fully surveyed.", y=7.5, h=0.45)
# =============== 19. RENT COMPS SUMMARY ===============
s = page("Rent Comps", 19)
s.shapes.add_picture(A("maps", "rent_comps_map.jpg"), I(0.5), I(1.15), I(4.4), I(2.84))
rows = [["#", "Property", "Units", "Built", "Avg SF", "1BR", "2BR", "3BR", "$/SF", "Occ."],
["S", NAME + " (proposed)", "334", "TBD", "[TBD]", "[TBD]", "[TBD]", "[TBD]", "[TBD]", "—"],
["1", "The Slate at Andover", "225", "2016", "987", "$2,408", "$2,963", "$4,171", "$2.81", "95.6%"],
["2", "Berry Farms", "196", "2016", "960", "$2,515", "$2,992", "$3,736", "$2.98", "93.4%"],
["3", "The Point at Merrimack River*", "248", "2018", "986", "$2,562", "$3,156", "$3,886", "$2.94", "96.8%"],
["4", "Haven North Andover", "192", "2019", "1,070", "$2,512", "$3,176", "—", "$2.71", "95.3%"],
["5", "Avalon North Andover", "221", "2022", "980", "$2,476", "$2,993", "$4,051", "$2.95", "95.0%"],
["6", "505 Sutton", "136", "2022", "899", "$2,800", "$3,248", "—", "$3.26", "86.8%"],
["", "Comp Average", "1,218", "", "", "$2,546", "$3,088", "$3,961", "", "94.3%"],
["", "Andover Submarket", "", "", "", "$2,437", "$2,764", "$3,618", "$2.66", "93.2%"]]
table(s, 5.1, 1.15, 5.4, rows, [0.25, 1.65, 0.45, 0.42, 0.45, 0.48, 0.48, 0.48, 0.4, 0.34], rh=0.31, size=7, shade=(8, 9))
head(s, 0.5, 4.2, 10, "Rent Comp Takeaways")
bullets(s, 0.5, 4.5, 10.0, 2.8, [
("The premium is in larger units.", "Against submarket in-place rents, the comps run +11.7% on 2BRs and +9.5% on 3BRs, but only +4.5% on 1BRs. 1BRs make up 60% of the proposed mix."),
("Rents cluster tightly.", "Average in-place rents fall between $2,719 and $2,855 across all six comps. The unit-weighted average is $2,805, with 94.3% occupancy."),
("Where rents top out.", "505 Sutton (2022) gets the set's highest rent at $3.26/SF but is only 86.8% occupied."),
("Hanover's own comp.", "The Point at Merrimack River, Hanover's 2018 Andover 40B half a mile from the Site, is 96.8% occupied at $2.94/SF.")], size=10)
src(s, "* Developed by Hanover (2018); current ownership to be confirmed. In-place rent = average rent on leased units, RealAI Rent Index as of Sep 26, 2026. Comps 3–6 sit in the Datamart's adjacent "
"\u201cLawrence\u201d submarket; under strict criteria only comps 1–2 qualify. 3BR average n = 4. Rent and $/SF for the Subject to come from Hanover's underwriting.", y=7.45, h=0.5)
# =============== 20–22. RENT COMP PROFILES ===============
def profile(s, x, title, addr, img, data, comments, credit):
tb(s, x, 1.15, 4.9, 0.32, title, 15, NAVY, SERIF, sp=0.5)
tb(s, x, 1.45, 4.9, 0.25, addr, 9, SLATE, SANS)
if img:
pic(s, img, x, 1.75, 4.9, 2.55)
tb(s, x, 4.32, 4.9, 0.2, credit, 6.5, GRAY, SANS)
else:
rect(s, x, 1.75, 4.9, 2.55, LGRAY)
tb(s, x, 2.85, 4.9, 0.4, "[Property photo — to be added]", 11, ORANGE, SERIF, align=PP_ALIGN.CENTER, italic=True)
table(s, x, 4.55, 4.9, data, [1.15, 0.85, 0.95, 0.95, 1.0], rh=0.27, size=8)
tb(s, x, 6.25, 4.9, 1.2, comments, 9, TXT, SERIF)
def comp_rows(units, yb, sf, rt, occ, b1, b2, b3, psf):
return [["Building Data", "", "Bedroom", "In-Place", "$/SF"],
["Units", units, "1BR", b1[0], b1[1]], ["Year Built", yb, "2BR", b2[0], b2[1]], ["Avg SF", sf, "3BR", b3[0], b3[1]],
["Rent Type", rt, "Total", "", psf], ["Occupancy", occ, "", "", ""]]
s = page("Rent Comps", 20)
profile(s, 0.5, "The Point at Merrimack River", "30–40 Shattuck Rd, Andover, MA 01810 | Developed by Hanover (2018)", "the_point_exterior_housingnavigator.jpg",
comp_rows("248", "2018", "986", "Mkt & Aff.", "96.8%", ("$2,562", "$3.12"), ("$3,156", "$2.64"), ("$3,886", "$2.66"), "$2.94"),
"Chapter 40B community 0.5 mi from the Site. Amenities include a 6,000 SF clubhouse, resort-style pool, 24-hour fitness, theater room and "
"garage parking ($250/mo). October 2026 listings show 1BR asking rents from $2,593 and 2BR from $3,277 to $3,552.",
"Photo: property listing via Housing Navigator MA")
profile(s, 5.6, "Haven North Andover", "1252 Osgood St, North Andover, MA 01845", "haven_north_andover_exterior.jpg",
comp_rows("192", "2019", "1,070", "Market", "95.3%", ("$2,512", "$2.90"), ("$3,176", "$2.51"), ("—", "—"), "$2.71"),
"Market-rate community on the Route 125 corridor. It has the set's largest average unit (1,070 SF) and the highest 2BR in-place rent "
"outside 505 Sutton. No 3BR units are tracked.", "Photo: havennorthandover.com")
src(s, "Source: RealAI Rent Index (Sep 26, 2026); The Point amenities and asking rents per Housing Navigator / Zillow listings (Oct 2026).")
s = page("Rent Comps", 21)
profile(s, 0.5, "505 Sutton", "495 Sutton St, North Andover, MA 01845", "505_sutton_entrance.jpg",
comp_rows("136", "2022", "899", "Market", "86.8%", ("$2,800", "$3.09"), ("$3,248", "$2.94"), ("—", "—"), "$3.26"),
"MINCO's 2022 market-rate delivery beside North Andover's new senior center. It has the highest rent per SF in the set, but occupancy of 86.8% shows "
"where pricing tops out for smaller units.", "Photo: 505sutton.com")
profile(s, 5.6, "Berry Farms", "4 Berry St, North Andover, MA 01845", "berry_farms_exterior.jpg",
comp_rows("196", "2016", "960", "Market", "93.4%", ("$2,515", "$3.18"), ("$2,992", "$2.80"), ("$3,736", "$3.05"), "$2.98"),
"Market-rate garden community in the Andover submarket. Its 1BR in-place rent of $3.18/SF is the highest of the strict two-property set.",
"Photo: berryfarmsapartments.com")
src(s, "Source: RealAI Rent Index (Sep 26, 2026); Town of North Andover (505 Sutton context).")
s = page("Rent Comps", 22)
profile(s, 0.5, "Avalon North Andover", "88 High St, North Andover, MA 01845", None,
comp_rows("221", "2022", "980", "Market", "95.0%", ("$2,476", "$3.17"), ("$2,993", "$2.58"), ("$4,051", "$2.72"), "$2.95"),
"AvalonBay's 2023 Mill District delivery next to downtown North Andover. It is 95.0% occupied, with studios at $2,123 in-place.", "")
profile(s, 5.6, "The Slate at Andover", "50 Woodview Way, Andover, MA 01810", None,
comp_rows("225", "2016", "987", "Mkt & Aff.", "95.6%", ("$2,408", "$2.94"), ("$2,963", "$2.58"), ("$4,171", "$2.76"), "$2.81"),
"Mixed-income community in ZIP 01810. It has the comp set's highest 3BR in-place rent ($4,171), supporting family-sized units in Andover.", "")
src(s, "Source: RealAI Rent Index (Sep 26, 2026). Photos for these two properties could not be downloaded (site access blocked) and should be added manually.")
# =============== 23. LEASING MOMENTUM ===============
s = page("Leasing Momentum", 23, "ZIP 01810 Multifamily")
cd = CategoryChartData()
cd.categories = ["Sep-25", "Oct-25", "Nov-25", "Dec-25", "Jan-26", "Feb-26", "Mar-26", "Apr-26", "May-26", "Jun-26", "Jul-26", "Aug-26"]
cd.add_series("Median asking rent", (2732, 2750, 2620, 2590, 2565, 2628, 2640.5, 2640, 2800, 2824, 2700, 2621))
cd.add_series("Median in-place rent", (2550, 2545, 2544, 2540, 2540.5, 2541, 2545, 2530, 2541, 2545, 2550, 2559))
ch = s.shapes.add_chart(XL_CHART_TYPE.LINE_MARKERS, I(0.5), I(1.5), I(6.4), I(4.6), cd).chart
ch.has_legend = True; ch.legend.position = XL_LEGEND_POSITION.BOTTOM; ch.legend.include_in_layout = False; ch.legend.font.size = Pt(9); ch.legend.font.name = SANS
va = ch.value_axis; va.minimum_scale, va.maximum_scale, va.major_unit = 2400, 2900, 100
va.tick_labels.font.size = Pt(8); va.tick_labels.number_format = '"$"#,##0'; va.tick_labels.number_format_is_linked = False
va.major_gridlines.format.line.color.rgb = LGRAY; ch.category_axis.tick_labels.font.size = Pt(8)
for ser, col in zip(ch.plots[0].series, [NAVY, ORANGE]):
ser.format.line.color.rgb = col; ser.format.line.width = Pt(2.25); ser.smooth = False
ser.marker.format.fill.solid(); ser.marker.format.fill.fore_color.rgb = col; ser.marker.format.line.color.rgb = col
kp = [("$2,820", "Median asking rent", "+0.8% YoY; 10.2% above $2,559 in-place"), ("96.7%", "Occupancy", "97.1% a year ago"),
("+3.6%", "New-lease tradeout", "Mar–Aug 2026, 131 leases; latest 30 days +5.85%"), ("59 days", "Median days on market", "Leases signed past 30 days (36)")]
for i, (v, l, d) in enumerate(kp):
y = 1.5 + i * 1.18
rect(s, 7.2, y, 3.3, 1.05, NAVY if i % 2 == 0 else SLATE)
tb(s, 7.35, y + 0.08, 3.0, 0.45, v, 22, WHITE, SERIF)
tb(s, 7.35, y + 0.5, 3.0, 0.25, l, 8.5, WHITE, SANS, True, caps=True)
tb(s, 7.35, y + 0.72, 3.0, 0.3, d, 8, GRAY, SANS)
tb(s, 0.5, 6.3, 10, 1.0, [[("Read-through. ", {"bold": True, "color": NAVY}),
"Occupancy is tight and new-lease pricing has turned up, from –0.2% over Sep 2025–Feb 2026 to +3.6% over Mar–Aug 2026. Asking rents are seasonal, "
"bottoming in January ($2,565) and peaking in June ($2,824). A two-month median days on market says renters are paying the 10% asking premium "
"gradually rather than competing for units."]], 10, TXT, SERIF)
src(s, "Source: RealAI Rent Index (snapshot Sep 26, 2026; monthly series through Aug 2026), ~1,213 tracked units in ZIP 01810. Tradeout is lease-weighted; monthly samples are small (10–28 leases).")
# =============== 24. SALE COMPS ===============
s = page("Asset Sale Comps", 24)
s.shapes.add_picture(A("maps", "sale_comps_map.jpg"), I(0.5), I(1.15), I(4.2), I(2.71))
rows = [["#", "Property", "Built", "Units", "Sale Date", "Price", "Per Unit", "Cap Rate", "Buyer"],
["S", NAME, "TBD", "334", "—", "[TBD]", "[TBD]", "[TBD]", "(Project cost)"],
["1", "Residences at Crosspoint, Lowell", "2020", "240", "Nov-24", "$85.1M", "$354,479", "5.0%", "n/a"],
["2", "Villas at Old Concord, Billerica", "2004", "324", "Sep-24", "$114.5M", "$353,395", "n/a", "TruAmerica"],
["3", "Point at Merrimack Valley, Methuen", "2021", "156", "Mar-24", "$58.1M", "$372,436", "n/a", "n/a"],
["4", "Woods at Merrimack, Methuen", "2019", "140", "Nov-25", "$37.5M", "$268,121", "n/a", "n/a"],
["5", "Legacy Park, Lawrence", "2009", "104", "Jun-26", "$30.2M", "$290,385", "n/a", "n/a"],
["6", "The Point at Woburn, Woburn", "2022", "289", "Jan-26", "$130.7M", "$452,166", "n/a", "Pantzer"]]
table(s, 4.9, 1.15, 5.6, rows, [0.22, 1.7, 0.4, 0.4, 0.5, 0.58, 0.65, 0.5, 0.65], rh=0.34, size=7)
head(s, 0.5, 4.1, 10, "Sale Comp Takeaways")
bullets(s, 0.5, 4.4, 10.0, 2.9, [
("Newer product trades at $354K–$452K per unit.", "The three 2020+ vintages average $393K/unit, led by The Point at Woburn ($452K, Jan 2026)."),
("Merrimack Valley trades are cheaper.", "Methuen and Lawrence sales of $268K–$372K/unit reflect older or less affluent locations than Andover."),
("Few cap rates are disclosed.", "Only Crosspoint reports one (5.0%, CoStar). Wronka puts the Boston market cap rate near 5.1% (Aug 2025). Exit cap and project cost per unit [TBD] from Hanover's model.")], size=10)
src(s, "Sources: RealAI Datamart (deed-recorded latest sale); YieldPro (Villas at Old Concord); MarketScreener (Point at Woburn buyer); Wronka Boston Multifamily Capital Market Report (Aug 2025). "
"Woods at Merrimack $/unit appears low and should be verified. n/a = not reported.", y=7.45, h=0.5)
# =============== 25. SPONSOR TRACK RECORD ===============
s = page("Sponsor Track Record", 25)
pic(s, "the_point_exterior_redfin.jpg", 0.5, 1.15, 3.6, 2.45)
tb(s, 0.5, 3.62, 3.6, 0.2, "The Point at Merrimack River. Photo: listing via Redfin (confirm rights)", 6.5, GRAY, SANS)
head(s, 4.35, 1.15, 6, "Hanover in Andover — The Point at Merrimack River")
bullets(s, 4.35, 1.45, 6.15, 2.3, [
"248-unit Chapter 40B community at 30–40 Shattuck Rd, developed by Hanover in 2018, about half a mile from the Site.",
"96.8% occupied at $2.94/SF average in-place rent (Sep 2026), the highest occupancy in the six-property comp set.",
"Gives Hanover direct operating history with Andover's permitting boards and renter base through the same 40B structure now proposed.",
"Current ownership and management to be confirmed: the fee owner of record is Andover Apartments LLC, and Pantzer manages on site."], size=10)
head(s, 0.5, 4.0, 10, "Hanover Atlanta — Performance vs. Submarket (Sep 2026)")
rows = [["Metric", "Hanover Edgewood", "Submarket¹", "Premium", "Hanover Midtown", "Midtown Submarket", "Premium"],
["Units / Year Built", "422 / 2022", "—", "—", "421 / 2023", "—", "—"],
["Average In-Place Rent", "$1,990", "$1,744", "+14.1%", "$2,740", "$2,496", "+9.8%"],
["2BR In-Place Rent", "$2,339", "$1,858", "+25.9%", "$3,396", "$3,022", "+12.4%"],
["Average $/SF", "$2.20", "$1.97", "+11.7%", "$2.86", "$2.81", "+1.8%"],
["Median Days on Market²", "35", "63", "28 days faster", "68", "82", "14 days faster"],
["Occupancy", "89.8%", "94.0%", "–4.1 pts", "80.5%", "85.3%", "–4.8 pts"]]
table(s, 0.5, 4.3, 10.0, rows, [2.0, 1.4, 1.3, 1.25, 1.4, 1.4, 1.25], rh=0.36, size=8.5, shade=(5,))
src(s, "¹ Datamart-assigned submarket (Chandler-McAfee/West Belvedere Park). Against its own ZIP (30307), Edgewood rents 2.6% below average. ² Leases signed in the past 30 days (Edgewood 23; Midtown 46). "
"Both properties trail submarket occupancy. Source: RealAI Rent Index (Sep 26, 2026); Eagle-Tribune; Andover Ledger; Town of Andover.", y=7.45, h=0.5)
# =============== 26. KEY CONTACTS ===============
s = page("Key Contacts", 26)
cts = [("Brandt Bowden", "Chief Executive Officer", "713 580 1203", "bbowden@hanoverco.com"),
("Theresa Blades", "Chief Investment Officer", "713 580 1150", "tblades@hanoverco.com"),
("Emily Coty", "Capital Markets Managing Director", "713 580 1027", "ecoty@hanoverco.com"),
("Drew Hunnicutt", "Capital Markets Director", "713 580 1376", "dhunnicutt@hanoverco.com"),
("David Hall", "Regional Development Partner", "[Phone — TBD]", "[Email — TBD]"),
("Steve Dazzo", "[Title — TBD]", "[Phone — TBD]", "[Email — TBD]"),
("Ethan Kao", "Capital Markets Analyst", "713 580 1358", "ekao@hanoverco.com")]
for i, (n, t, p, e) in enumerate(cts):
x = 0.5 + (i % 4) * 2.55; y = 1.4 + (i // 4) * 2.2
tb(s, x, y, 2.4, 0.35, n, 15, NAVY, SERIF)
tb(s, x, y + 0.38, 2.4, 0.5, t, 9, SLATE, SANS, True, caps=True)
tb(s, x, y + 0.9, 2.4, 0.25, "Direct: " + p, 9, ORANGE if "TBD" in p else TXT, SERIF)
tb(s, x, y + 1.15, 2.4, 0.25, "Email: " + e, 9, ORANGE if "TBD" in e else TXT, SERIF)
hline(s, 0.5, 6.05, 10, GRAY, 0.5)
tb(s, 0.5, 6.15, 10, 0.3, "LEGAL NOTICE", 9, NAVY, SERIF, True, align=PP_ALIGN.CENTER, sp=2)
tb(s, 0.5, 6.45, 10, 0.9, "The information contained in this brochure is for informational purposes only and all market analysis and projections are merely examples. "
"The examples are not intended to represent or guarantee that the results will be achieved. You understand that The Hanover Company is not warranting the accuracy, "
"completeness, merchantability or fitness of any information contained in this brochure. You recognize any business endeavor has inherent risk for loss of capital "
"and that there are no guarantees on your investment.", 8.5, TXT, SERIF, align=PP_ALIGN.CENTER)
tb(s, 0.5, 7.4, 10, 0.25, "©2026 Hanover Company", 8.5, GRAY, SERIF, align=PP_ALIGN.CENTER)
src(s, "Contacts carried over from the Hanover Sugarloaf book (Apr 2025); confirm the Minuteman Park deal team. David Hall and Steve Dazzo are named in Andover press coverage.", y=7.7)
out = os.path.join(ROOT, "Hanover_Minuteman_Park_Investment_Book.pptx")
prs.save(out); print("saved", out, len(prs.slides), "slides")
cd hanover/minuteman && python3 scripts/build_book.py 2>&1 | tail -5 && mkdir -p qa && libreoffice --headless --convert-to pdf Hanover_Minuteman_Park_Investment_Book.pptx --outdir qa >/dev/null 2>&1; ls -la qa; rm -f qa/p-*.png; pdftoppm -r 45 -png qa/Hanover_Minuteman_Park_Investment_Book.pdf qa/p && montage qa/p-0[1-9].png qa/p-1[0-2].png -tile 3x4 -geometry +3+3 qa/m1.png && montage qa/p-1[3-9].png qa/p-2*.png -tile 4x4 -geometry +3+3 qa/m2.png
cd hanover/minuteman/scripts && python3 - <<'EOF'
s=open("build_book.py").read()
s=s.replace('ch.has_legend = False; pl = ch.plots[0]; pl.gap_width = 60','ch.has_legend = False; ch.has_title = False; pl = ch.plots[0]; pl.gap_width = 60')
s=s.replace('ch.has_legend = False; pl = ch.plots[0]; pl.gap_width = 45','ch.has_legend = False; ch.has_title = False; pl = ch.plots[0]; pl.gap_width = 45')
s=s.replace('ch.has_legend = True; ch.legend.position','ch.has_title = False; ch.has_legend = True; ch.legend.position')
s=s.replace('tb(s, 0.45, 7.45, 4, 0.55, "HANOVER", 30','tb(s, 0.45, 7.2, 4, 0.55, "HANOVER", 30')
s=s.replace('s.shapes.add_picture(A("maps", "rent_comps_map.jpg"), I(0.5), I(1.15), I(4.4), I(2.84))','s.shapes.add_picture(A("maps", "rent_comps_map.jpg"), I(0.5), I(1.15), I(3.7), I(2.39))')
s=s.replace('table(s, 5.1, 1.15, 5.4, rows, [0.25, 1.65, 0.45, 0.42, 0.45, 0.48, 0.48, 0.48, 0.4, 0.34], rh=0.31, size=7, shade=(8, 9))',
'table(s, 4.35, 1.15, 6.15, rows, [0.25, 1.85, 0.45, 0.45, 0.5, 0.55, 0.55, 0.55, 0.5, 0.5], rh=0.3, size=7, shade=(8, 9))')
open("build_book.py","w").write(s)
EOF
cd .. && python3 scripts/build_book.py | tail -1 && libreoffice --headless --convert-to pdf Hanover_Minuteman_Park_Investment_Book.pptx --outdir qa >/dev/null 2>&1; rm -f qa/h-*.png; pdftoppm -r 80 -png -f 1 -l 1 qa/Hanover_Minuteman_Park_Investment_Book.pdf qa/h; pdftoppm -r 80 -png -f 19 -l 19 qa/Hanover_Minuteman_Park_Investment_Book.pdf qa/h; montage qa/h-*.png -tile 2x1 -geometry +3+3 qa/hm.pngcd hanover/minuteman/scripts && python3 - <<'EOF'
s=open("build_book.py").read()
s=s.replace('''tb(s, 0.45, 7.2, 4, 0.55, "HANOVER", 30''','''band = rect(s, 0, 6.95, 11, 1.55, NAVY)
sf = band.fill._xPr.find(qn("a:solidFill")); clr = sf[0]; a = clr.makeelement(qn("a:alpha"), {"val": "55000"}); clr.append(a)
tb(s, 0.45, 7.2, 4, 0.55, "HANOVER", 30''')
open("build_book.py","w").write(s)
EOF
cd .. && python3 scripts/build_book.py | tail -1 && libreoffice --headless --convert-to pdf Hanover_Minuteman_Park_Investment_Book.pptx --outdir qa >/dev/null 2>&1; pdftoppm -r 50 -png -f 1 -l 1 qa/Hanover_Minuteman_Park_Investment_Book.pdf qa/c; ls qa; du -sh --exclude=./scratch --exclude=./skills --exclude=./realai-assets /vercel/sandbox 2>/dev/null | tail -1cd hanover/minuteman && rm -rf assets/tilecache qa/*.png assets/maps/qa_* && cp qa/Hanover_Minuteman_Park_Investment_Book.pdf . && du -sh /vercel/sandbox/hanover /vercel/sandbox/subagents /vercel/sandbox/tool-outputs 2>/dev/null
The 26-page Hanover Minuteman Park investment book is built in the Sugarloaf format, as an editable PowerPoint plus a PDF export. It follows the Sugarloaf structure: navy cover band, serif headings, and the same footer, page order and photo conventions. The financial, cost and operating pages are placeholders, with every input marked [TBD] in orange .
The deck presents the program you confirmed: 250 market-rate units plus 84 affordable units at 80% of area median income under the Local Initiative Program.
Visuals I created or sourced
What the area research adds
Gaps to close before it goes out
A few questions:
Files are in the library:
This is a read-only snapshot of a RealAI analysis.