#!/usr/bin/env python3 """Generates an editable Word (.docx) version of the EV charging case study. Same content/numbers as the published HTML case study, re-presented on a white page with Terrapin brand accents (yellow, logo, Archivo) instead of the web version's dark full-bleed theme — a dark background is impractical in an editable/printable Word document. Run: python3 generate_case_study_docx.py """ import os from docx import Document from docx.shared import Pt, Inches, RGBColor from docx.enum.text import WD_ALIGN_PARAGRAPH from docx.enum.table import WD_ALIGN_VERTICAL from docx.oxml.ns import qn from docx.oxml import OxmlElement BASE = os.path.dirname(os.path.abspath(__file__)) LOGO_PATH = os.path.join(BASE, "Pikes Peak Terrapin Template/assets/terrapin-logo.png") OUT_PATH = os.path.join( BASE, "Pikes Peak Terrapin Template/Reports/EV Charging Case Study - The Plaza at Pikes Peak.docx") FONT = "Archivo" ACCENT = RGBColor(0xB8, 0x9A, 0x00) # darkened yellow — readable as text on white ACCENT_BG = "F5F7C9" # pale yellow tint for shaded cells (hex, no #) INK = RGBColor(0x1B, 0x1B, 0x1B) INK_SOFT = RGBColor(0x66, 0x66, 0x66) INK_FAINT = RGBColor(0x8F, 0x8F, 0x8F) CAUTION = RGBColor(0xB2, 0x3C, 0x33) LINE_GRAY = "D9D9D9" def shade_cell(cell, hex_color): shd = OxmlElement("w:shd") shd.set(qn("w:val"), "clear") shd.set(qn("w:color"), "auto") shd.set(qn("w:fill"), hex_color) cell._tc.get_or_add_tcPr().append(shd) def set_cell_borders(cell, color=LINE_GRAY, sz=4, edges=("top", "bottom", "left", "right")): tcPr = cell._tc.get_or_add_tcPr() borders = OxmlElement("w:tcBorders") for edge in edges: el = OxmlElement(f"w:{edge}") el.set(qn("w:val"), "single") el.set(qn("w:sz"), str(sz)) el.set(qn("w:color"), color) borders.append(el) tcPr.append(borders) def set_row_height(row, pts): row.height = Pt(pts) def add_run(paragraph, text, size=11, bold=False, italic=False, color=INK, font=FONT): run = paragraph.add_run(text) run.font.name = font run.font.size = Pt(size) run.font.bold = bold run.font.italic = italic run.font.color.rgb = color return run def add_para(doc, text="", size=11, bold=False, italic=False, color=INK, space_before=0, space_after=8, align=None): p = doc.add_paragraph() p.paragraph_format.space_before = Pt(space_before) p.paragraph_format.space_after = Pt(space_after) if align: p.alignment = align if text: add_run(p, text, size=size, bold=bold, italic=italic, color=color) return p def add_rule(doc, color=LINE_GRAY): p = doc.add_paragraph() p.paragraph_format.space_before = Pt(4) p.paragraph_format.space_after = Pt(4) pPr = p._p.get_or_add_pPr() border = OxmlElement("w:pBdr") bottom = OxmlElement("w:bottom") bottom.set(qn("w:val"), "single") bottom.set(qn("w:sz"), "6") bottom.set(qn("w:space"), "1") bottom.set(qn("w:color"), color) border.append(bottom) pPr.append(border) return p def add_evidence_table(doc, rows): table = doc.add_table(rows=len(rows), cols=2) table.autofit = True for i, (label, value) in enumerate(rows): lcell, vcell = table.rows[i].cells lp = lcell.paragraphs[0] add_run(lp, label, size=10, color=INK_SOFT) vp = vcell.paragraphs[0] vp.alignment = WD_ALIGN_PARAGRAPH.RIGHT vcolor = CAUTION if value.strip().startswith("−") else INK add_run(vp, value, size=10, bold=True, color=vcolor) set_cell_borders(lcell, edges=("bottom",)) set_cell_borders(vcell, edges=("bottom",)) for cell in (lcell, vcell): cell.vertical_alignment = WD_ALIGN_VERTICAL.CENTER cell.paragraphs[0].paragraph_format.space_before = Pt(4) cell.paragraphs[0].paragraph_format.space_after = Pt(4) return table def add_why_box(doc, audience, text): table = doc.add_table(rows=1, cols=1) cell = table.rows[0].cells[0] shade_cell(cell, ACCENT_BG) set_cell_borders(cell, color="E5DC80", sz=4) cell.paragraphs[0].paragraph_format.space_before = Pt(10) label_p = cell.paragraphs[0] add_run(label_p, f"WHY IT MATTERS TO {audience.upper()}", size=8.5, bold=True, color=ACCENT) body_p = cell.add_paragraph() body_p.paragraph_format.space_after = Pt(10) add_run(body_p, text, size=10, color=RGBColor(0x33, 0x33, 0x33)) return table def add_stat_band(doc, stats): table = doc.add_table(rows=1, cols=len(stats)) table.autofit = True for cell, (label, figure, sub) in zip(table.rows[0].cells, stats): shade_cell(cell, "F2F2F2") set_cell_borders(cell, sz=4) cell.vertical_alignment = WD_ALIGN_VERTICAL.TOP lp = cell.paragraphs[0] lp.paragraph_format.space_before = Pt(6) add_run(lp, label, size=8, bold=True, color=ACCENT) fp = cell.add_paragraph() add_run(fp, figure, size=17, bold=True, color=INK) sp = cell.add_paragraph() sp.paragraph_format.space_after = Pt(6) add_run(sp, sub, size=8, color=INK_SOFT) return table CAUTION_BG = "FBE9E7" # pale red tint for the "before" column def add_compare_panel(doc, before, after): """before/after = (label, figure, sub, [bullet lines])""" table = doc.add_table(rows=1, cols=2) table.autofit = True specs = [(before, CAUTION_BG, CAUTION), (after, ACCENT_BG, ACCENT)] for cell, ((label, figure, sub, bullets), bg, fg) in zip(table.rows[0].cells, specs): shade_cell(cell, bg) set_cell_borders(cell, color="E0C9C6" if bg == CAUTION_BG else "E5DC80", sz=4) cell.vertical_alignment = WD_ALIGN_VERTICAL.TOP lp = cell.paragraphs[0] lp.paragraph_format.space_before = Pt(8) add_run(lp, label, size=8.5, bold=True, color=fg) fp = cell.add_paragraph() add_run(fp, figure, size=18, bold=True, color=fg) sp = cell.add_paragraph() sp.paragraph_format.space_after = Pt(6) add_run(sp, sub, size=8.5, color=INK_SOFT) for bullet in bullets: bp = cell.add_paragraph() bp.paragraph_format.space_after = Pt(4) add_run(bp, "• ", size=9.5, color=RGBColor(0x33, 0x33, 0x33)) add_run(bp, bullet, size=9.5, color=RGBColor(0x33, 0x33, 0x33)) cell.paragraphs[-1].paragraph_format.space_after = Pt(8) return table def add_pillar(doc, num, audience, figure, title, body_paragraphs, evidence_rows, why_text, trailing_paragraph=None): add_para(doc, f"{num} · {audience.upper()}", size=9, bold=True, color=INK_FAINT, space_after=2) h = doc.add_paragraph() h.paragraph_format.space_after = Pt(4) add_run(h, title, size=16, bold=True, color=INK) fp = doc.add_paragraph() fp.paragraph_format.space_after = Pt(10) add_run(fp, figure, size=22, bold=True, color=ACCENT) for para in body_paragraphs: add_para(doc, para, size=10.5, color=RGBColor(0x33, 0x33, 0x33), space_after=8) add_evidence_table(doc, evidence_rows) if trailing_paragraph: add_para(doc, trailing_paragraph, size=10.5, color=RGBColor(0x33, 0x33, 0x33), space_before=10, space_after=4) add_why_box(doc, audience, why_text) add_para(doc, "", space_after=4) def main(): doc = Document() section = doc.sections[0] section.left_margin = section.right_margin = Inches(0.9) section.top_margin = section.bottom_margin = Inches(0.7) style = doc.styles["Normal"] style.font.name = FONT style.font.size = Pt(11) style.font.color.rgb = INK # ---- Cover ---- if os.path.exists(LOGO_PATH): logo_p = doc.add_paragraph() logo_p.alignment = WD_ALIGN_PARAGRAPH.RIGHT logo_run = logo_p.add_run() logo_run.add_picture(LOGO_PATH, width=Inches(1.7)) pill = doc.add_paragraph() pill.paragraph_format.space_before = Pt(4) add_run(pill, "CASE STUDY", size=9, bold=True, color=ACCENT) title = doc.add_paragraph() title.paragraph_format.space_before = Pt(2) title.paragraph_format.space_after = Pt(10) add_run(title, "One EV Charging Program, Four Ways It Pays Off", size=26, bold=True, color=INK) prop = doc.add_paragraph() prop.paragraph_format.space_after = Pt(2) add_run(prop, "The Plaza at Pikes Peak", size=11, bold=True, color=INK) add_run(prop, " · 702 East Pikes Peak Avenue", size=11, color=INK_SOFT) prop2 = doc.add_paragraph() prop2.paragraph_format.space_after = Pt(14) add_run(prop2, "4 CMS-managed ports (DQ-424, DQ-433, DQ-434, DQ-435) · Data measured Jun 8 – Aug 17, 2026", size=10, color=INK_SOFT) add_rule(doc) thesis = doc.add_paragraph() thesis.paragraph_format.space_before = Pt(12) thesis.paragraph_format.space_after = Pt(20) add_run(thesis, "A property owner installed four EV chargers expecting a tenant amenity and a " "line-item cost. Six weeks of measured 15-minute utility and session data instead " "show a program that pays for itself, trims the building's own demand charge, gets " "drivers to voluntarily shift off-peak, and gives the utility a real, quantified " "peak-shaving story, all from one small, price-signaled charging deployment.", size=11.5, color=RGBColor(0x33, 0x33, 0x33)) add_compare_panel( doc, before=( "BEFORE · JUN 8–21 (FREE TO DRIVERS)", "–$202", "14 days, entirely a cost center", [ "82 sessions, $0 ever charged to drivers", "$202 electricity cost, 100% absorbed by the property, $0 recovered", "No revenue, no offset: a pure line-item expense", ], ), after=( "AFTER · JULY (FIRST FULL MONTH PRICED)", "+$491", "31 days, self-funding and then some", [ "83 sessions, $789 revenue collected from drivers", "Electricity bill ($251) and fees ($48) fully covered by that revenue", "Pikes Peak paid $98 as a parking-spot licensing fee for hosting the chargers", ], ), ) doc.add_paragraph().paragraph_format.space_after = Pt(4) add_stat_band(doc, [ ("OWNER · REVENUE", "$603", "net revenue since tenant pricing began (Jun 22 – Jul 31), electricity covered, plus a licensing fee"), ("OWNER · BILL IMPACT", "2.3%+", "guaranteed savings from shifting on-peak charging off-peak, 64 days, up to 39% best case"), ("DRIVERS · BEHAVIOR", "73%→55%", "share of weekday sessions in the peak-price window, before vs. after pricing"), ("UTILITY · PEAK LOAD", "0%", "of 15-min intervals had all 4 ports charging at once, across 71 days measured"), ]) cap = doc.add_paragraph() cap.paragraph_format.space_before = Pt(8) cap.paragraph_format.space_after = Pt(0) add_run(cap, "All figures above are measured from real 15-minute CSU meter readings and " "CMS session records, not projections.", size=9, italic=True, color=INK_FAINT) doc.add_paragraph().paragraph_format.space_after = Pt(4) add_stat_band(doc, [ ("PROJECTED ANNUAL REVENUE", "$5,502", "extrapolated from the measured Jun 22 – Jul 31 net-revenue run rate over a full year, not a guarantee"), ("PROJECTED ANNUAL BILL SAVINGS", "$103–$1,745", "the same off-peak-shift scenario (guaranteed to best case), annualized from the 64-day measured window"), ]) proj_cap = doc.add_paragraph() proj_cap.paragraph_format.space_before = Pt(8) proj_cap.paragraph_format.space_after = Pt(0) add_run(proj_cap, "The two figures above are projections, extrapolated from measured data to a " "full year; every other figure in this report is measured, not projected.", size=9, italic=True, color=INK_FAINT) # ---- Page 2: owner pillars ---- doc.add_page_break() add_pillar( doc, "01", "Owner", "$603", "From a pure cost to a covered bill, plus a licensing fee", [ "Before June 22, every charging session at the property was free, a tenant amenity " "with no revenue, and the property absorbed the full electricity cost out of pocket. " "Once charger pricing went live ($0.35/kWh weekday 11am–8pm, $0.25/kWh all other " "times), that flipped: the program now pays its own electricity bill in full, and " "Pikes Peak collects a licensing fee on top for hosting the chargers, not a shared " "“profit,” but a fee for the parking spots, the same way a property earns rent " "from any other on-site amenity.", ], [ ("Before pricing (Jun 8–21, free to drivers)", "82 sessions · $0 revenue · $202 electricity cost, 100% on the property"), ("July (first full month under pricing)", "83 sessions · 2,918 kWh · $789 revenue · $251 electricity covered · $491 net"), ("Pikes Peak's licensing fee (July)", "$98: a fee for hosting the chargers, not a profit split"), ("TEC's share (July)", "$392: covers TEC's own operating costs and margin"), ("Projected annual net revenue", "$5,502, extrapolated from the measured run rate"), ], "The electricity cost isn't estimated after the fact; it's calculated four different " "documented ways from the same measured data (bundled rate, time-of-use energy, and two " "demand-charge allocation methods), so the number an owner sees is auditable, not a " "vendor's promise. And the property's cut is framed as a licensing fee for hosting the " "chargers, a fee for the parking spots, not a stake in the charging business itself.", ) add_pillar( doc, "02", "Owner", "2.3%–39%", "Shifting charging off-peak lowers the building's own bill", [ "CSU bills on-peak energy at nearly double the off-peak rate ($0.0459/kWh vs. " "$0.0254/kWh), and demand charges are assessed separately for each window. Simulating " "100% of on-peak charging moved to off-peak hours, over the same 64-day metered window, " "shows a guaranteed floor and a realistic upside, as a share of the chargers' own " "electricity cost:", ], [ ("Guaranteed (energy rate only, no demand-peak risk)", "+2.3% saved"), ("Best case (off-peak shift doesn't raise a new peak)", "+39.1% saved"), ("Worst case (shift creates a new off-peak demand peak)", "−51.4%"), ("Projected annual savings (guaranteed to best case)", "$103–$1,745"), ], "There's real headroom to move charging off-peak without the building fighting its own " "peak; the risk in the “worst case” number is manageable with basic scheduling, not a " "coin flip.", trailing_paragraph=( "The building's own demand rarely peaks because of the chargers: at the exact moment " "the property's on-peak demand hit its billed high (47.9 kW), chargers were only " "drawing 12.6 kW of it; chargers run during just 35% of on-peak intervals to begin with." ), ) # ---- Page 3: driver + utility pillars ---- doc.add_page_break() add_pillar( doc, "03", "Drivers", "18pt shift", "Drivers actually respond to the price signal, measured not assumed", [ "A common objection to tenant EV pricing is that drivers won't change behavior. The " "pre/post-pricing comparison at this property says otherwise: once a $0.35 peak / " "$0.25 off-peak split went live, the share of weekday charging that happened inside " "the expensive window dropped substantially.", ], [ ("Weekday sessions in the $0.35 peak window (before pricing)", "73%"), ("Weekday sessions in the $0.35 peak window (after pricing)", "55%"), ("8pm–10pm off-peak ($0.25) weekday sessions", "11% → 24%"), ], "Deferring a charge by a few hours is a real, achievable discount, not a hypothetical " "one drivers have to take on faith. The data shows a meaningful share of drivers already " "choosing the cheaper window on their own.", ) add_pillar( doc, "04", "Utility", "1.7%", "A quantified peak-shaving story a utility can act on", [ "Utilities considering managed-charging or demand-response partnerships need evidence " "that a site's EV load isn't a coincident peak risk. Across the full 71-day session " "record, simultaneous port usage at this property was measured directly, interval by " "interval:", ], [ ("Time all 4 ports were simultaneously plugged in", "1.7% of elapsed time (28.5 of 1,674 hours)"), ("Intervals where all 4 were simultaneously charging", "0 of 6,697 measured"), ("Avg. ports actively drawing power during rare all-4-occupied windows", "1.4 of 4"), ], "This is the evidence base a utility needs before funding a demand-response incentive or " "rate pilot: a real site, real meter data, and a measured response to price, not a " "simulation.", trailing_paragraph=( "Combined with the driver behavior shift in Pillar 3 and the demand-coincidence finding " "in Pillar 2, the pattern is consistent: a modest price signal, with no hardware " "throttling required, keeps this site's EV load from ever stacking into a genuine " "simultaneous peak." ), ) # ---- Page 4: partner pitch ---- doc.add_page_break() add_para(doc, "05 · PROSPECTIVE PARTNERS", size=9, bold=True, color=INK_FAINT, space_after=2) partner_h = doc.add_paragraph() partner_h.paragraph_format.space_after = Pt(4) add_run(partner_h, "Want this at your property? We cover everything.", size=16, bold=True, color=INK) partner_fig = doc.add_paragraph() partner_fig.paragraph_format.space_after = Pt(10) add_run(partner_fig, "$0 cost to you", size=22, bold=True, color=ACCENT) add_para(doc, "Everything above happened without the property spending a dollar or taking on any " "operating risk. That's the model: Terrapin installs, owns, and runs the chargers. " "The property hosts them and gets paid.", size=10.5, color=RGBColor(0x33, 0x33, 0x33), space_after=10) for label, detail in [ ("Hardware", "the chargers themselves, fully paid for by Terrapin"), ("Installation", "electrical work, permitting, and setup, at Terrapin's cost"), ("Operations", "billing, maintenance, driver support, and the electricity bill itself, all on Terrapin"), ]: cp = doc.add_paragraph() cp.paragraph_format.space_after = Pt(6) add_run(cp, "✓ ", size=10.5, bold=True, color=ACCENT) add_run(cp, f"{label}", size=10.5, bold=True, color=INK) add_run(cp, f": {detail}", size=10.5, color=RGBColor(0x33, 0x33, 0x33)) add_para(doc, "In exchange, the property earns a licensing fee for hosting the chargers, at The " "Plaza at Pikes Peak, that was $98 in July alone, from just four charging spots, with " "the number scaling with property size and usage. No capital outlay, no maintenance " "calls, no downside if usage is slow, just parking spots turned into a new, passive " "line of income.", size=10.5, color=RGBColor(0x33, 0x33, 0x33), space_before=10, space_after=4) add_why_box(doc, "the property", "Every risk in this case study (the cost of electricity, the demand-charge " "exposure, the equipment itself) sits with Terrapin, not the property. The " "property's downside is zero. That's what makes it a no-brainer.") cta = doc.add_paragraph() cta.paragraph_format.space_before = Pt(14) add_run(cta, "Talk to Terrapin about turning your property's parking into revenue.", size=12, bold=True, color=ACCENT) # ---- Footer ---- add_rule(doc) method = doc.add_paragraph() method.paragraph_format.space_before = Pt(10) add_run(method, "Methodology. ", size=9, bold=True, color=INK_SOFT) add_run(method, "All figures are computed from measured 15-minute CSU utility meter readings and " "CMS session-level export data for 702 East Pikes Peak Avenue, Jun 8–Aug 17, " "2026, not extrapolated or simulated except where explicitly labeled “simulation” " "(Pillar 2's shift scenario, which models moving already-measured on-peak kWh to " "off-peak hours) or “projected” (the annualized revenue and bill-savings figures, " "which extrapolate the measured run rate to a full year). Electricity cost uses " "CSU's published ETL (Electric, Time-of-Use, Large) tariff rates. Full derivations " "are available on request.", size=9, color=INK_FAINT) foot = doc.add_paragraph() foot.paragraph_format.space_before = Pt(14) add_run(foot, "The Plaza at Pikes Peak", size=9.5, bold=True, color=ACCENT) add_run(foot, "\t\t\t\t\t\tPowered by Terrapin", size=9.5, bold=True, color=ACCENT) os.makedirs(os.path.dirname(OUT_PATH), exist_ok=True) doc.save(OUT_PATH) print(f"Wrote: {OUT_PATH}") if __name__ == "__main__": main()