studies/202602_AmazonConnect -> studies/202602_TEI_Amazon_Connect, rebuilt as pattern Variant 4 (TEI composite reproduction): - teicalc/ self-contained engine (stdlib-only): Forrester's tables as the never-edited verbatim anchor, NPV/ROI/payback + risk adjustment transplanted from core/calculations, ClientDrivers overlay (contacts/ agents/fixed driver map, growth re-base, identity at composite scale), scenario stress with core-identical semantics - one deliverable notebook (business_case.ipynb): widget-pair sidebar drivers, published-vs-overlay KPI columns, cash-flow/waterfall/scenario charts, verification gate, backstage JSON data appendix - gate + tests reproduce the published totals within PDF rounding: NPV $78.7M / ROI 342% / payback <6 months (engine $78,713,492 / 342.48% / 0.7 months); 27 study tests, headless nbconvert green, stage simulation leak-free, exports carry the appendix - old Athena workflow (00_provision..04_export, config.py, seed_data.py) deleted; git history preserves it; root test fixture repointed to teicalc.anchor - docs: study README rewritten; root README points new studies at template/MercuryNotebook; pattern doc stale ctm-token-calculator paths now cite studies/202607_CTM_GenesysCX; Variant 4 cites this study as its realized reference Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
6947 lines
247 KiB
Plaintext
6947 lines
247 KiB
Plaintext
{
|
||
"cells": [
|
||
{
|
||
"cell_type": "markdown",
|
||
"id": "cell-0",
|
||
"metadata": {},
|
||
"source": [
|
||
"# Amazon Connect TEI — Business Case\n",
|
||
"\n",
|
||
"Reproduction of Forrester's *Total Economic Impact™ Of Amazon Connect*\n",
|
||
"(February 2026, commissioned by AWS) — and a live personalization of it.\n",
|
||
"The published composite organization is the **verbatim anchor** (never\n",
|
||
"edited); the verification gate proves this notebook reproduces the\n",
|
||
"published **$78.7M NPV · 342% ROI · <6-month payback**; the client drivers\n",
|
||
"then rescale the composite to your organization.\n",
|
||
"\n",
|
||
"**This notebook is the deliverable** — served interactively with Mercury,\n",
|
||
"exported via nbconvert as the report source (Mercury Notebook Pattern,\n",
|
||
"Variant 4).\n",
|
||
"\n",
|
||
"| Layer | What it is | Confidence |\n",
|
||
"|---|---|---|\n",
|
||
"| Verbatim anchor | Forrester's composite tables, unedited | 🟢 published |\n",
|
||
"| Client overlay | first-order linear rescale by your drivers | 🟡 estimated |\n",
|
||
"| Scenario | adoption × risk stress | 🟡 estimated |\n",
|
||
"\n",
|
||
"Confidence legend: 🟢 confirmed/published · 🟡 estimated (stated assumption) · 🔴 unknown (flagged)\n"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": 1,
|
||
"id": "cell-1",
|
||
"metadata": {
|
||
"execution": {
|
||
"iopub.execute_input": "2026-07-09T18:15:41.740701Z",
|
||
"iopub.status.busy": "2026-07-09T18:15:41.740233Z",
|
||
"iopub.status.idle": "2026-07-09T18:15:42.174061Z",
|
||
"shell.execute_reply": "2026-07-09T18:15:42.173079Z"
|
||
}
|
||
},
|
||
"outputs": [
|
||
{
|
||
"name": "stdout",
|
||
"output_type": "stream",
|
||
"text": [
|
||
"teicalc loaded — window 2026–2028 · published NPV $78.7M · ROI 342%\n"
|
||
]
|
||
}
|
||
],
|
||
"source": [
|
||
"# ── Setup ──────────────────────────────────────────────────────────\n",
|
||
"import sys, pathlib\n",
|
||
"_ROOT = pathlib.Path.cwd()\n",
|
||
"if not (_ROOT / \"teicalc\").exists(): # notebook lives in notebooks/\n",
|
||
" _ROOT = _ROOT.parent\n",
|
||
"sys.path.insert(0, str(_ROOT))\n",
|
||
"\n",
|
||
"import pandas as pd\n",
|
||
"import plotly.graph_objects as go\n",
|
||
"\n",
|
||
"import mercury as mr\n",
|
||
"\n",
|
||
"# Single source of truth — all math lives in the study package; only\n",
|
||
"# presentation (and Mercury input widgets) lives here.\n",
|
||
"from teicalc import (\n",
|
||
" ASSUMPTIONS, BENEFITS_VERBATIM, COSTS_VERBATIM, PUBLISHED,\n",
|
||
" YEARS, X_LABELS,\n",
|
||
" BENEFIT_DRIVERS, COST_DRIVERS, COMPOSITE, ClientDrivers,\n",
|
||
" SCENARIOS, apply_scenario, compute_summary, growth_multiplier,\n",
|
||
" money, html_money, overlay_rows,\n",
|
||
")\n",
|
||
"from teicalc.staging import backstage\n",
|
||
"\n",
|
||
"pd.options.display.float_format = \"{:,.0f}\".format\n",
|
||
"\n",
|
||
"# ── Chart chrome (dataviz reference palette, light surface) ─────────\n",
|
||
"INK, INK2, MUTED = \"#0b0b0b\", \"#52514e\", \"#898781\"\n",
|
||
"SURFACE, GRID, BASELINE = \"#fcfcfb\", \"#e1e0d9\", \"#c3c2b7\"\n",
|
||
"CUMULATIVE, CONTEXT = \"#52514e\", \"#c3c2b7\" # neutral line; de-emphasized context series\n",
|
||
"FONT_STACK = 'system-ui, -apple-system, \"Segoe UI\", sans-serif'\n",
|
||
"\n",
|
||
"# Fixed row colors — color follows the entity across every figure.\n",
|
||
"BENEFIT_COLOR = {\n",
|
||
" \"ai_contact_resolution\": \"#2a78d6\", # blue\n",
|
||
" \"ai_content_sentiment\": \"#1baf7a\", # aqua\n",
|
||
" \"ai_forecasting_supervision\": \"#4a3aa7\", # violet\n",
|
||
" \"data_driven_profit_lift\": \"#eda100\", # yellow\n",
|
||
" \"legacy_solution_savings\": \"#008300\", # green\n",
|
||
"}\n",
|
||
"COST_COLOR = {\n",
|
||
" \"amazon_connect_usage\": \"#e34948\", # red\n",
|
||
" \"implementation_migration\": \"#eda100\", # yellow\n",
|
||
" \"ongoing_management\": \"#4a3aa7\", # violet\n",
|
||
"}\n",
|
||
"BEN_TOTAL, COST_TOTAL, NPV_COLOR = \"#1baf7a\", \"#e34948\", \"#2a78d6\"\n",
|
||
"\n",
|
||
"\n",
|
||
"def tei_layout(fig, title, subtitle=None, height=460):\n",
|
||
" t = f\"<b>{title}</b>\"\n",
|
||
" if subtitle:\n",
|
||
" t += f\"<br><span style='font-size:12px;color:{MUTED}'>{subtitle}</span>\"\n",
|
||
" fig.update_layout(\n",
|
||
" title=dict(text=t, font=dict(size=16, color=INK), x=0.02, xanchor=\"left\"),\n",
|
||
" paper_bgcolor=SURFACE, plot_bgcolor=SURFACE,\n",
|
||
" font=dict(family=FONT_STACK, size=12, color=INK2),\n",
|
||
" legend=dict(orientation=\"h\", yanchor=\"top\", y=-0.10, x=0,\n",
|
||
" font=dict(size=11, color=INK2)),\n",
|
||
" xaxis=dict(type=\"category\", showgrid=False, linecolor=BASELINE,\n",
|
||
" tickfont=dict(color=MUTED)),\n",
|
||
" yaxis=dict(gridcolor=GRID, zerolinecolor=BASELINE, zerolinewidth=1.5,\n",
|
||
" tickformat=\"$~s\", tickfont=dict(color=MUTED)),\n",
|
||
" hovermode=\"x unified\", bargap=0.45, height=height,\n",
|
||
" margin=dict(t=70, r=30, b=80, l=70),\n",
|
||
" )\n",
|
||
" return fig\n",
|
||
"\n",
|
||
"\n",
|
||
"def bar(x, y, name, color):\n",
|
||
" return go.Bar(x=x, y=y, name=name,\n",
|
||
" marker=dict(color=color, line=dict(width=2, color=SURFACE)),\n",
|
||
" hovertemplate=\"%{fullData.name}: %{y:$,.0f}<extra></extra>\")\n",
|
||
"\n",
|
||
"\n",
|
||
"def cum_line(x, y, name, color=CUMULATIVE, dash=None):\n",
|
||
" return go.Scatter(x=x, y=y, name=name, mode=\"lines+markers\",\n",
|
||
" line=dict(color=color, width=2, dash=dash),\n",
|
||
" marker=dict(size=8, line=dict(width=2, color=SURFACE)),\n",
|
||
" hovertemplate=\"%{fullData.name}: %{y:$,.0f}<extra></extra>\")\n",
|
||
"\n",
|
||
"\n",
|
||
"backstage(f\"teicalc loaded — window {YEARS[0]}–{YEARS[-1]} · published \"\n",
|
||
" f\"NPV {money(PUBLISHED['npv'])} · ROI {PUBLISHED['roi_pct']}%\")\n"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": 2,
|
||
"id": "cell-2",
|
||
"metadata": {
|
||
"execution": {
|
||
"iopub.execute_input": "2026-07-09T18:15:42.176402Z",
|
||
"iopub.status.busy": "2026-07-09T18:15:42.175865Z",
|
||
"iopub.status.idle": "2026-07-09T18:15:42.192559Z",
|
||
"shell.execute_reply": "2026-07-09T18:15:42.191936Z"
|
||
}
|
||
},
|
||
"outputs": [
|
||
{
|
||
"data": {
|
||
"application/mercury+json": {
|
||
"model_id": "0833fb2c5bd3466aba5ff466dec6ff1b",
|
||
"position": "sidebar",
|
||
"widget": "MarkdownWidget"
|
||
},
|
||
"application/vnd.jupyter.widget-view+json": {
|
||
"model_id": "0833fb2c5bd3466aba5ff466dec6ff1b",
|
||
"version_major": 2,
|
||
"version_minor": 0
|
||
},
|
||
"text/plain": [
|
||
"MarkdownWidget(value='<div style=\"font-family: ui-sans-serif, system-ui, -apple-system, \\'Segoe UI\\', Roboto, …"
|
||
]
|
||
},
|
||
"metadata": {},
|
||
"output_type": "display_data"
|
||
}
|
||
],
|
||
"source": [
|
||
"# ── Jump-to-section ToC (Mercury sidebar — widgets only, no output) ──\n",
|
||
"# Fragment links don't scroll inside Mercury's app shell (SPA base URL),\n",
|
||
"# so each entry scrolls via JS: anchor id first, heading-text fallback.\n",
|
||
"_TOC = [\n",
|
||
" (1, \"Composite organization\"),\n",
|
||
" (2, \"Client inputs\"),\n",
|
||
" (3, \"Benefits\"),\n",
|
||
" (4, \"Costs\"),\n",
|
||
" (5, \"Business case\"),\n",
|
||
" (6, \"Scenarios\"),\n",
|
||
" (7, \"Verification & assertions\"),\n",
|
||
" (8, \"Data appendix\"),\n",
|
||
"]\n",
|
||
"# NB: no raw \"<\" allowed inside the handler — python-markdown escapes the\n",
|
||
"# whole tag if the attribute text looks like malformed HTML.\n",
|
||
"_JS = (\"var el=document.getElementById('section-{n}');\"\n",
|
||
" \"if(!el){{document.querySelectorAll('h1,h2').forEach(function(h){{\"\n",
|
||
" \"if(!el&&h.textContent.trim().indexOf('{n} ')===0){{el=h;}}}});}}\"\n",
|
||
" \"if(el)el.scrollIntoView({{behavior:'smooth',block:'start'}});\")\n",
|
||
"_items = \"\".join(\n",
|
||
" f'<li style=\"margin:2px 0\"><a style=\"cursor:pointer;text-decoration:underline\"'\n",
|
||
" f' onclick=\"{_JS.format(n=n)}\">{label}</a></li>'\n",
|
||
" for n, label in _TOC)\n",
|
||
"_toc = mr.Markdown(\n",
|
||
" text=(f'<b>Jump to section</b>'\n",
|
||
" f'<ol style=\"padding-left:1.2em;margin:6px 0\">{_items}</ol>'),\n",
|
||
" position=\"sidebar\")\n"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"id": "cell-3",
|
||
"metadata": {},
|
||
"source": [
|
||
"<a id=\"section-1\"></a>\n",
|
||
"## 1 · The Forrester composite (verbatim anchor 🟢)\n",
|
||
"\n",
|
||
"Forrester's composite organization: a global B2C company with **$10B\n",
|
||
"year-1 revenue growing 30% YoY**, **2,000 contact-center agents** plus 200\n",
|
||
"supervisors, **20M annual contacts** (75% calls / 25% chat), and a\n",
|
||
"10-minute legacy average handle time.\n",
|
||
"\n",
|
||
"TEI methodology, carried verbatim into the engine: benefits are\n",
|
||
"risk-adjusted **down** (×(1−rf)), costs **up** (×(1+rf)); the initial\n",
|
||
"investment sits at time 0 undiscounted; year flows discount at end-of-year\n",
|
||
"(10%, 3 years). Payback runs on risk-adjusted *undiscounted* flows, per the\n",
|
||
"PDF's Cash Flow Analysis tables.\n"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": 3,
|
||
"id": "cell-4",
|
||
"metadata": {
|
||
"execution": {
|
||
"iopub.execute_input": "2026-07-09T18:15:42.194666Z",
|
||
"iopub.status.busy": "2026-07-09T18:15:42.194415Z",
|
||
"iopub.status.idle": "2026-07-09T18:15:42.210841Z",
|
||
"shell.execute_reply": "2026-07-09T18:15:42.210047Z"
|
||
}
|
||
},
|
||
"outputs": [
|
||
{
|
||
"data": {
|
||
"text/html": [
|
||
"<div>\n",
|
||
"<style scoped>\n",
|
||
" .dataframe tbody tr th:only-of-type {\n",
|
||
" vertical-align: middle;\n",
|
||
" }\n",
|
||
"\n",
|
||
" .dataframe tbody tr th {\n",
|
||
" vertical-align: top;\n",
|
||
" }\n",
|
||
"\n",
|
||
" .dataframe thead th {\n",
|
||
" text-align: right;\n",
|
||
" }\n",
|
||
"</style>\n",
|
||
"<table border=\"1\" class=\"dataframe\">\n",
|
||
" <thead>\n",
|
||
" <tr style=\"text-align: right;\">\n",
|
||
" <th></th>\n",
|
||
" <th>Value 🟢</th>\n",
|
||
" </tr>\n",
|
||
" <tr>\n",
|
||
" <th>Assumption</th>\n",
|
||
" <th></th>\n",
|
||
" </tr>\n",
|
||
" </thead>\n",
|
||
" <tbody>\n",
|
||
" <tr>\n",
|
||
" <th>agents fte</th>\n",
|
||
" <td>2,000</td>\n",
|
||
" </tr>\n",
|
||
" <tr>\n",
|
||
" <th>supervisors fte</th>\n",
|
||
" <td>200</td>\n",
|
||
" </tr>\n",
|
||
" <tr>\n",
|
||
" <th>annual contacts y1</th>\n",
|
||
" <td>20,000,000</td>\n",
|
||
" </tr>\n",
|
||
" <tr>\n",
|
||
" <th>growth rate</th>\n",
|
||
" <td>30%</td>\n",
|
||
" </tr>\n",
|
||
" <tr>\n",
|
||
" <th>call share</th>\n",
|
||
" <td>75%</td>\n",
|
||
" </tr>\n",
|
||
" <tr>\n",
|
||
" <th>aht legacy minutes</th>\n",
|
||
" <td>10 min</td>\n",
|
||
" </tr>\n",
|
||
" <tr>\n",
|
||
" <th>agent salary</th>\n",
|
||
" <td>$45,760</td>\n",
|
||
" </tr>\n",
|
||
" <tr>\n",
|
||
" <th>supervisor salary</th>\n",
|
||
" <td>$55,800</td>\n",
|
||
" </tr>\n",
|
||
" <tr>\n",
|
||
" <th>discount rate</th>\n",
|
||
" <td>10%</td>\n",
|
||
" </tr>\n",
|
||
" <tr>\n",
|
||
" <th>analysis years</th>\n",
|
||
" <td>3 years</td>\n",
|
||
" </tr>\n",
|
||
" </tbody>\n",
|
||
"</table>\n",
|
||
"</div>"
|
||
],
|
||
"text/plain": [
|
||
" Value 🟢\n",
|
||
"Assumption \n",
|
||
"agents fte 2,000\n",
|
||
"supervisors fte 200\n",
|
||
"annual contacts y1 20,000,000\n",
|
||
"growth rate 30%\n",
|
||
"call share 75%\n",
|
||
"aht legacy minutes 10 min\n",
|
||
"agent salary $45,760\n",
|
||
"supervisor salary $55,800\n",
|
||
"discount rate 10%\n",
|
||
"analysis years 3 years"
|
||
]
|
||
},
|
||
"metadata": {},
|
||
"output_type": "display_data"
|
||
},
|
||
{
|
||
"data": {
|
||
"text/html": [
|
||
"<div>\n",
|
||
"<style scoped>\n",
|
||
" .dataframe tbody tr th:only-of-type {\n",
|
||
" vertical-align: middle;\n",
|
||
" }\n",
|
||
"\n",
|
||
" .dataframe tbody tr th {\n",
|
||
" vertical-align: top;\n",
|
||
" }\n",
|
||
"\n",
|
||
" .dataframe thead th {\n",
|
||
" text-align: right;\n",
|
||
" }\n",
|
||
"</style>\n",
|
||
"<table border=\"1\" class=\"dataframe\">\n",
|
||
" <thead>\n",
|
||
" <tr style=\"text-align: right;\">\n",
|
||
" <th></th>\n",
|
||
" <th>Published 🟢</th>\n",
|
||
" </tr>\n",
|
||
" <tr>\n",
|
||
" <th>Metric</th>\n",
|
||
" <th></th>\n",
|
||
" </tr>\n",
|
||
" </thead>\n",
|
||
" <tbody>\n",
|
||
" <tr>\n",
|
||
" <th>Benefits PV (risk-adjusted)</th>\n",
|
||
" <td>$101,696,791</td>\n",
|
||
" </tr>\n",
|
||
" <tr>\n",
|
||
" <th>Costs PV (risk-adjusted)</th>\n",
|
||
" <td>$22,983,076</td>\n",
|
||
" </tr>\n",
|
||
" <tr>\n",
|
||
" <th>NPV</th>\n",
|
||
" <td>$78,713,715</td>\n",
|
||
" </tr>\n",
|
||
" <tr>\n",
|
||
" <th>ROI</th>\n",
|
||
" <td>342%</td>\n",
|
||
" </tr>\n",
|
||
" <tr>\n",
|
||
" <th>Payback</th>\n",
|
||
" <td><6 months</td>\n",
|
||
" </tr>\n",
|
||
" </tbody>\n",
|
||
"</table>\n",
|
||
"</div>"
|
||
],
|
||
"text/plain": [
|
||
" Published 🟢\n",
|
||
"Metric \n",
|
||
"Benefits PV (risk-adjusted) $101,696,791\n",
|
||
"Costs PV (risk-adjusted) $22,983,076\n",
|
||
"NPV $78,713,715\n",
|
||
"ROI 342%\n",
|
||
"Payback <6 months"
|
||
]
|
||
},
|
||
"metadata": {},
|
||
"output_type": "display_data"
|
||
},
|
||
{
|
||
"name": "stdout",
|
||
"output_type": "stream",
|
||
"text": [
|
||
"engine reproduction Δ vs PDF (Forrester table rounding): benefits -223.45 · costs -0.22 · npv -223.22\n"
|
||
]
|
||
}
|
||
],
|
||
"source": [
|
||
"# ── Composite assumptions & published financial summary (🟢) ─────────\n",
|
||
"composite = compute_summary(BENEFITS_VERBATIM, COSTS_VERBATIM,\n",
|
||
" PUBLISHED[\"discount_rate\"])\n",
|
||
"\n",
|
||
"_fmt = {\n",
|
||
" \"agents_fte\": \"{:,}\", \"supervisors_fte\": \"{:,}\",\n",
|
||
" \"annual_contacts_y1\": \"{:,}\", \"growth_rate\": \"{:.0%}\",\n",
|
||
" \"call_share\": \"{:.0%}\", \"aht_legacy_minutes\": \"{} min\",\n",
|
||
" \"agent_salary\": \"${:,}\", \"supervisor_salary\": \"${:,}\",\n",
|
||
" \"discount_rate\": \"{:.0%}\", \"analysis_years\": \"{} years\",\n",
|
||
"}\n",
|
||
"assumptions_df = pd.DataFrame(\n",
|
||
" [{\"Assumption\": k.replace(\"_\", \" \"), \"Value 🟢\": _fmt[k].format(v)}\n",
|
||
" for k, v in ASSUMPTIONS.items()])\n",
|
||
"display(assumptions_df.set_index(\"Assumption\"))\n",
|
||
"\n",
|
||
"published_df = pd.DataFrame([\n",
|
||
" {\"Metric\": \"Benefits PV (risk-adjusted)\", \"Published 🟢\": f\"${PUBLISHED['benefits_pv']:,}\"},\n",
|
||
" {\"Metric\": \"Costs PV (risk-adjusted)\", \"Published 🟢\": f\"${PUBLISHED['costs_pv']:,}\"},\n",
|
||
" {\"Metric\": \"NPV\", \"Published 🟢\": f\"${PUBLISHED['npv']:,}\"},\n",
|
||
" {\"Metric\": \"ROI\", \"Published 🟢\": f\"{PUBLISHED['roi_pct']}%\"},\n",
|
||
" {\"Metric\": \"Payback\", \"Published 🟢\": \"<6 months\"},\n",
|
||
"])\n",
|
||
"display(published_df.set_index(\"Metric\"))\n",
|
||
"\n",
|
||
"backstage(f\"engine reproduction Δ vs PDF (Forrester table rounding): \"\n",
|
||
" f\"benefits {composite['benefits_pv'] - PUBLISHED['benefits_pv']:+,.2f} · \"\n",
|
||
" f\"costs {composite['costs_pv'] - PUBLISHED['costs_pv']:+,.2f} · \"\n",
|
||
" f\"npv {composite['npv'] - PUBLISHED['npv']:+,.2f}\")\n"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"id": "cell-5",
|
||
"metadata": {},
|
||
"source": [
|
||
"<a id=\"section-2\"></a>\n",
|
||
"## 2 · Client inputs (overlay 🟡)\n",
|
||
"\n",
|
||
"The overlay is a **first-order linear rescale** of Forrester's composite —\n",
|
||
"it answers *\"what does the composite look like at your size?\"*, not *\"what\n",
|
||
"is your TEI?\"*. Each published row scales with the driver that dominates\n",
|
||
"its derivation in the PDF; project-based costs stay fixed. The client\n",
|
||
"growth rate re-bases the composite's Y1→Y3 trajectory (which embeds 30%\n",
|
||
"YoY).\n",
|
||
"\n",
|
||
"| Published row | Scales with | Confidence |\n",
|
||
"|---|---|---|\n",
|
||
"| AI-driven contact resolution efficiency | contacts | 🟡 |\n",
|
||
"| AI-powered content & sentiment analysis | contacts | 🟡 |\n",
|
||
"| AI-enabled forecasting, scheduling & supervision | agents | 🟡 |\n",
|
||
"| Data-driven profit lift | contacts | 🔴 proxy — revenue-driven in the PDF |\n",
|
||
"| Legacy solution cost savings | agents | 🟡 |\n",
|
||
"| Amazon Connect usage | contacts | 🟡 |\n",
|
||
"| Implementation & migration | fixed | 🟡 project-based |\n",
|
||
"| Ongoing management | fixed | 🟡 |\n",
|
||
"\n",
|
||
"*Change any input in the sidebar — every table, figure and KPI below\n",
|
||
"recomputes. The assertions in §7 hold at any setting.*\n"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": 4,
|
||
"id": "cell-6",
|
||
"metadata": {
|
||
"execution": {
|
||
"iopub.execute_input": "2026-07-09T18:15:42.213716Z",
|
||
"iopub.status.busy": "2026-07-09T18:15:42.213491Z",
|
||
"iopub.status.idle": "2026-07-09T18:15:42.227389Z",
|
||
"shell.execute_reply": "2026-07-09T18:15:42.226903Z"
|
||
}
|
||
},
|
||
"outputs": [
|
||
{
|
||
"data": {
|
||
"application/mercury+json": {
|
||
"model_id": "ace9c00dc3a84babb5b29ae61e2be62c",
|
||
"position": "sidebar",
|
||
"widget": "NumberInputWidget"
|
||
},
|
||
"application/vnd.jupyter.widget-view+json": {
|
||
"model_id": "ace9c00dc3a84babb5b29ae61e2be62c",
|
||
"version_major": 2,
|
||
"version_minor": 1
|
||
},
|
||
"text/plain": [
|
||
"<mercury.number.NumberInputWidget object at 0x7f1550b512b0>"
|
||
]
|
||
},
|
||
"metadata": {},
|
||
"output_type": "display_data"
|
||
},
|
||
{
|
||
"data": {
|
||
"application/mercury+json": {
|
||
"model_id": "01c230d30aed4aa58c5a005c44e65f84",
|
||
"position": "sidebar",
|
||
"widget": "NumberInputWidget"
|
||
},
|
||
"application/vnd.jupyter.widget-view+json": {
|
||
"model_id": "01c230d30aed4aa58c5a005c44e65f84",
|
||
"version_major": 2,
|
||
"version_minor": 1
|
||
},
|
||
"text/plain": [
|
||
"<mercury.number.NumberInputWidget object at 0x7f1550b4d1d0>"
|
||
]
|
||
},
|
||
"metadata": {},
|
||
"output_type": "display_data"
|
||
},
|
||
{
|
||
"data": {
|
||
"application/mercury+json": {
|
||
"model_id": "d4ccace17e824e4a85c55ff6ef95afb0",
|
||
"position": "sidebar",
|
||
"widget": "NumberInputWidget"
|
||
},
|
||
"application/vnd.jupyter.widget-view+json": {
|
||
"model_id": "d4ccace17e824e4a85c55ff6ef95afb0",
|
||
"version_major": 2,
|
||
"version_minor": 1
|
||
},
|
||
"text/plain": [
|
||
"<mercury.number.NumberInputWidget object at 0x7f1550b4cf50>"
|
||
]
|
||
},
|
||
"metadata": {},
|
||
"output_type": "display_data"
|
||
},
|
||
{
|
||
"data": {
|
||
"application/mercury+json": {
|
||
"model_id": "eb0056195e9b444dbb8ef3c08e744446",
|
||
"position": "sidebar",
|
||
"widget": "SelectWidget"
|
||
},
|
||
"application/vnd.jupyter.widget-view+json": {
|
||
"model_id": "eb0056195e9b444dbb8ef3c08e744446",
|
||
"version_major": 2,
|
||
"version_minor": 1
|
||
},
|
||
"text/plain": [
|
||
"<mercury.select.SelectWidget object at 0x7f1550b516a0>"
|
||
]
|
||
},
|
||
"metadata": {},
|
||
"output_type": "display_data"
|
||
},
|
||
{
|
||
"data": {
|
||
"application/mercury+json": {
|
||
"model_id": "b1e82327c29a44a8a4a45e8100b3d857",
|
||
"position": "sidebar",
|
||
"widget": "SelectWidget"
|
||
},
|
||
"application/vnd.jupyter.widget-view+json": {
|
||
"model_id": "b1e82327c29a44a8a4a45e8100b3d857",
|
||
"version_major": 2,
|
||
"version_minor": 1
|
||
},
|
||
"text/plain": [
|
||
"<mercury.select.SelectWidget object at 0x7f1550b4d810>"
|
||
]
|
||
},
|
||
"metadata": {},
|
||
"output_type": "display_data"
|
||
}
|
||
],
|
||
"source": [
|
||
"# ── Client drivers (Mercury sidebar — widgets only, NO other output) ─\n",
|
||
"# NB: Mercury re-executes only cells BELOW a changed widget's cell, so\n",
|
||
"# this cell constructs widgets ONLY — .value is read downstream.\n",
|
||
"_agents_w = mr.NumberInput(label=\"Contact-center agents (FTE) — composite 2,000\",\n",
|
||
" value=2_000, min=50, max=50_000, step=50)\n",
|
||
"_contacts_w = mr.NumberInput(label=\"Annual contacts, year 1 — composite 20M\",\n",
|
||
" value=20_000_000, min=100_000, max=500_000_000,\n",
|
||
" step=1_000_000)\n",
|
||
"_growth_w = mr.NumberInput(label=\"Contact growth (%/yr) — composite 30\",\n",
|
||
" value=30, min=0, max=100, step=5)\n",
|
||
"_discount_w = mr.Select(label=\"Discount rate\", value=\"10% (Forrester)\",\n",
|
||
" choices=[\"8%\", \"10% (Forrester)\", \"12%\"])\n",
|
||
"_scenario_w = mr.Select(label=\"Scenario\", value=\"moderate\",\n",
|
||
" choices=[\"conservative\", \"moderate\", \"aggressive\"])\n"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": 5,
|
||
"id": "cell-7",
|
||
"metadata": {
|
||
"execution": {
|
||
"iopub.execute_input": "2026-07-09T18:15:42.229494Z",
|
||
"iopub.status.busy": "2026-07-09T18:15:42.229335Z",
|
||
"iopub.status.idle": "2026-07-09T18:15:42.234902Z",
|
||
"shell.execute_reply": "2026-07-09T18:15:42.234410Z"
|
||
}
|
||
},
|
||
"outputs": [
|
||
{
|
||
"name": "stdout",
|
||
"output_type": "stream",
|
||
"text": [
|
||
"Client frame: 2,000 agents · 20M contacts (+30%/yr) · moderate scenario → NPV $78.7M · ROI 342% · payback 0.7 months (~Jan 2026)\n",
|
||
"scale factors — agents 1.00× · contacts 1.00× · growth re-base Y3 1.000×\n"
|
||
]
|
||
}
|
||
],
|
||
"source": [
|
||
"# ── Client overlay state (re-runs on any change to the widgets above) ─\n",
|
||
"AGENTS_FTE = int(_agents_w.value)\n",
|
||
"CONTACTS_Y1 = int(_contacts_w.value)\n",
|
||
"GROWTH_RATE = float(_growth_w.value) / 100 # widget holds a % integer\n",
|
||
"DISCOUNT_RATE = {\"8%\": 0.08, \"10% (Forrester)\": 0.10,\n",
|
||
" \"12%\": 0.12}[str(_discount_w.value)]\n",
|
||
"SCENARIO = str(_scenario_w.value)\n",
|
||
"\n",
|
||
"DRIVERS = ClientDrivers(agents_fte=AGENTS_FTE, annual_contacts_y1=CONTACTS_Y1,\n",
|
||
" growth_rate=GROWTH_RATE, discount_rate=DISCOUNT_RATE)\n",
|
||
"overlay_benefits, overlay_costs = overlay_rows(DRIVERS)\n",
|
||
"client_benefits = apply_scenario(overlay_benefits, SCENARIO)\n",
|
||
"client_costs = apply_scenario(overlay_costs, SCENARIO)\n",
|
||
"client = compute_summary(client_benefits, client_costs, DISCOUNT_RATE)\n",
|
||
"\n",
|
||
"_at_default = (DRIVERS == COMPOSITE and SCENARIO == \"moderate\")\n",
|
||
"\n",
|
||
"print(f\"Client frame: {AGENTS_FTE:,} agents · {CONTACTS_Y1 / 1e6:,.0f}M contacts \"\n",
|
||
" f\"(+{GROWTH_RATE:.0%}/yr) · {SCENARIO} scenario → NPV {money(client['npv'])} · \"\n",
|
||
" f\"ROI {client['roi_pct']:.0f}% · payback {client['payback_label']}\")\n",
|
||
"backstage(f\"scale factors — agents {AGENTS_FTE / ASSUMPTIONS['agents_fte']:.2f}× · \"\n",
|
||
" f\"contacts {CONTACTS_Y1 / ASSUMPTIONS['annual_contacts_y1']:.2f}× · \"\n",
|
||
" f\"growth re-base Y3 {growth_multiplier(3, GROWTH_RATE):.3f}×\")\n"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"id": "cell-8",
|
||
"metadata": {},
|
||
"source": [
|
||
"<a id=\"section-3\"></a>\n",
|
||
"## 3 · Benefits\n",
|
||
"\n",
|
||
"Five benefit streams (Forrester refs At–Et), risk-adjusted down 15–20%.\n",
|
||
"Half the total is AI-driven contact-resolution efficiency; all five phase\n",
|
||
"up with the growth trajectory across the window.\n"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": 6,
|
||
"id": "cell-9",
|
||
"metadata": {
|
||
"execution": {
|
||
"iopub.execute_input": "2026-07-09T18:15:42.236799Z",
|
||
"iopub.status.busy": "2026-07-09T18:15:42.236657Z",
|
||
"iopub.status.idle": "2026-07-09T18:15:42.247373Z",
|
||
"shell.execute_reply": "2026-07-09T18:15:42.246802Z"
|
||
}
|
||
},
|
||
"outputs": [
|
||
{
|
||
"data": {
|
||
"text/html": [
|
||
"<div>\n",
|
||
"<style scoped>\n",
|
||
" .dataframe tbody tr th:only-of-type {\n",
|
||
" vertical-align: middle;\n",
|
||
" }\n",
|
||
"\n",
|
||
" .dataframe tbody tr th {\n",
|
||
" vertical-align: top;\n",
|
||
" }\n",
|
||
"\n",
|
||
" .dataframe thead th {\n",
|
||
" text-align: right;\n",
|
||
" }\n",
|
||
"</style>\n",
|
||
"<table border=\"1\" class=\"dataframe\">\n",
|
||
" <thead>\n",
|
||
" <tr style=\"text-align: right;\">\n",
|
||
" <th></th>\n",
|
||
" <th>Driver</th>\n",
|
||
" <th>Risk adj</th>\n",
|
||
" <th>2026</th>\n",
|
||
" <th>2027</th>\n",
|
||
" <th>2028</th>\n",
|
||
" <th>3-yr RA</th>\n",
|
||
" <th>PV</th>\n",
|
||
" </tr>\n",
|
||
" <tr>\n",
|
||
" <th>Benefit</th>\n",
|
||
" <th></th>\n",
|
||
" <th></th>\n",
|
||
" <th></th>\n",
|
||
" <th></th>\n",
|
||
" <th></th>\n",
|
||
" <th></th>\n",
|
||
" <th></th>\n",
|
||
" </tr>\n",
|
||
" </thead>\n",
|
||
" <tbody>\n",
|
||
" <tr>\n",
|
||
" <th>AI-driven contact resolution efficiency</th>\n",
|
||
" <td>contacts</td>\n",
|
||
" <td>-15%</td>\n",
|
||
" <td>11,824,384</td>\n",
|
||
" <td>20,342,608</td>\n",
|
||
" <td>32,128,096</td>\n",
|
||
" <td>64,295,088</td>\n",
|
||
" <td>51,699,827</td>\n",
|
||
" </tr>\n",
|
||
" <tr>\n",
|
||
" <th>AI-powered content and sentiment analysis savings</th>\n",
|
||
" <td>contacts</td>\n",
|
||
" <td>-15%</td>\n",
|
||
" <td>3,898,627</td>\n",
|
||
" <td>4,554,650</td>\n",
|
||
" <td>5,347,928</td>\n",
|
||
" <td>13,801,205</td>\n",
|
||
" <td>11,326,358</td>\n",
|
||
" </tr>\n",
|
||
" <tr>\n",
|
||
" <th>AI-enabled forecasting, agent scheduling, and supervision</th>\n",
|
||
" <td>agents</td>\n",
|
||
" <td>-15%</td>\n",
|
||
" <td>5,653,928</td>\n",
|
||
" <td>7,763,696</td>\n",
|
||
" <td>10,532,955</td>\n",
|
||
" <td>23,950,579</td>\n",
|
||
" <td>19,469,777</td>\n",
|
||
" </tr>\n",
|
||
" <tr>\n",
|
||
" <th>Data-driven profit lift with increased conversion</th>\n",
|
||
" <td>contacts</td>\n",
|
||
" <td>-20%</td>\n",
|
||
" <td>960,000</td>\n",
|
||
" <td>1,248,000</td>\n",
|
||
" <td>1,622,400</td>\n",
|
||
" <td>3,830,400</td>\n",
|
||
" <td>3,123,065</td>\n",
|
||
" </tr>\n",
|
||
" <tr>\n",
|
||
" <th>Legacy solution cost savings</th>\n",
|
||
" <td>agents</td>\n",
|
||
" <td>-20%</td>\n",
|
||
" <td>4,942,080</td>\n",
|
||
" <td>6,424,704</td>\n",
|
||
" <td>8,352,115</td>\n",
|
||
" <td>19,718,899</td>\n",
|
||
" <td>16,077,540</td>\n",
|
||
" </tr>\n",
|
||
" <tr>\n",
|
||
" <th>TOTAL</th>\n",
|
||
" <td></td>\n",
|
||
" <td></td>\n",
|
||
" <td>27,279,019</td>\n",
|
||
" <td>40,333,658</td>\n",
|
||
" <td>57,983,494</td>\n",
|
||
" <td>125,596,172</td>\n",
|
||
" <td>101,696,568</td>\n",
|
||
" </tr>\n",
|
||
" </tbody>\n",
|
||
"</table>\n",
|
||
"</div>"
|
||
],
|
||
"text/plain": [
|
||
" Driver Risk adj \\\n",
|
||
"Benefit \n",
|
||
"AI-driven contact resolution efficiency contacts -15% \n",
|
||
"AI-powered content and sentiment analysis savings contacts -15% \n",
|
||
"AI-enabled forecasting, agent scheduling, and s... agents -15% \n",
|
||
"Data-driven profit lift with increased conversion contacts -20% \n",
|
||
"Legacy solution cost savings agents -20% \n",
|
||
"TOTAL \n",
|
||
"\n",
|
||
" 2026 2027 \\\n",
|
||
"Benefit \n",
|
||
"AI-driven contact resolution efficiency 11,824,384 20,342,608 \n",
|
||
"AI-powered content and sentiment analysis savings 3,898,627 4,554,650 \n",
|
||
"AI-enabled forecasting, agent scheduling, and s... 5,653,928 7,763,696 \n",
|
||
"Data-driven profit lift with increased conversion 960,000 1,248,000 \n",
|
||
"Legacy solution cost savings 4,942,080 6,424,704 \n",
|
||
"TOTAL 27,279,019 40,333,658 \n",
|
||
"\n",
|
||
" 2028 3-yr RA \\\n",
|
||
"Benefit \n",
|
||
"AI-driven contact resolution efficiency 32,128,096 64,295,088 \n",
|
||
"AI-powered content and sentiment analysis savings 5,347,928 13,801,205 \n",
|
||
"AI-enabled forecasting, agent scheduling, and s... 10,532,955 23,950,579 \n",
|
||
"Data-driven profit lift with increased conversion 1,622,400 3,830,400 \n",
|
||
"Legacy solution cost savings 8,352,115 19,718,899 \n",
|
||
"TOTAL 57,983,494 125,596,172 \n",
|
||
"\n",
|
||
" PV \n",
|
||
"Benefit \n",
|
||
"AI-driven contact resolution efficiency 51,699,827 \n",
|
||
"AI-powered content and sentiment analysis savings 11,326,358 \n",
|
||
"AI-enabled forecasting, agent scheduling, and s... 19,469,777 \n",
|
||
"Data-driven profit lift with increased conversion 3,123,065 \n",
|
||
"Legacy solution cost savings 16,077,540 \n",
|
||
"TOTAL 101,696,568 "
|
||
]
|
||
},
|
||
"metadata": {},
|
||
"output_type": "display_data"
|
||
}
|
||
],
|
||
"source": [
|
||
"# ── Benefits table — client overlay, risk-adjusted ───────────────────\n",
|
||
"_ben_rows = client[\"rows\"][\"benefits\"]\n",
|
||
"benefits_df = pd.DataFrame([{\n",
|
||
" \"Benefit\": r[\"label\"],\n",
|
||
" \"Driver\": BENEFIT_DRIVERS[r[\"field_key\"]],\n",
|
||
" \"Risk adj\": f\"-{r['risk_adjustment']:.0%}\",\n",
|
||
" **{str(y): r[\"ra_by_year\"][y] for y in YEARS},\n",
|
||
" \"3-yr RA\": r[\"three_yr_ra\"],\n",
|
||
" \"PV\": r[\"pv\"],\n",
|
||
"} for r in _ben_rows]).set_index(\"Benefit\")\n",
|
||
"benefits_df.loc[\"TOTAL\"] = [\"\", \"\"] + [client[\"benefits_by_year\"][y] for y in YEARS] \\\n",
|
||
" + [sum(r[\"three_yr_ra\"] for r in _ben_rows), client[\"benefits_pv\"]]\n",
|
||
"if not _at_default: # published composite beside the overlay for reference\n",
|
||
" benefits_df[\"Composite PV 🟢\"] = \\\n",
|
||
" [r[\"pv\"] for r in composite[\"rows\"][\"benefits\"]] + [composite[\"benefits_pv\"]]\n",
|
||
"display(benefits_df)\n"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": 7,
|
||
"id": "cell-10",
|
||
"metadata": {
|
||
"execution": {
|
||
"iopub.execute_input": "2026-07-09T18:15:42.249508Z",
|
||
"iopub.status.busy": "2026-07-09T18:15:42.249351Z",
|
||
"iopub.status.idle": "2026-07-09T18:15:43.253962Z",
|
||
"shell.execute_reply": "2026-07-09T18:15:43.253381Z"
|
||
}
|
||
},
|
||
"outputs": [
|
||
{
|
||
"data": {
|
||
"application/vnd.plotly.v1+json": {
|
||
"config": {
|
||
"plotlyServerURL": "https://plot.ly"
|
||
},
|
||
"data": [
|
||
{
|
||
"hovertemplate": "%{fullData.name}: %{y:$,.0f}<extra></extra>",
|
||
"marker": {
|
||
"color": "#2a78d6",
|
||
"line": {
|
||
"color": "#fcfcfb",
|
||
"width": 2
|
||
}
|
||
},
|
||
"name": "AI-driven contact resolution efficiency",
|
||
"type": "bar",
|
||
"x": [
|
||
"2026",
|
||
"2027",
|
||
"2028"
|
||
],
|
||
"y": [
|
||
11824384.0,
|
||
20342608.0,
|
||
32128096.0
|
||
]
|
||
},
|
||
{
|
||
"hovertemplate": "%{fullData.name}: %{y:$,.0f}<extra></extra>",
|
||
"marker": {
|
||
"color": "#1baf7a",
|
||
"line": {
|
||
"color": "#fcfcfb",
|
||
"width": 2
|
||
}
|
||
},
|
||
"name": "AI-powered content and sentiment analysis savings",
|
||
"type": "bar",
|
||
"x": [
|
||
"2026",
|
||
"2027",
|
||
"2028"
|
||
],
|
||
"y": [
|
||
3898627.0,
|
||
4554650.2,
|
||
5347928.0
|
||
]
|
||
},
|
||
{
|
||
"hovertemplate": "%{fullData.name}: %{y:$,.0f}<extra></extra>",
|
||
"marker": {
|
||
"color": "#4a3aa7",
|
||
"line": {
|
||
"color": "#fcfcfb",
|
||
"width": 2
|
||
}
|
||
},
|
||
"name": "AI-enabled forecasting, agent scheduling, and supervision",
|
||
"type": "bar",
|
||
"x": [
|
||
"2026",
|
||
"2027",
|
||
"2028"
|
||
],
|
||
"y": [
|
||
5653928.0,
|
||
7763696.0,
|
||
10532955.2
|
||
]
|
||
},
|
||
{
|
||
"hovertemplate": "%{fullData.name}: %{y:$,.0f}<extra></extra>",
|
||
"marker": {
|
||
"color": "#eda100",
|
||
"line": {
|
||
"color": "#fcfcfb",
|
||
"width": 2
|
||
}
|
||
},
|
||
"name": "Data-driven profit lift with increased conversion",
|
||
"type": "bar",
|
||
"x": [
|
||
"2026",
|
||
"2027",
|
||
"2028"
|
||
],
|
||
"y": [
|
||
960000.0,
|
||
1248000.0,
|
||
1622400.0
|
||
]
|
||
},
|
||
{
|
||
"hovertemplate": "%{fullData.name}: %{y:$,.0f}<extra></extra>",
|
||
"marker": {
|
||
"color": "#008300",
|
||
"line": {
|
||
"color": "#fcfcfb",
|
||
"width": 2
|
||
}
|
||
},
|
||
"name": "Legacy solution cost savings",
|
||
"type": "bar",
|
||
"x": [
|
||
"2026",
|
||
"2027",
|
||
"2028"
|
||
],
|
||
"y": [
|
||
4942080.0,
|
||
6424704.0,
|
||
8352115.2
|
||
]
|
||
}
|
||
],
|
||
"layout": {
|
||
"bargap": 0.45,
|
||
"barmode": "stack",
|
||
"font": {
|
||
"color": "#52514e",
|
||
"family": "system-ui, -apple-system, \"Segoe UI\", sans-serif",
|
||
"size": 12
|
||
},
|
||
"height": 480,
|
||
"hovermode": "x unified",
|
||
"legend": {
|
||
"font": {
|
||
"color": "#52514e",
|
||
"size": 11
|
||
},
|
||
"orientation": "h",
|
||
"x": 0,
|
||
"y": -0.1,
|
||
"yanchor": "top"
|
||
},
|
||
"margin": {
|
||
"b": 80,
|
||
"l": 70,
|
||
"r": 30,
|
||
"t": 70
|
||
},
|
||
"paper_bgcolor": "#fcfcfb",
|
||
"plot_bgcolor": "#fcfcfb",
|
||
"template": {
|
||
"data": {
|
||
"bar": [
|
||
{
|
||
"error_x": {
|
||
"color": "#2a3f5f"
|
||
},
|
||
"error_y": {
|
||
"color": "#2a3f5f"
|
||
},
|
||
"marker": {
|
||
"line": {
|
||
"color": "#E5ECF6",
|
||
"width": 0.5
|
||
},
|
||
"pattern": {
|
||
"fillmode": "overlay",
|
||
"size": 10,
|
||
"solidity": 0.2
|
||
}
|
||
},
|
||
"type": "bar"
|
||
}
|
||
],
|
||
"barpolar": [
|
||
{
|
||
"marker": {
|
||
"line": {
|
||
"color": "#E5ECF6",
|
||
"width": 0.5
|
||
},
|
||
"pattern": {
|
||
"fillmode": "overlay",
|
||
"size": 10,
|
||
"solidity": 0.2
|
||
}
|
||
},
|
||
"type": "barpolar"
|
||
}
|
||
],
|
||
"carpet": [
|
||
{
|
||
"aaxis": {
|
||
"endlinecolor": "#2a3f5f",
|
||
"gridcolor": "white",
|
||
"linecolor": "white",
|
||
"minorgridcolor": "white",
|
||
"startlinecolor": "#2a3f5f"
|
||
},
|
||
"baxis": {
|
||
"endlinecolor": "#2a3f5f",
|
||
"gridcolor": "white",
|
||
"linecolor": "white",
|
||
"minorgridcolor": "white",
|
||
"startlinecolor": "#2a3f5f"
|
||
},
|
||
"type": "carpet"
|
||
}
|
||
],
|
||
"choropleth": [
|
||
{
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
},
|
||
"type": "choropleth"
|
||
}
|
||
],
|
||
"contour": [
|
||
{
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
},
|
||
"colorscale": [
|
||
[
|
||
0.0,
|
||
"#0d0887"
|
||
],
|
||
[
|
||
0.1111111111111111,
|
||
"#46039f"
|
||
],
|
||
[
|
||
0.2222222222222222,
|
||
"#7201a8"
|
||
],
|
||
[
|
||
0.3333333333333333,
|
||
"#9c179e"
|
||
],
|
||
[
|
||
0.4444444444444444,
|
||
"#bd3786"
|
||
],
|
||
[
|
||
0.5555555555555556,
|
||
"#d8576b"
|
||
],
|
||
[
|
||
0.6666666666666666,
|
||
"#ed7953"
|
||
],
|
||
[
|
||
0.7777777777777778,
|
||
"#fb9f3a"
|
||
],
|
||
[
|
||
0.8888888888888888,
|
||
"#fdca26"
|
||
],
|
||
[
|
||
1.0,
|
||
"#f0f921"
|
||
]
|
||
],
|
||
"type": "contour"
|
||
}
|
||
],
|
||
"contourcarpet": [
|
||
{
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
},
|
||
"type": "contourcarpet"
|
||
}
|
||
],
|
||
"heatmap": [
|
||
{
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
},
|
||
"colorscale": [
|
||
[
|
||
0.0,
|
||
"#0d0887"
|
||
],
|
||
[
|
||
0.1111111111111111,
|
||
"#46039f"
|
||
],
|
||
[
|
||
0.2222222222222222,
|
||
"#7201a8"
|
||
],
|
||
[
|
||
0.3333333333333333,
|
||
"#9c179e"
|
||
],
|
||
[
|
||
0.4444444444444444,
|
||
"#bd3786"
|
||
],
|
||
[
|
||
0.5555555555555556,
|
||
"#d8576b"
|
||
],
|
||
[
|
||
0.6666666666666666,
|
||
"#ed7953"
|
||
],
|
||
[
|
||
0.7777777777777778,
|
||
"#fb9f3a"
|
||
],
|
||
[
|
||
0.8888888888888888,
|
||
"#fdca26"
|
||
],
|
||
[
|
||
1.0,
|
||
"#f0f921"
|
||
]
|
||
],
|
||
"type": "heatmap"
|
||
}
|
||
],
|
||
"histogram": [
|
||
{
|
||
"marker": {
|
||
"pattern": {
|
||
"fillmode": "overlay",
|
||
"size": 10,
|
||
"solidity": 0.2
|
||
}
|
||
},
|
||
"type": "histogram"
|
||
}
|
||
],
|
||
"histogram2d": [
|
||
{
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
},
|
||
"colorscale": [
|
||
[
|
||
0.0,
|
||
"#0d0887"
|
||
],
|
||
[
|
||
0.1111111111111111,
|
||
"#46039f"
|
||
],
|
||
[
|
||
0.2222222222222222,
|
||
"#7201a8"
|
||
],
|
||
[
|
||
0.3333333333333333,
|
||
"#9c179e"
|
||
],
|
||
[
|
||
0.4444444444444444,
|
||
"#bd3786"
|
||
],
|
||
[
|
||
0.5555555555555556,
|
||
"#d8576b"
|
||
],
|
||
[
|
||
0.6666666666666666,
|
||
"#ed7953"
|
||
],
|
||
[
|
||
0.7777777777777778,
|
||
"#fb9f3a"
|
||
],
|
||
[
|
||
0.8888888888888888,
|
||
"#fdca26"
|
||
],
|
||
[
|
||
1.0,
|
||
"#f0f921"
|
||
]
|
||
],
|
||
"type": "histogram2d"
|
||
}
|
||
],
|
||
"histogram2dcontour": [
|
||
{
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
},
|
||
"colorscale": [
|
||
[
|
||
0.0,
|
||
"#0d0887"
|
||
],
|
||
[
|
||
0.1111111111111111,
|
||
"#46039f"
|
||
],
|
||
[
|
||
0.2222222222222222,
|
||
"#7201a8"
|
||
],
|
||
[
|
||
0.3333333333333333,
|
||
"#9c179e"
|
||
],
|
||
[
|
||
0.4444444444444444,
|
||
"#bd3786"
|
||
],
|
||
[
|
||
0.5555555555555556,
|
||
"#d8576b"
|
||
],
|
||
[
|
||
0.6666666666666666,
|
||
"#ed7953"
|
||
],
|
||
[
|
||
0.7777777777777778,
|
||
"#fb9f3a"
|
||
],
|
||
[
|
||
0.8888888888888888,
|
||
"#fdca26"
|
||
],
|
||
[
|
||
1.0,
|
||
"#f0f921"
|
||
]
|
||
],
|
||
"type": "histogram2dcontour"
|
||
}
|
||
],
|
||
"mesh3d": [
|
||
{
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
},
|
||
"type": "mesh3d"
|
||
}
|
||
],
|
||
"parcoords": [
|
||
{
|
||
"line": {
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
}
|
||
},
|
||
"type": "parcoords"
|
||
}
|
||
],
|
||
"pie": [
|
||
{
|
||
"automargin": true,
|
||
"type": "pie"
|
||
}
|
||
],
|
||
"scatter": [
|
||
{
|
||
"fillpattern": {
|
||
"fillmode": "overlay",
|
||
"size": 10,
|
||
"solidity": 0.2
|
||
},
|
||
"type": "scatter"
|
||
}
|
||
],
|
||
"scatter3d": [
|
||
{
|
||
"line": {
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
}
|
||
},
|
||
"marker": {
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
}
|
||
},
|
||
"type": "scatter3d"
|
||
}
|
||
],
|
||
"scattercarpet": [
|
||
{
|
||
"marker": {
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
}
|
||
},
|
||
"type": "scattercarpet"
|
||
}
|
||
],
|
||
"scattergeo": [
|
||
{
|
||
"marker": {
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
}
|
||
},
|
||
"type": "scattergeo"
|
||
}
|
||
],
|
||
"scattergl": [
|
||
{
|
||
"marker": {
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
}
|
||
},
|
||
"type": "scattergl"
|
||
}
|
||
],
|
||
"scattermap": [
|
||
{
|
||
"marker": {
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
}
|
||
},
|
||
"type": "scattermap"
|
||
}
|
||
],
|
||
"scattermapbox": [
|
||
{
|
||
"marker": {
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
}
|
||
},
|
||
"type": "scattermapbox"
|
||
}
|
||
],
|
||
"scatterpolar": [
|
||
{
|
||
"marker": {
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
}
|
||
},
|
||
"type": "scatterpolar"
|
||
}
|
||
],
|
||
"scatterpolargl": [
|
||
{
|
||
"marker": {
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
}
|
||
},
|
||
"type": "scatterpolargl"
|
||
}
|
||
],
|
||
"scatterternary": [
|
||
{
|
||
"marker": {
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
}
|
||
},
|
||
"type": "scatterternary"
|
||
}
|
||
],
|
||
"surface": [
|
||
{
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
},
|
||
"colorscale": [
|
||
[
|
||
0.0,
|
||
"#0d0887"
|
||
],
|
||
[
|
||
0.1111111111111111,
|
||
"#46039f"
|
||
],
|
||
[
|
||
0.2222222222222222,
|
||
"#7201a8"
|
||
],
|
||
[
|
||
0.3333333333333333,
|
||
"#9c179e"
|
||
],
|
||
[
|
||
0.4444444444444444,
|
||
"#bd3786"
|
||
],
|
||
[
|
||
0.5555555555555556,
|
||
"#d8576b"
|
||
],
|
||
[
|
||
0.6666666666666666,
|
||
"#ed7953"
|
||
],
|
||
[
|
||
0.7777777777777778,
|
||
"#fb9f3a"
|
||
],
|
||
[
|
||
0.8888888888888888,
|
||
"#fdca26"
|
||
],
|
||
[
|
||
1.0,
|
||
"#f0f921"
|
||
]
|
||
],
|
||
"type": "surface"
|
||
}
|
||
],
|
||
"table": [
|
||
{
|
||
"cells": {
|
||
"fill": {
|
||
"color": "#EBF0F8"
|
||
},
|
||
"line": {
|
||
"color": "white"
|
||
}
|
||
},
|
||
"header": {
|
||
"fill": {
|
||
"color": "#C8D4E3"
|
||
},
|
||
"line": {
|
||
"color": "white"
|
||
}
|
||
},
|
||
"type": "table"
|
||
}
|
||
]
|
||
},
|
||
"layout": {
|
||
"annotationdefaults": {
|
||
"arrowcolor": "#2a3f5f",
|
||
"arrowhead": 0,
|
||
"arrowwidth": 1
|
||
},
|
||
"autotypenumbers": "strict",
|
||
"coloraxis": {
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
}
|
||
},
|
||
"colorscale": {
|
||
"diverging": [
|
||
[
|
||
0,
|
||
"#8e0152"
|
||
],
|
||
[
|
||
0.1,
|
||
"#c51b7d"
|
||
],
|
||
[
|
||
0.2,
|
||
"#de77ae"
|
||
],
|
||
[
|
||
0.3,
|
||
"#f1b6da"
|
||
],
|
||
[
|
||
0.4,
|
||
"#fde0ef"
|
||
],
|
||
[
|
||
0.5,
|
||
"#f7f7f7"
|
||
],
|
||
[
|
||
0.6,
|
||
"#e6f5d0"
|
||
],
|
||
[
|
||
0.7,
|
||
"#b8e186"
|
||
],
|
||
[
|
||
0.8,
|
||
"#7fbc41"
|
||
],
|
||
[
|
||
0.9,
|
||
"#4d9221"
|
||
],
|
||
[
|
||
1,
|
||
"#276419"
|
||
]
|
||
],
|
||
"sequential": [
|
||
[
|
||
0.0,
|
||
"#0d0887"
|
||
],
|
||
[
|
||
0.1111111111111111,
|
||
"#46039f"
|
||
],
|
||
[
|
||
0.2222222222222222,
|
||
"#7201a8"
|
||
],
|
||
[
|
||
0.3333333333333333,
|
||
"#9c179e"
|
||
],
|
||
[
|
||
0.4444444444444444,
|
||
"#bd3786"
|
||
],
|
||
[
|
||
0.5555555555555556,
|
||
"#d8576b"
|
||
],
|
||
[
|
||
0.6666666666666666,
|
||
"#ed7953"
|
||
],
|
||
[
|
||
0.7777777777777778,
|
||
"#fb9f3a"
|
||
],
|
||
[
|
||
0.8888888888888888,
|
||
"#fdca26"
|
||
],
|
||
[
|
||
1.0,
|
||
"#f0f921"
|
||
]
|
||
],
|
||
"sequentialminus": [
|
||
[
|
||
0.0,
|
||
"#0d0887"
|
||
],
|
||
[
|
||
0.1111111111111111,
|
||
"#46039f"
|
||
],
|
||
[
|
||
0.2222222222222222,
|
||
"#7201a8"
|
||
],
|
||
[
|
||
0.3333333333333333,
|
||
"#9c179e"
|
||
],
|
||
[
|
||
0.4444444444444444,
|
||
"#bd3786"
|
||
],
|
||
[
|
||
0.5555555555555556,
|
||
"#d8576b"
|
||
],
|
||
[
|
||
0.6666666666666666,
|
||
"#ed7953"
|
||
],
|
||
[
|
||
0.7777777777777778,
|
||
"#fb9f3a"
|
||
],
|
||
[
|
||
0.8888888888888888,
|
||
"#fdca26"
|
||
],
|
||
[
|
||
1.0,
|
||
"#f0f921"
|
||
]
|
||
]
|
||
},
|
||
"colorway": [
|
||
"#636efa",
|
||
"#EF553B",
|
||
"#00cc96",
|
||
"#ab63fa",
|
||
"#FFA15A",
|
||
"#19d3f3",
|
||
"#FF6692",
|
||
"#B6E880",
|
||
"#FF97FF",
|
||
"#FECB52"
|
||
],
|
||
"font": {
|
||
"color": "#2a3f5f"
|
||
},
|
||
"geo": {
|
||
"bgcolor": "white",
|
||
"lakecolor": "white",
|
||
"landcolor": "#E5ECF6",
|
||
"showlakes": true,
|
||
"showland": true,
|
||
"subunitcolor": "white"
|
||
},
|
||
"hoverlabel": {
|
||
"align": "left"
|
||
},
|
||
"hovermode": "closest",
|
||
"mapbox": {
|
||
"style": "light"
|
||
},
|
||
"paper_bgcolor": "white",
|
||
"plot_bgcolor": "#E5ECF6",
|
||
"polar": {
|
||
"angularaxis": {
|
||
"gridcolor": "white",
|
||
"linecolor": "white",
|
||
"ticks": ""
|
||
},
|
||
"bgcolor": "#E5ECF6",
|
||
"radialaxis": {
|
||
"gridcolor": "white",
|
||
"linecolor": "white",
|
||
"ticks": ""
|
||
}
|
||
},
|
||
"scene": {
|
||
"xaxis": {
|
||
"backgroundcolor": "#E5ECF6",
|
||
"gridcolor": "white",
|
||
"gridwidth": 2,
|
||
"linecolor": "white",
|
||
"showbackground": true,
|
||
"ticks": "",
|
||
"zerolinecolor": "white"
|
||
},
|
||
"yaxis": {
|
||
"backgroundcolor": "#E5ECF6",
|
||
"gridcolor": "white",
|
||
"gridwidth": 2,
|
||
"linecolor": "white",
|
||
"showbackground": true,
|
||
"ticks": "",
|
||
"zerolinecolor": "white"
|
||
},
|
||
"zaxis": {
|
||
"backgroundcolor": "#E5ECF6",
|
||
"gridcolor": "white",
|
||
"gridwidth": 2,
|
||
"linecolor": "white",
|
||
"showbackground": true,
|
||
"ticks": "",
|
||
"zerolinecolor": "white"
|
||
}
|
||
},
|
||
"shapedefaults": {
|
||
"line": {
|
||
"color": "#2a3f5f"
|
||
}
|
||
},
|
||
"ternary": {
|
||
"aaxis": {
|
||
"gridcolor": "white",
|
||
"linecolor": "white",
|
||
"ticks": ""
|
||
},
|
||
"baxis": {
|
||
"gridcolor": "white",
|
||
"linecolor": "white",
|
||
"ticks": ""
|
||
},
|
||
"bgcolor": "#E5ECF6",
|
||
"caxis": {
|
||
"gridcolor": "white",
|
||
"linecolor": "white",
|
||
"ticks": ""
|
||
}
|
||
},
|
||
"title": {
|
||
"x": 0.05
|
||
},
|
||
"xaxis": {
|
||
"automargin": true,
|
||
"gridcolor": "white",
|
||
"linecolor": "white",
|
||
"ticks": "",
|
||
"title": {
|
||
"standoff": 15
|
||
},
|
||
"zerolinecolor": "white",
|
||
"zerolinewidth": 2
|
||
},
|
||
"yaxis": {
|
||
"automargin": true,
|
||
"gridcolor": "white",
|
||
"linecolor": "white",
|
||
"ticks": "",
|
||
"title": {
|
||
"standoff": 15
|
||
},
|
||
"zerolinecolor": "white",
|
||
"zerolinewidth": 2
|
||
}
|
||
}
|
||
},
|
||
"title": {
|
||
"font": {
|
||
"color": "#0b0b0b",
|
||
"size": 16
|
||
},
|
||
"text": "<b>Benefits by year (risk-adjusted)</b><br><span style='font-size:12px;color:#898781'>stacked by benefit stream — client overlay</span>",
|
||
"x": 0.02,
|
||
"xanchor": "left"
|
||
},
|
||
"xaxis": {
|
||
"linecolor": "#c3c2b7",
|
||
"showgrid": false,
|
||
"tickfont": {
|
||
"color": "#898781"
|
||
},
|
||
"type": "category"
|
||
},
|
||
"yaxis": {
|
||
"gridcolor": "#e1e0d9",
|
||
"tickfont": {
|
||
"color": "#898781"
|
||
},
|
||
"tickformat": "$~s",
|
||
"zerolinecolor": "#c3c2b7",
|
||
"zerolinewidth": 1.5
|
||
}
|
||
}
|
||
}
|
||
},
|
||
"metadata": {},
|
||
"output_type": "display_data"
|
||
}
|
||
],
|
||
"source": [
|
||
"fig = go.Figure()\n",
|
||
"_x = [str(y) for y in YEARS]\n",
|
||
"for r in client[\"rows\"][\"benefits\"]:\n",
|
||
" fig.add_trace(bar(_x, [r[\"ra_by_year\"][y] for y in YEARS],\n",
|
||
" r[\"label\"], BENEFIT_COLOR[r[\"field_key\"]]))\n",
|
||
"if not _at_default: # composite yearly total as de-emphasized context\n",
|
||
" fig.add_trace(cum_line(_x, [composite[\"benefits_by_year\"][y] for y in YEARS],\n",
|
||
" \"Forrester composite total\", CONTEXT, dash=\"dot\"))\n",
|
||
"fig.update_layout(barmode=\"stack\")\n",
|
||
"tei_layout(fig, \"Benefits by year (risk-adjusted)\",\n",
|
||
" subtitle=\"stacked by benefit stream — client overlay\", height=480)\n",
|
||
"fig.show()\n"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"id": "cell-11",
|
||
"metadata": {},
|
||
"source": [
|
||
"<a id=\"section-4\"></a>\n",
|
||
"## 4 · Costs\n",
|
||
"\n",
|
||
"Three cost lines, risk-adjusted **up** 5–15%. Consumption-priced Amazon\n",
|
||
"Connect usage is ~90% of costs PV — the cost side scales with contact\n",
|
||
"volume, not seats. Implementation carries the only time-0 outlay\n",
|
||
"($1.09M nominal → $1.20M risk-adjusted, undiscounted in the *Initial*\n",
|
||
"column).\n"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": 8,
|
||
"id": "cell-12",
|
||
"metadata": {
|
||
"execution": {
|
||
"iopub.execute_input": "2026-07-09T18:15:43.257431Z",
|
||
"iopub.status.busy": "2026-07-09T18:15:43.257260Z",
|
||
"iopub.status.idle": "2026-07-09T18:15:43.268554Z",
|
||
"shell.execute_reply": "2026-07-09T18:15:43.267833Z"
|
||
}
|
||
},
|
||
"outputs": [
|
||
{
|
||
"data": {
|
||
"text/html": [
|
||
"<div>\n",
|
||
"<style scoped>\n",
|
||
" .dataframe tbody tr th:only-of-type {\n",
|
||
" vertical-align: middle;\n",
|
||
" }\n",
|
||
"\n",
|
||
" .dataframe tbody tr th {\n",
|
||
" vertical-align: top;\n",
|
||
" }\n",
|
||
"\n",
|
||
" .dataframe thead th {\n",
|
||
" text-align: right;\n",
|
||
" }\n",
|
||
"</style>\n",
|
||
"<table border=\"1\" class=\"dataframe\">\n",
|
||
" <thead>\n",
|
||
" <tr style=\"text-align: right;\">\n",
|
||
" <th></th>\n",
|
||
" <th>Driver</th>\n",
|
||
" <th>Risk adj</th>\n",
|
||
" <th>Initial</th>\n",
|
||
" <th>2026</th>\n",
|
||
" <th>2027</th>\n",
|
||
" <th>2028</th>\n",
|
||
" <th>PV</th>\n",
|
||
" </tr>\n",
|
||
" <tr>\n",
|
||
" <th>Cost</th>\n",
|
||
" <th></th>\n",
|
||
" <th></th>\n",
|
||
" <th></th>\n",
|
||
" <th></th>\n",
|
||
" <th></th>\n",
|
||
" <th></th>\n",
|
||
" <th></th>\n",
|
||
" </tr>\n",
|
||
" </thead>\n",
|
||
" <tbody>\n",
|
||
" <tr>\n",
|
||
" <th>Amazon Connect usage cost</th>\n",
|
||
" <td>contacts</td>\n",
|
||
" <td>+5%</td>\n",
|
||
" <td>0</td>\n",
|
||
" <td>6,779,270</td>\n",
|
||
" <td>8,348,722</td>\n",
|
||
" <td>10,324,609</td>\n",
|
||
" <td>20,819,775</td>\n",
|
||
" </tr>\n",
|
||
" <tr>\n",
|
||
" <th>Implementation and migration cost</th>\n",
|
||
" <td>fixed</td>\n",
|
||
" <td>+10%</td>\n",
|
||
" <td>1,196,250</td>\n",
|
||
" <td>207,166</td>\n",
|
||
" <td>207,166</td>\n",
|
||
" <td>0</td>\n",
|
||
" <td>1,555,795</td>\n",
|
||
" </tr>\n",
|
||
" <tr>\n",
|
||
" <th>Ongoing management</th>\n",
|
||
" <td>fixed</td>\n",
|
||
" <td>+15%</td>\n",
|
||
" <td>0</td>\n",
|
||
" <td>294,630</td>\n",
|
||
" <td>215,280</td>\n",
|
||
" <td>215,280</td>\n",
|
||
" <td>607,506</td>\n",
|
||
" </tr>\n",
|
||
" <tr>\n",
|
||
" <th>TOTAL</th>\n",
|
||
" <td></td>\n",
|
||
" <td></td>\n",
|
||
" <td>1,196,250</td>\n",
|
||
" <td>7,281,067</td>\n",
|
||
" <td>8,771,168</td>\n",
|
||
" <td>10,539,889</td>\n",
|
||
" <td>22,983,076</td>\n",
|
||
" </tr>\n",
|
||
" </tbody>\n",
|
||
"</table>\n",
|
||
"</div>"
|
||
],
|
||
"text/plain": [
|
||
" Driver Risk adj Initial 2026 \\\n",
|
||
"Cost \n",
|
||
"Amazon Connect usage cost contacts +5% 0 6,779,270 \n",
|
||
"Implementation and migration cost fixed +10% 1,196,250 207,166 \n",
|
||
"Ongoing management fixed +15% 0 294,630 \n",
|
||
"TOTAL 1,196,250 7,281,067 \n",
|
||
"\n",
|
||
" 2027 2028 PV \n",
|
||
"Cost \n",
|
||
"Amazon Connect usage cost 8,348,722 10,324,609 20,819,775 \n",
|
||
"Implementation and migration cost 207,166 0 1,555,795 \n",
|
||
"Ongoing management 215,280 215,280 607,506 \n",
|
||
"TOTAL 8,771,168 10,539,889 22,983,076 "
|
||
]
|
||
},
|
||
"metadata": {},
|
||
"output_type": "display_data"
|
||
}
|
||
],
|
||
"source": [
|
||
"# ── Costs table — client overlay, risk-adjusted ──────────────────────\n",
|
||
"_cost_rows = client[\"rows\"][\"costs\"]\n",
|
||
"costs_df = pd.DataFrame([{\n",
|
||
" \"Cost\": r[\"label\"],\n",
|
||
" \"Driver\": COST_DRIVERS[r[\"field_key\"]],\n",
|
||
" \"Risk adj\": f\"+{r['risk_adjustment']:.0%}\",\n",
|
||
" \"Initial\": r[\"initial_ra\"],\n",
|
||
" **{str(y): r[\"ra_by_year\"][y] for y in YEARS},\n",
|
||
" \"PV\": r[\"pv\"],\n",
|
||
"} for r in _cost_rows]).set_index(\"Cost\")\n",
|
||
"costs_df.loc[\"TOTAL\"] = [\"\", \"\", client[\"initial_costs\"]] \\\n",
|
||
" + [client[\"costs_by_year\"][y] for y in YEARS] + [client[\"costs_pv\"]]\n",
|
||
"if not _at_default:\n",
|
||
" costs_df[\"Composite PV 🟢\"] = \\\n",
|
||
" [r[\"pv\"] for r in composite[\"rows\"][\"costs\"]] + [composite[\"costs_pv\"]]\n",
|
||
"display(costs_df)\n"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": 9,
|
||
"id": "cell-13",
|
||
"metadata": {
|
||
"execution": {
|
||
"iopub.execute_input": "2026-07-09T18:15:43.270331Z",
|
||
"iopub.status.busy": "2026-07-09T18:15:43.270194Z",
|
||
"iopub.status.idle": "2026-07-09T18:15:43.287522Z",
|
||
"shell.execute_reply": "2026-07-09T18:15:43.286918Z"
|
||
}
|
||
},
|
||
"outputs": [
|
||
{
|
||
"data": {
|
||
"application/vnd.plotly.v1+json": {
|
||
"config": {
|
||
"plotlyServerURL": "https://plot.ly"
|
||
},
|
||
"data": [
|
||
{
|
||
"hovertemplate": "%{fullData.name}: %{y:$,.0f}<extra></extra>",
|
||
"marker": {
|
||
"color": "#e34948",
|
||
"line": {
|
||
"color": "#fcfcfb",
|
||
"width": 2
|
||
}
|
||
},
|
||
"name": "Amazon Connect usage cost",
|
||
"type": "bar",
|
||
"x": [
|
||
"Initial",
|
||
"2026",
|
||
"2027",
|
||
"2028"
|
||
],
|
||
"y": [
|
||
0.0,
|
||
6779270.4,
|
||
8348722.2,
|
||
10324609.05
|
||
]
|
||
},
|
||
{
|
||
"hovertemplate": "%{fullData.name}: %{y:$,.0f}<extra></extra>",
|
||
"marker": {
|
||
"color": "#eda100",
|
||
"line": {
|
||
"color": "#fcfcfb",
|
||
"width": 2
|
||
}
|
||
},
|
||
"name": "Implementation and migration cost",
|
||
"type": "bar",
|
||
"x": [
|
||
"Initial",
|
||
"2026",
|
||
"2027",
|
||
"2028"
|
||
],
|
||
"y": [
|
||
1196250.0,
|
||
207166.30000000002,
|
||
207166.30000000002,
|
||
0.0
|
||
]
|
||
},
|
||
{
|
||
"hovertemplate": "%{fullData.name}: %{y:$,.0f}<extra></extra>",
|
||
"marker": {
|
||
"color": "#4a3aa7",
|
||
"line": {
|
||
"color": "#fcfcfb",
|
||
"width": 2
|
||
}
|
||
},
|
||
"name": "Ongoing management",
|
||
"type": "bar",
|
||
"x": [
|
||
"Initial",
|
||
"2026",
|
||
"2027",
|
||
"2028"
|
||
],
|
||
"y": [
|
||
0.0,
|
||
294630.0,
|
||
215279.99999999997,
|
||
215279.99999999997
|
||
]
|
||
}
|
||
],
|
||
"layout": {
|
||
"bargap": 0.45,
|
||
"barmode": "stack",
|
||
"font": {
|
||
"color": "#52514e",
|
||
"family": "system-ui, -apple-system, \"Segoe UI\", sans-serif",
|
||
"size": 12
|
||
},
|
||
"height": 460,
|
||
"hovermode": "x unified",
|
||
"legend": {
|
||
"font": {
|
||
"color": "#52514e",
|
||
"size": 11
|
||
},
|
||
"orientation": "h",
|
||
"x": 0,
|
||
"y": -0.1,
|
||
"yanchor": "top"
|
||
},
|
||
"margin": {
|
||
"b": 80,
|
||
"l": 70,
|
||
"r": 30,
|
||
"t": 70
|
||
},
|
||
"paper_bgcolor": "#fcfcfb",
|
||
"plot_bgcolor": "#fcfcfb",
|
||
"template": {
|
||
"data": {
|
||
"bar": [
|
||
{
|
||
"error_x": {
|
||
"color": "#2a3f5f"
|
||
},
|
||
"error_y": {
|
||
"color": "#2a3f5f"
|
||
},
|
||
"marker": {
|
||
"line": {
|
||
"color": "#E5ECF6",
|
||
"width": 0.5
|
||
},
|
||
"pattern": {
|
||
"fillmode": "overlay",
|
||
"size": 10,
|
||
"solidity": 0.2
|
||
}
|
||
},
|
||
"type": "bar"
|
||
}
|
||
],
|
||
"barpolar": [
|
||
{
|
||
"marker": {
|
||
"line": {
|
||
"color": "#E5ECF6",
|
||
"width": 0.5
|
||
},
|
||
"pattern": {
|
||
"fillmode": "overlay",
|
||
"size": 10,
|
||
"solidity": 0.2
|
||
}
|
||
},
|
||
"type": "barpolar"
|
||
}
|
||
],
|
||
"carpet": [
|
||
{
|
||
"aaxis": {
|
||
"endlinecolor": "#2a3f5f",
|
||
"gridcolor": "white",
|
||
"linecolor": "white",
|
||
"minorgridcolor": "white",
|
||
"startlinecolor": "#2a3f5f"
|
||
},
|
||
"baxis": {
|
||
"endlinecolor": "#2a3f5f",
|
||
"gridcolor": "white",
|
||
"linecolor": "white",
|
||
"minorgridcolor": "white",
|
||
"startlinecolor": "#2a3f5f"
|
||
},
|
||
"type": "carpet"
|
||
}
|
||
],
|
||
"choropleth": [
|
||
{
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
},
|
||
"type": "choropleth"
|
||
}
|
||
],
|
||
"contour": [
|
||
{
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
},
|
||
"colorscale": [
|
||
[
|
||
0.0,
|
||
"#0d0887"
|
||
],
|
||
[
|
||
0.1111111111111111,
|
||
"#46039f"
|
||
],
|
||
[
|
||
0.2222222222222222,
|
||
"#7201a8"
|
||
],
|
||
[
|
||
0.3333333333333333,
|
||
"#9c179e"
|
||
],
|
||
[
|
||
0.4444444444444444,
|
||
"#bd3786"
|
||
],
|
||
[
|
||
0.5555555555555556,
|
||
"#d8576b"
|
||
],
|
||
[
|
||
0.6666666666666666,
|
||
"#ed7953"
|
||
],
|
||
[
|
||
0.7777777777777778,
|
||
"#fb9f3a"
|
||
],
|
||
[
|
||
0.8888888888888888,
|
||
"#fdca26"
|
||
],
|
||
[
|
||
1.0,
|
||
"#f0f921"
|
||
]
|
||
],
|
||
"type": "contour"
|
||
}
|
||
],
|
||
"contourcarpet": [
|
||
{
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
},
|
||
"type": "contourcarpet"
|
||
}
|
||
],
|
||
"heatmap": [
|
||
{
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
},
|
||
"colorscale": [
|
||
[
|
||
0.0,
|
||
"#0d0887"
|
||
],
|
||
[
|
||
0.1111111111111111,
|
||
"#46039f"
|
||
],
|
||
[
|
||
0.2222222222222222,
|
||
"#7201a8"
|
||
],
|
||
[
|
||
0.3333333333333333,
|
||
"#9c179e"
|
||
],
|
||
[
|
||
0.4444444444444444,
|
||
"#bd3786"
|
||
],
|
||
[
|
||
0.5555555555555556,
|
||
"#d8576b"
|
||
],
|
||
[
|
||
0.6666666666666666,
|
||
"#ed7953"
|
||
],
|
||
[
|
||
0.7777777777777778,
|
||
"#fb9f3a"
|
||
],
|
||
[
|
||
0.8888888888888888,
|
||
"#fdca26"
|
||
],
|
||
[
|
||
1.0,
|
||
"#f0f921"
|
||
]
|
||
],
|
||
"type": "heatmap"
|
||
}
|
||
],
|
||
"histogram": [
|
||
{
|
||
"marker": {
|
||
"pattern": {
|
||
"fillmode": "overlay",
|
||
"size": 10,
|
||
"solidity": 0.2
|
||
}
|
||
},
|
||
"type": "histogram"
|
||
}
|
||
],
|
||
"histogram2d": [
|
||
{
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
},
|
||
"colorscale": [
|
||
[
|
||
0.0,
|
||
"#0d0887"
|
||
],
|
||
[
|
||
0.1111111111111111,
|
||
"#46039f"
|
||
],
|
||
[
|
||
0.2222222222222222,
|
||
"#7201a8"
|
||
],
|
||
[
|
||
0.3333333333333333,
|
||
"#9c179e"
|
||
],
|
||
[
|
||
0.4444444444444444,
|
||
"#bd3786"
|
||
],
|
||
[
|
||
0.5555555555555556,
|
||
"#d8576b"
|
||
],
|
||
[
|
||
0.6666666666666666,
|
||
"#ed7953"
|
||
],
|
||
[
|
||
0.7777777777777778,
|
||
"#fb9f3a"
|
||
],
|
||
[
|
||
0.8888888888888888,
|
||
"#fdca26"
|
||
],
|
||
[
|
||
1.0,
|
||
"#f0f921"
|
||
]
|
||
],
|
||
"type": "histogram2d"
|
||
}
|
||
],
|
||
"histogram2dcontour": [
|
||
{
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
},
|
||
"colorscale": [
|
||
[
|
||
0.0,
|
||
"#0d0887"
|
||
],
|
||
[
|
||
0.1111111111111111,
|
||
"#46039f"
|
||
],
|
||
[
|
||
0.2222222222222222,
|
||
"#7201a8"
|
||
],
|
||
[
|
||
0.3333333333333333,
|
||
"#9c179e"
|
||
],
|
||
[
|
||
0.4444444444444444,
|
||
"#bd3786"
|
||
],
|
||
[
|
||
0.5555555555555556,
|
||
"#d8576b"
|
||
],
|
||
[
|
||
0.6666666666666666,
|
||
"#ed7953"
|
||
],
|
||
[
|
||
0.7777777777777778,
|
||
"#fb9f3a"
|
||
],
|
||
[
|
||
0.8888888888888888,
|
||
"#fdca26"
|
||
],
|
||
[
|
||
1.0,
|
||
"#f0f921"
|
||
]
|
||
],
|
||
"type": "histogram2dcontour"
|
||
}
|
||
],
|
||
"mesh3d": [
|
||
{
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
},
|
||
"type": "mesh3d"
|
||
}
|
||
],
|
||
"parcoords": [
|
||
{
|
||
"line": {
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
}
|
||
},
|
||
"type": "parcoords"
|
||
}
|
||
],
|
||
"pie": [
|
||
{
|
||
"automargin": true,
|
||
"type": "pie"
|
||
}
|
||
],
|
||
"scatter": [
|
||
{
|
||
"fillpattern": {
|
||
"fillmode": "overlay",
|
||
"size": 10,
|
||
"solidity": 0.2
|
||
},
|
||
"type": "scatter"
|
||
}
|
||
],
|
||
"scatter3d": [
|
||
{
|
||
"line": {
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
}
|
||
},
|
||
"marker": {
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
}
|
||
},
|
||
"type": "scatter3d"
|
||
}
|
||
],
|
||
"scattercarpet": [
|
||
{
|
||
"marker": {
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
}
|
||
},
|
||
"type": "scattercarpet"
|
||
}
|
||
],
|
||
"scattergeo": [
|
||
{
|
||
"marker": {
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
}
|
||
},
|
||
"type": "scattergeo"
|
||
}
|
||
],
|
||
"scattergl": [
|
||
{
|
||
"marker": {
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
}
|
||
},
|
||
"type": "scattergl"
|
||
}
|
||
],
|
||
"scattermap": [
|
||
{
|
||
"marker": {
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
}
|
||
},
|
||
"type": "scattermap"
|
||
}
|
||
],
|
||
"scattermapbox": [
|
||
{
|
||
"marker": {
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
}
|
||
},
|
||
"type": "scattermapbox"
|
||
}
|
||
],
|
||
"scatterpolar": [
|
||
{
|
||
"marker": {
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
}
|
||
},
|
||
"type": "scatterpolar"
|
||
}
|
||
],
|
||
"scatterpolargl": [
|
||
{
|
||
"marker": {
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
}
|
||
},
|
||
"type": "scatterpolargl"
|
||
}
|
||
],
|
||
"scatterternary": [
|
||
{
|
||
"marker": {
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
}
|
||
},
|
||
"type": "scatterternary"
|
||
}
|
||
],
|
||
"surface": [
|
||
{
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
},
|
||
"colorscale": [
|
||
[
|
||
0.0,
|
||
"#0d0887"
|
||
],
|
||
[
|
||
0.1111111111111111,
|
||
"#46039f"
|
||
],
|
||
[
|
||
0.2222222222222222,
|
||
"#7201a8"
|
||
],
|
||
[
|
||
0.3333333333333333,
|
||
"#9c179e"
|
||
],
|
||
[
|
||
0.4444444444444444,
|
||
"#bd3786"
|
||
],
|
||
[
|
||
0.5555555555555556,
|
||
"#d8576b"
|
||
],
|
||
[
|
||
0.6666666666666666,
|
||
"#ed7953"
|
||
],
|
||
[
|
||
0.7777777777777778,
|
||
"#fb9f3a"
|
||
],
|
||
[
|
||
0.8888888888888888,
|
||
"#fdca26"
|
||
],
|
||
[
|
||
1.0,
|
||
"#f0f921"
|
||
]
|
||
],
|
||
"type": "surface"
|
||
}
|
||
],
|
||
"table": [
|
||
{
|
||
"cells": {
|
||
"fill": {
|
||
"color": "#EBF0F8"
|
||
},
|
||
"line": {
|
||
"color": "white"
|
||
}
|
||
},
|
||
"header": {
|
||
"fill": {
|
||
"color": "#C8D4E3"
|
||
},
|
||
"line": {
|
||
"color": "white"
|
||
}
|
||
},
|
||
"type": "table"
|
||
}
|
||
]
|
||
},
|
||
"layout": {
|
||
"annotationdefaults": {
|
||
"arrowcolor": "#2a3f5f",
|
||
"arrowhead": 0,
|
||
"arrowwidth": 1
|
||
},
|
||
"autotypenumbers": "strict",
|
||
"coloraxis": {
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
}
|
||
},
|
||
"colorscale": {
|
||
"diverging": [
|
||
[
|
||
0,
|
||
"#8e0152"
|
||
],
|
||
[
|
||
0.1,
|
||
"#c51b7d"
|
||
],
|
||
[
|
||
0.2,
|
||
"#de77ae"
|
||
],
|
||
[
|
||
0.3,
|
||
"#f1b6da"
|
||
],
|
||
[
|
||
0.4,
|
||
"#fde0ef"
|
||
],
|
||
[
|
||
0.5,
|
||
"#f7f7f7"
|
||
],
|
||
[
|
||
0.6,
|
||
"#e6f5d0"
|
||
],
|
||
[
|
||
0.7,
|
||
"#b8e186"
|
||
],
|
||
[
|
||
0.8,
|
||
"#7fbc41"
|
||
],
|
||
[
|
||
0.9,
|
||
"#4d9221"
|
||
],
|
||
[
|
||
1,
|
||
"#276419"
|
||
]
|
||
],
|
||
"sequential": [
|
||
[
|
||
0.0,
|
||
"#0d0887"
|
||
],
|
||
[
|
||
0.1111111111111111,
|
||
"#46039f"
|
||
],
|
||
[
|
||
0.2222222222222222,
|
||
"#7201a8"
|
||
],
|
||
[
|
||
0.3333333333333333,
|
||
"#9c179e"
|
||
],
|
||
[
|
||
0.4444444444444444,
|
||
"#bd3786"
|
||
],
|
||
[
|
||
0.5555555555555556,
|
||
"#d8576b"
|
||
],
|
||
[
|
||
0.6666666666666666,
|
||
"#ed7953"
|
||
],
|
||
[
|
||
0.7777777777777778,
|
||
"#fb9f3a"
|
||
],
|
||
[
|
||
0.8888888888888888,
|
||
"#fdca26"
|
||
],
|
||
[
|
||
1.0,
|
||
"#f0f921"
|
||
]
|
||
],
|
||
"sequentialminus": [
|
||
[
|
||
0.0,
|
||
"#0d0887"
|
||
],
|
||
[
|
||
0.1111111111111111,
|
||
"#46039f"
|
||
],
|
||
[
|
||
0.2222222222222222,
|
||
"#7201a8"
|
||
],
|
||
[
|
||
0.3333333333333333,
|
||
"#9c179e"
|
||
],
|
||
[
|
||
0.4444444444444444,
|
||
"#bd3786"
|
||
],
|
||
[
|
||
0.5555555555555556,
|
||
"#d8576b"
|
||
],
|
||
[
|
||
0.6666666666666666,
|
||
"#ed7953"
|
||
],
|
||
[
|
||
0.7777777777777778,
|
||
"#fb9f3a"
|
||
],
|
||
[
|
||
0.8888888888888888,
|
||
"#fdca26"
|
||
],
|
||
[
|
||
1.0,
|
||
"#f0f921"
|
||
]
|
||
]
|
||
},
|
||
"colorway": [
|
||
"#636efa",
|
||
"#EF553B",
|
||
"#00cc96",
|
||
"#ab63fa",
|
||
"#FFA15A",
|
||
"#19d3f3",
|
||
"#FF6692",
|
||
"#B6E880",
|
||
"#FF97FF",
|
||
"#FECB52"
|
||
],
|
||
"font": {
|
||
"color": "#2a3f5f"
|
||
},
|
||
"geo": {
|
||
"bgcolor": "white",
|
||
"lakecolor": "white",
|
||
"landcolor": "#E5ECF6",
|
||
"showlakes": true,
|
||
"showland": true,
|
||
"subunitcolor": "white"
|
||
},
|
||
"hoverlabel": {
|
||
"align": "left"
|
||
},
|
||
"hovermode": "closest",
|
||
"mapbox": {
|
||
"style": "light"
|
||
},
|
||
"paper_bgcolor": "white",
|
||
"plot_bgcolor": "#E5ECF6",
|
||
"polar": {
|
||
"angularaxis": {
|
||
"gridcolor": "white",
|
||
"linecolor": "white",
|
||
"ticks": ""
|
||
},
|
||
"bgcolor": "#E5ECF6",
|
||
"radialaxis": {
|
||
"gridcolor": "white",
|
||
"linecolor": "white",
|
||
"ticks": ""
|
||
}
|
||
},
|
||
"scene": {
|
||
"xaxis": {
|
||
"backgroundcolor": "#E5ECF6",
|
||
"gridcolor": "white",
|
||
"gridwidth": 2,
|
||
"linecolor": "white",
|
||
"showbackground": true,
|
||
"ticks": "",
|
||
"zerolinecolor": "white"
|
||
},
|
||
"yaxis": {
|
||
"backgroundcolor": "#E5ECF6",
|
||
"gridcolor": "white",
|
||
"gridwidth": 2,
|
||
"linecolor": "white",
|
||
"showbackground": true,
|
||
"ticks": "",
|
||
"zerolinecolor": "white"
|
||
},
|
||
"zaxis": {
|
||
"backgroundcolor": "#E5ECF6",
|
||
"gridcolor": "white",
|
||
"gridwidth": 2,
|
||
"linecolor": "white",
|
||
"showbackground": true,
|
||
"ticks": "",
|
||
"zerolinecolor": "white"
|
||
}
|
||
},
|
||
"shapedefaults": {
|
||
"line": {
|
||
"color": "#2a3f5f"
|
||
}
|
||
},
|
||
"ternary": {
|
||
"aaxis": {
|
||
"gridcolor": "white",
|
||
"linecolor": "white",
|
||
"ticks": ""
|
||
},
|
||
"baxis": {
|
||
"gridcolor": "white",
|
||
"linecolor": "white",
|
||
"ticks": ""
|
||
},
|
||
"bgcolor": "#E5ECF6",
|
||
"caxis": {
|
||
"gridcolor": "white",
|
||
"linecolor": "white",
|
||
"ticks": ""
|
||
}
|
||
},
|
||
"title": {
|
||
"x": 0.05
|
||
},
|
||
"xaxis": {
|
||
"automargin": true,
|
||
"gridcolor": "white",
|
||
"linecolor": "white",
|
||
"ticks": "",
|
||
"title": {
|
||
"standoff": 15
|
||
},
|
||
"zerolinecolor": "white",
|
||
"zerolinewidth": 2
|
||
},
|
||
"yaxis": {
|
||
"automargin": true,
|
||
"gridcolor": "white",
|
||
"linecolor": "white",
|
||
"ticks": "",
|
||
"title": {
|
||
"standoff": 15
|
||
},
|
||
"zerolinecolor": "white",
|
||
"zerolinewidth": 2
|
||
}
|
||
}
|
||
},
|
||
"title": {
|
||
"font": {
|
||
"color": "#0b0b0b",
|
||
"size": 16
|
||
},
|
||
"text": "<b>Costs by year (risk-adjusted)</b><br><span style='font-size:12px;color:#898781'>Initial = undiscounted time-0 outlay (implementation)</span>",
|
||
"x": 0.02,
|
||
"xanchor": "left"
|
||
},
|
||
"xaxis": {
|
||
"linecolor": "#c3c2b7",
|
||
"showgrid": false,
|
||
"tickfont": {
|
||
"color": "#898781"
|
||
},
|
||
"type": "category"
|
||
},
|
||
"yaxis": {
|
||
"gridcolor": "#e1e0d9",
|
||
"tickfont": {
|
||
"color": "#898781"
|
||
},
|
||
"tickformat": "$~s",
|
||
"zerolinecolor": "#c3c2b7",
|
||
"zerolinewidth": 1.5
|
||
}
|
||
}
|
||
}
|
||
},
|
||
"metadata": {},
|
||
"output_type": "display_data"
|
||
}
|
||
],
|
||
"source": [
|
||
"fig = go.Figure()\n",
|
||
"for r in client[\"rows\"][\"costs\"]:\n",
|
||
" fig.add_trace(bar(X_LABELS,\n",
|
||
" [r[\"initial_ra\"]] + [r[\"ra_by_year\"][y] for y in YEARS],\n",
|
||
" r[\"label\"], COST_COLOR[r[\"field_key\"]]))\n",
|
||
"fig.update_layout(barmode=\"stack\")\n",
|
||
"tei_layout(fig, \"Costs by year (risk-adjusted)\",\n",
|
||
" subtitle=\"Initial = undiscounted time-0 outlay (implementation)\")\n",
|
||
"fig.show()\n"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"id": "cell-14",
|
||
"metadata": {},
|
||
"source": [
|
||
"<a id=\"section-5\"></a>\n",
|
||
"## 5 · Business case\n",
|
||
"\n",
|
||
"The left column is Forrester's published Financial Summary, verbatim. The\n",
|
||
"right column is the client overlay at the sidebar's drivers. At the\n",
|
||
"defaults the engine reproduces the published totals to within the PDF's\n",
|
||
"own table rounding — the gate in §7 enforces it. Payback lands under a\n",
|
||
"month because the composite's $1.2M initial outlay is small against\n",
|
||
"~$20M of year-1 net benefit; Forrester publishes it simply as\n",
|
||
"\"<6 months\", and sub-month precision is a full-year-aggregation artifact,\n",
|
||
"not a forecast.\n"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": 10,
|
||
"id": "cell-15",
|
||
"metadata": {
|
||
"execution": {
|
||
"iopub.execute_input": "2026-07-09T18:15:43.291216Z",
|
||
"iopub.status.busy": "2026-07-09T18:15:43.291061Z",
|
||
"iopub.status.idle": "2026-07-09T18:15:43.298783Z",
|
||
"shell.execute_reply": "2026-07-09T18:15:43.298192Z"
|
||
}
|
||
},
|
||
"outputs": [
|
||
{
|
||
"data": {
|
||
"text/html": [
|
||
"<div>\n",
|
||
"<style scoped>\n",
|
||
" .dataframe tbody tr th:only-of-type {\n",
|
||
" vertical-align: middle;\n",
|
||
" }\n",
|
||
"\n",
|
||
" .dataframe tbody tr th {\n",
|
||
" vertical-align: top;\n",
|
||
" }\n",
|
||
"\n",
|
||
" .dataframe thead th {\n",
|
||
" text-align: right;\n",
|
||
" }\n",
|
||
"</style>\n",
|
||
"<table border=\"1\" class=\"dataframe\">\n",
|
||
" <thead>\n",
|
||
" <tr style=\"text-align: right;\">\n",
|
||
" <th></th>\n",
|
||
" <th>Forrester composite (published 🟢)</th>\n",
|
||
" <th>Client overlay (🟡)</th>\n",
|
||
" </tr>\n",
|
||
" </thead>\n",
|
||
" <tbody>\n",
|
||
" <tr>\n",
|
||
" <th>Benefits PV</th>\n",
|
||
" <td>$101,696,791</td>\n",
|
||
" <td>$101,696,568</td>\n",
|
||
" </tr>\n",
|
||
" <tr>\n",
|
||
" <th>Costs PV</th>\n",
|
||
" <td>$22,983,076</td>\n",
|
||
" <td>$22,983,076</td>\n",
|
||
" </tr>\n",
|
||
" <tr>\n",
|
||
" <th>NPV</th>\n",
|
||
" <td>$78,713,715</td>\n",
|
||
" <td>$78,713,492</td>\n",
|
||
" </tr>\n",
|
||
" <tr>\n",
|
||
" <th>ROI</th>\n",
|
||
" <td>342%</td>\n",
|
||
" <td>342%</td>\n",
|
||
" </tr>\n",
|
||
" <tr>\n",
|
||
" <th>Payback</th>\n",
|
||
" <td><6 months</td>\n",
|
||
" <td>0.7 months (~Jan 2026)</td>\n",
|
||
" </tr>\n",
|
||
" <tr>\n",
|
||
" <th>Discount rate</th>\n",
|
||
" <td>10%</td>\n",
|
||
" <td>10%</td>\n",
|
||
" </tr>\n",
|
||
" </tbody>\n",
|
||
"</table>\n",
|
||
"</div>"
|
||
],
|
||
"text/plain": [
|
||
" Forrester composite (published 🟢) Client overlay (🟡)\n",
|
||
"Benefits PV $101,696,791 $101,696,568\n",
|
||
"Costs PV $22,983,076 $22,983,076\n",
|
||
"NPV $78,713,715 $78,713,492\n",
|
||
"ROI 342% 342%\n",
|
||
"Payback <6 months 0.7 months (~Jan 2026)\n",
|
||
"Discount rate 10% 10%"
|
||
]
|
||
},
|
||
"metadata": {},
|
||
"output_type": "display_data"
|
||
},
|
||
{
|
||
"name": "stdout",
|
||
"output_type": "stream",
|
||
"text": [
|
||
"engine composite NPV $78,713,491.78 vs published $78,713,715 (Δ -223.22)\n"
|
||
]
|
||
}
|
||
],
|
||
"source": [
|
||
"# ── KPIs — published composite beside the client overlay ─────────────\n",
|
||
"kpis_fmt = pd.DataFrame({\n",
|
||
" \"Forrester composite (published 🟢)\": {\n",
|
||
" \"Benefits PV\": f\"${PUBLISHED['benefits_pv']:,}\",\n",
|
||
" \"Costs PV\": f\"${PUBLISHED['costs_pv']:,}\",\n",
|
||
" \"NPV\": f\"${PUBLISHED['npv']:,}\",\n",
|
||
" \"ROI\": f\"{PUBLISHED['roi_pct']}%\",\n",
|
||
" \"Payback\": \"<6 months\",\n",
|
||
" \"Discount rate\": f\"{PUBLISHED['discount_rate']:.0%}\",\n",
|
||
" },\n",
|
||
" \"Client overlay (🟡)\": {\n",
|
||
" \"Benefits PV\": f\"${client['benefits_pv']:,.0f}\",\n",
|
||
" \"Costs PV\": f\"${client['costs_pv']:,.0f}\",\n",
|
||
" \"NPV\": f\"${client['npv']:,.0f}\",\n",
|
||
" \"ROI\": f\"{client['roi_pct']:.0f}%\",\n",
|
||
" \"Payback\": client[\"payback_label\"],\n",
|
||
" \"Discount rate\": f\"{DISCOUNT_RATE:.0%}\",\n",
|
||
" },\n",
|
||
"})\n",
|
||
"display(kpis_fmt)\n",
|
||
"backstage(f\"engine composite NPV ${composite['npv']:,.2f} vs published \"\n",
|
||
" f\"${PUBLISHED['npv']:,} (Δ {composite['npv'] - PUBLISHED['npv']:+,.2f})\")\n"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": 11,
|
||
"id": "cell-16",
|
||
"metadata": {
|
||
"execution": {
|
||
"iopub.execute_input": "2026-07-09T18:15:43.302934Z",
|
||
"iopub.status.busy": "2026-07-09T18:15:43.302786Z",
|
||
"iopub.status.idle": "2026-07-09T18:15:43.324633Z",
|
||
"shell.execute_reply": "2026-07-09T18:15:43.324079Z"
|
||
}
|
||
},
|
||
"outputs": [
|
||
{
|
||
"data": {
|
||
"application/vnd.plotly.v1+json": {
|
||
"config": {
|
||
"plotlyServerURL": "https://plot.ly"
|
||
},
|
||
"data": [
|
||
{
|
||
"hovertemplate": "%{fullData.name}: %{y:$,.0f}<extra></extra>",
|
||
"marker": {
|
||
"color": "#1baf7a",
|
||
"line": {
|
||
"color": "#fcfcfb",
|
||
"width": 2
|
||
}
|
||
},
|
||
"name": "Benefits (risk-adjusted)",
|
||
"type": "bar",
|
||
"x": [
|
||
"Initial",
|
||
"2026",
|
||
"2027",
|
||
"2028"
|
||
],
|
||
"y": [
|
||
0,
|
||
27279019.0,
|
||
40333658.2,
|
||
57983494.400000006
|
||
]
|
||
},
|
||
{
|
||
"hovertemplate": "%{fullData.name}: %{y:$,.0f}<extra></extra>",
|
||
"marker": {
|
||
"color": "#e34948",
|
||
"line": {
|
||
"color": "#fcfcfb",
|
||
"width": 2
|
||
}
|
||
},
|
||
"name": "Costs (risk-adjusted)",
|
||
"type": "bar",
|
||
"x": [
|
||
"Initial",
|
||
"2026",
|
||
"2027",
|
||
"2028"
|
||
],
|
||
"y": [
|
||
-1196250.0,
|
||
-7281066.7,
|
||
-8771168.5,
|
||
-10539889.05
|
||
]
|
||
},
|
||
{
|
||
"hovertemplate": "%{fullData.name}: %{y:$,.0f}<extra></extra>",
|
||
"line": {
|
||
"color": "#52514e",
|
||
"width": 2
|
||
},
|
||
"marker": {
|
||
"line": {
|
||
"color": "#fcfcfb",
|
||
"width": 2
|
||
},
|
||
"size": 8
|
||
},
|
||
"mode": "lines+markers",
|
||
"name": "Cumulative net",
|
||
"type": "scatter",
|
||
"x": [
|
||
"Initial",
|
||
"2026",
|
||
"2027",
|
||
"2028"
|
||
],
|
||
"y": [
|
||
-1196250.0,
|
||
18801702.3,
|
||
50364192.0,
|
||
97807797.35000001
|
||
]
|
||
}
|
||
],
|
||
"layout": {
|
||
"annotations": [
|
||
{
|
||
"align": "left",
|
||
"bgcolor": "#fcfcfb",
|
||
"bordercolor": "#e1e0d9",
|
||
"borderwidth": 1,
|
||
"font": {
|
||
"color": "#0b0b0b",
|
||
"size": 12
|
||
},
|
||
"showarrow": false,
|
||
"text": "NPV $78.7M · ROI 342% · payback 0.7 months (~Jan 2026)",
|
||
"x": 0.02,
|
||
"xref": "paper",
|
||
"y": 0.98,
|
||
"yref": "paper"
|
||
}
|
||
],
|
||
"bargap": 0.45,
|
||
"barmode": "relative",
|
||
"font": {
|
||
"color": "#52514e",
|
||
"family": "system-ui, -apple-system, \"Segoe UI\", sans-serif",
|
||
"size": 12
|
||
},
|
||
"height": 460,
|
||
"hovermode": "x unified",
|
||
"legend": {
|
||
"font": {
|
||
"color": "#52514e",
|
||
"size": 11
|
||
},
|
||
"orientation": "h",
|
||
"x": 0,
|
||
"y": -0.1,
|
||
"yanchor": "top"
|
||
},
|
||
"margin": {
|
||
"b": 80,
|
||
"l": 70,
|
||
"r": 30,
|
||
"t": 70
|
||
},
|
||
"paper_bgcolor": "#fcfcfb",
|
||
"plot_bgcolor": "#fcfcfb",
|
||
"template": {
|
||
"data": {
|
||
"bar": [
|
||
{
|
||
"error_x": {
|
||
"color": "#2a3f5f"
|
||
},
|
||
"error_y": {
|
||
"color": "#2a3f5f"
|
||
},
|
||
"marker": {
|
||
"line": {
|
||
"color": "#E5ECF6",
|
||
"width": 0.5
|
||
},
|
||
"pattern": {
|
||
"fillmode": "overlay",
|
||
"size": 10,
|
||
"solidity": 0.2
|
||
}
|
||
},
|
||
"type": "bar"
|
||
}
|
||
],
|
||
"barpolar": [
|
||
{
|
||
"marker": {
|
||
"line": {
|
||
"color": "#E5ECF6",
|
||
"width": 0.5
|
||
},
|
||
"pattern": {
|
||
"fillmode": "overlay",
|
||
"size": 10,
|
||
"solidity": 0.2
|
||
}
|
||
},
|
||
"type": "barpolar"
|
||
}
|
||
],
|
||
"carpet": [
|
||
{
|
||
"aaxis": {
|
||
"endlinecolor": "#2a3f5f",
|
||
"gridcolor": "white",
|
||
"linecolor": "white",
|
||
"minorgridcolor": "white",
|
||
"startlinecolor": "#2a3f5f"
|
||
},
|
||
"baxis": {
|
||
"endlinecolor": "#2a3f5f",
|
||
"gridcolor": "white",
|
||
"linecolor": "white",
|
||
"minorgridcolor": "white",
|
||
"startlinecolor": "#2a3f5f"
|
||
},
|
||
"type": "carpet"
|
||
}
|
||
],
|
||
"choropleth": [
|
||
{
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
},
|
||
"type": "choropleth"
|
||
}
|
||
],
|
||
"contour": [
|
||
{
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
},
|
||
"colorscale": [
|
||
[
|
||
0.0,
|
||
"#0d0887"
|
||
],
|
||
[
|
||
0.1111111111111111,
|
||
"#46039f"
|
||
],
|
||
[
|
||
0.2222222222222222,
|
||
"#7201a8"
|
||
],
|
||
[
|
||
0.3333333333333333,
|
||
"#9c179e"
|
||
],
|
||
[
|
||
0.4444444444444444,
|
||
"#bd3786"
|
||
],
|
||
[
|
||
0.5555555555555556,
|
||
"#d8576b"
|
||
],
|
||
[
|
||
0.6666666666666666,
|
||
"#ed7953"
|
||
],
|
||
[
|
||
0.7777777777777778,
|
||
"#fb9f3a"
|
||
],
|
||
[
|
||
0.8888888888888888,
|
||
"#fdca26"
|
||
],
|
||
[
|
||
1.0,
|
||
"#f0f921"
|
||
]
|
||
],
|
||
"type": "contour"
|
||
}
|
||
],
|
||
"contourcarpet": [
|
||
{
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
},
|
||
"type": "contourcarpet"
|
||
}
|
||
],
|
||
"heatmap": [
|
||
{
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
},
|
||
"colorscale": [
|
||
[
|
||
0.0,
|
||
"#0d0887"
|
||
],
|
||
[
|
||
0.1111111111111111,
|
||
"#46039f"
|
||
],
|
||
[
|
||
0.2222222222222222,
|
||
"#7201a8"
|
||
],
|
||
[
|
||
0.3333333333333333,
|
||
"#9c179e"
|
||
],
|
||
[
|
||
0.4444444444444444,
|
||
"#bd3786"
|
||
],
|
||
[
|
||
0.5555555555555556,
|
||
"#d8576b"
|
||
],
|
||
[
|
||
0.6666666666666666,
|
||
"#ed7953"
|
||
],
|
||
[
|
||
0.7777777777777778,
|
||
"#fb9f3a"
|
||
],
|
||
[
|
||
0.8888888888888888,
|
||
"#fdca26"
|
||
],
|
||
[
|
||
1.0,
|
||
"#f0f921"
|
||
]
|
||
],
|
||
"type": "heatmap"
|
||
}
|
||
],
|
||
"histogram": [
|
||
{
|
||
"marker": {
|
||
"pattern": {
|
||
"fillmode": "overlay",
|
||
"size": 10,
|
||
"solidity": 0.2
|
||
}
|
||
},
|
||
"type": "histogram"
|
||
}
|
||
],
|
||
"histogram2d": [
|
||
{
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
},
|
||
"colorscale": [
|
||
[
|
||
0.0,
|
||
"#0d0887"
|
||
],
|
||
[
|
||
0.1111111111111111,
|
||
"#46039f"
|
||
],
|
||
[
|
||
0.2222222222222222,
|
||
"#7201a8"
|
||
],
|
||
[
|
||
0.3333333333333333,
|
||
"#9c179e"
|
||
],
|
||
[
|
||
0.4444444444444444,
|
||
"#bd3786"
|
||
],
|
||
[
|
||
0.5555555555555556,
|
||
"#d8576b"
|
||
],
|
||
[
|
||
0.6666666666666666,
|
||
"#ed7953"
|
||
],
|
||
[
|
||
0.7777777777777778,
|
||
"#fb9f3a"
|
||
],
|
||
[
|
||
0.8888888888888888,
|
||
"#fdca26"
|
||
],
|
||
[
|
||
1.0,
|
||
"#f0f921"
|
||
]
|
||
],
|
||
"type": "histogram2d"
|
||
}
|
||
],
|
||
"histogram2dcontour": [
|
||
{
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
},
|
||
"colorscale": [
|
||
[
|
||
0.0,
|
||
"#0d0887"
|
||
],
|
||
[
|
||
0.1111111111111111,
|
||
"#46039f"
|
||
],
|
||
[
|
||
0.2222222222222222,
|
||
"#7201a8"
|
||
],
|
||
[
|
||
0.3333333333333333,
|
||
"#9c179e"
|
||
],
|
||
[
|
||
0.4444444444444444,
|
||
"#bd3786"
|
||
],
|
||
[
|
||
0.5555555555555556,
|
||
"#d8576b"
|
||
],
|
||
[
|
||
0.6666666666666666,
|
||
"#ed7953"
|
||
],
|
||
[
|
||
0.7777777777777778,
|
||
"#fb9f3a"
|
||
],
|
||
[
|
||
0.8888888888888888,
|
||
"#fdca26"
|
||
],
|
||
[
|
||
1.0,
|
||
"#f0f921"
|
||
]
|
||
],
|
||
"type": "histogram2dcontour"
|
||
}
|
||
],
|
||
"mesh3d": [
|
||
{
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
},
|
||
"type": "mesh3d"
|
||
}
|
||
],
|
||
"parcoords": [
|
||
{
|
||
"line": {
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
}
|
||
},
|
||
"type": "parcoords"
|
||
}
|
||
],
|
||
"pie": [
|
||
{
|
||
"automargin": true,
|
||
"type": "pie"
|
||
}
|
||
],
|
||
"scatter": [
|
||
{
|
||
"fillpattern": {
|
||
"fillmode": "overlay",
|
||
"size": 10,
|
||
"solidity": 0.2
|
||
},
|
||
"type": "scatter"
|
||
}
|
||
],
|
||
"scatter3d": [
|
||
{
|
||
"line": {
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
}
|
||
},
|
||
"marker": {
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
}
|
||
},
|
||
"type": "scatter3d"
|
||
}
|
||
],
|
||
"scattercarpet": [
|
||
{
|
||
"marker": {
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
}
|
||
},
|
||
"type": "scattercarpet"
|
||
}
|
||
],
|
||
"scattergeo": [
|
||
{
|
||
"marker": {
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
}
|
||
},
|
||
"type": "scattergeo"
|
||
}
|
||
],
|
||
"scattergl": [
|
||
{
|
||
"marker": {
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
}
|
||
},
|
||
"type": "scattergl"
|
||
}
|
||
],
|
||
"scattermap": [
|
||
{
|
||
"marker": {
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
}
|
||
},
|
||
"type": "scattermap"
|
||
}
|
||
],
|
||
"scattermapbox": [
|
||
{
|
||
"marker": {
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
}
|
||
},
|
||
"type": "scattermapbox"
|
||
}
|
||
],
|
||
"scatterpolar": [
|
||
{
|
||
"marker": {
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
}
|
||
},
|
||
"type": "scatterpolar"
|
||
}
|
||
],
|
||
"scatterpolargl": [
|
||
{
|
||
"marker": {
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
}
|
||
},
|
||
"type": "scatterpolargl"
|
||
}
|
||
],
|
||
"scatterternary": [
|
||
{
|
||
"marker": {
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
}
|
||
},
|
||
"type": "scatterternary"
|
||
}
|
||
],
|
||
"surface": [
|
||
{
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
},
|
||
"colorscale": [
|
||
[
|
||
0.0,
|
||
"#0d0887"
|
||
],
|
||
[
|
||
0.1111111111111111,
|
||
"#46039f"
|
||
],
|
||
[
|
||
0.2222222222222222,
|
||
"#7201a8"
|
||
],
|
||
[
|
||
0.3333333333333333,
|
||
"#9c179e"
|
||
],
|
||
[
|
||
0.4444444444444444,
|
||
"#bd3786"
|
||
],
|
||
[
|
||
0.5555555555555556,
|
||
"#d8576b"
|
||
],
|
||
[
|
||
0.6666666666666666,
|
||
"#ed7953"
|
||
],
|
||
[
|
||
0.7777777777777778,
|
||
"#fb9f3a"
|
||
],
|
||
[
|
||
0.8888888888888888,
|
||
"#fdca26"
|
||
],
|
||
[
|
||
1.0,
|
||
"#f0f921"
|
||
]
|
||
],
|
||
"type": "surface"
|
||
}
|
||
],
|
||
"table": [
|
||
{
|
||
"cells": {
|
||
"fill": {
|
||
"color": "#EBF0F8"
|
||
},
|
||
"line": {
|
||
"color": "white"
|
||
}
|
||
},
|
||
"header": {
|
||
"fill": {
|
||
"color": "#C8D4E3"
|
||
},
|
||
"line": {
|
||
"color": "white"
|
||
}
|
||
},
|
||
"type": "table"
|
||
}
|
||
]
|
||
},
|
||
"layout": {
|
||
"annotationdefaults": {
|
||
"arrowcolor": "#2a3f5f",
|
||
"arrowhead": 0,
|
||
"arrowwidth": 1
|
||
},
|
||
"autotypenumbers": "strict",
|
||
"coloraxis": {
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
}
|
||
},
|
||
"colorscale": {
|
||
"diverging": [
|
||
[
|
||
0,
|
||
"#8e0152"
|
||
],
|
||
[
|
||
0.1,
|
||
"#c51b7d"
|
||
],
|
||
[
|
||
0.2,
|
||
"#de77ae"
|
||
],
|
||
[
|
||
0.3,
|
||
"#f1b6da"
|
||
],
|
||
[
|
||
0.4,
|
||
"#fde0ef"
|
||
],
|
||
[
|
||
0.5,
|
||
"#f7f7f7"
|
||
],
|
||
[
|
||
0.6,
|
||
"#e6f5d0"
|
||
],
|
||
[
|
||
0.7,
|
||
"#b8e186"
|
||
],
|
||
[
|
||
0.8,
|
||
"#7fbc41"
|
||
],
|
||
[
|
||
0.9,
|
||
"#4d9221"
|
||
],
|
||
[
|
||
1,
|
||
"#276419"
|
||
]
|
||
],
|
||
"sequential": [
|
||
[
|
||
0.0,
|
||
"#0d0887"
|
||
],
|
||
[
|
||
0.1111111111111111,
|
||
"#46039f"
|
||
],
|
||
[
|
||
0.2222222222222222,
|
||
"#7201a8"
|
||
],
|
||
[
|
||
0.3333333333333333,
|
||
"#9c179e"
|
||
],
|
||
[
|
||
0.4444444444444444,
|
||
"#bd3786"
|
||
],
|
||
[
|
||
0.5555555555555556,
|
||
"#d8576b"
|
||
],
|
||
[
|
||
0.6666666666666666,
|
||
"#ed7953"
|
||
],
|
||
[
|
||
0.7777777777777778,
|
||
"#fb9f3a"
|
||
],
|
||
[
|
||
0.8888888888888888,
|
||
"#fdca26"
|
||
],
|
||
[
|
||
1.0,
|
||
"#f0f921"
|
||
]
|
||
],
|
||
"sequentialminus": [
|
||
[
|
||
0.0,
|
||
"#0d0887"
|
||
],
|
||
[
|
||
0.1111111111111111,
|
||
"#46039f"
|
||
],
|
||
[
|
||
0.2222222222222222,
|
||
"#7201a8"
|
||
],
|
||
[
|
||
0.3333333333333333,
|
||
"#9c179e"
|
||
],
|
||
[
|
||
0.4444444444444444,
|
||
"#bd3786"
|
||
],
|
||
[
|
||
0.5555555555555556,
|
||
"#d8576b"
|
||
],
|
||
[
|
||
0.6666666666666666,
|
||
"#ed7953"
|
||
],
|
||
[
|
||
0.7777777777777778,
|
||
"#fb9f3a"
|
||
],
|
||
[
|
||
0.8888888888888888,
|
||
"#fdca26"
|
||
],
|
||
[
|
||
1.0,
|
||
"#f0f921"
|
||
]
|
||
]
|
||
},
|
||
"colorway": [
|
||
"#636efa",
|
||
"#EF553B",
|
||
"#00cc96",
|
||
"#ab63fa",
|
||
"#FFA15A",
|
||
"#19d3f3",
|
||
"#FF6692",
|
||
"#B6E880",
|
||
"#FF97FF",
|
||
"#FECB52"
|
||
],
|
||
"font": {
|
||
"color": "#2a3f5f"
|
||
},
|
||
"geo": {
|
||
"bgcolor": "white",
|
||
"lakecolor": "white",
|
||
"landcolor": "#E5ECF6",
|
||
"showlakes": true,
|
||
"showland": true,
|
||
"subunitcolor": "white"
|
||
},
|
||
"hoverlabel": {
|
||
"align": "left"
|
||
},
|
||
"hovermode": "closest",
|
||
"mapbox": {
|
||
"style": "light"
|
||
},
|
||
"paper_bgcolor": "white",
|
||
"plot_bgcolor": "#E5ECF6",
|
||
"polar": {
|
||
"angularaxis": {
|
||
"gridcolor": "white",
|
||
"linecolor": "white",
|
||
"ticks": ""
|
||
},
|
||
"bgcolor": "#E5ECF6",
|
||
"radialaxis": {
|
||
"gridcolor": "white",
|
||
"linecolor": "white",
|
||
"ticks": ""
|
||
}
|
||
},
|
||
"scene": {
|
||
"xaxis": {
|
||
"backgroundcolor": "#E5ECF6",
|
||
"gridcolor": "white",
|
||
"gridwidth": 2,
|
||
"linecolor": "white",
|
||
"showbackground": true,
|
||
"ticks": "",
|
||
"zerolinecolor": "white"
|
||
},
|
||
"yaxis": {
|
||
"backgroundcolor": "#E5ECF6",
|
||
"gridcolor": "white",
|
||
"gridwidth": 2,
|
||
"linecolor": "white",
|
||
"showbackground": true,
|
||
"ticks": "",
|
||
"zerolinecolor": "white"
|
||
},
|
||
"zaxis": {
|
||
"backgroundcolor": "#E5ECF6",
|
||
"gridcolor": "white",
|
||
"gridwidth": 2,
|
||
"linecolor": "white",
|
||
"showbackground": true,
|
||
"ticks": "",
|
||
"zerolinecolor": "white"
|
||
}
|
||
},
|
||
"shapedefaults": {
|
||
"line": {
|
||
"color": "#2a3f5f"
|
||
}
|
||
},
|
||
"ternary": {
|
||
"aaxis": {
|
||
"gridcolor": "white",
|
||
"linecolor": "white",
|
||
"ticks": ""
|
||
},
|
||
"baxis": {
|
||
"gridcolor": "white",
|
||
"linecolor": "white",
|
||
"ticks": ""
|
||
},
|
||
"bgcolor": "#E5ECF6",
|
||
"caxis": {
|
||
"gridcolor": "white",
|
||
"linecolor": "white",
|
||
"ticks": ""
|
||
}
|
||
},
|
||
"title": {
|
||
"x": 0.05
|
||
},
|
||
"xaxis": {
|
||
"automargin": true,
|
||
"gridcolor": "white",
|
||
"linecolor": "white",
|
||
"ticks": "",
|
||
"title": {
|
||
"standoff": 15
|
||
},
|
||
"zerolinecolor": "white",
|
||
"zerolinewidth": 2
|
||
},
|
||
"yaxis": {
|
||
"automargin": true,
|
||
"gridcolor": "white",
|
||
"linecolor": "white",
|
||
"ticks": "",
|
||
"title": {
|
||
"standoff": 15
|
||
},
|
||
"zerolinecolor": "white",
|
||
"zerolinewidth": 2
|
||
}
|
||
}
|
||
},
|
||
"title": {
|
||
"font": {
|
||
"color": "#0b0b0b",
|
||
"size": 16
|
||
},
|
||
"text": "<b>Cash flow — risk-adjusted</b><br><span style='font-size:12px;color:#898781'>benefits up, costs down; initial outlay undiscounted at time 0</span>",
|
||
"x": 0.02,
|
||
"xanchor": "left"
|
||
},
|
||
"xaxis": {
|
||
"linecolor": "#c3c2b7",
|
||
"showgrid": false,
|
||
"tickfont": {
|
||
"color": "#898781"
|
||
},
|
||
"type": "category"
|
||
},
|
||
"yaxis": {
|
||
"gridcolor": "#e1e0d9",
|
||
"tickfont": {
|
||
"color": "#898781"
|
||
},
|
||
"tickformat": "$~s",
|
||
"zerolinecolor": "#c3c2b7",
|
||
"zerolinewidth": 1.5
|
||
}
|
||
}
|
||
}
|
||
},
|
||
"metadata": {},
|
||
"output_type": "display_data"
|
||
}
|
||
],
|
||
"source": [
|
||
"# ── Cash flow — mirrors the PDF's Cash Flow Chart (p.25) ─────────────\n",
|
||
"fig = go.Figure()\n",
|
||
"fig.add_trace(bar(X_LABELS,\n",
|
||
" [0] + [client[\"benefits_by_year\"][y] for y in YEARS],\n",
|
||
" \"Benefits (risk-adjusted)\", BEN_TOTAL))\n",
|
||
"fig.add_trace(bar(X_LABELS,\n",
|
||
" [-client[\"initial_costs\"]] + [-client[\"costs_by_year\"][y] for y in YEARS],\n",
|
||
" \"Costs (risk-adjusted)\", COST_TOTAL))\n",
|
||
"fig.add_trace(cum_line(X_LABELS,\n",
|
||
" [-client[\"initial_costs\"]] + [client[\"cumulative_net_by_year\"][y] for y in YEARS],\n",
|
||
" \"Cumulative net\"))\n",
|
||
"fig.update_layout(barmode=\"relative\")\n",
|
||
"fig.add_annotation(\n",
|
||
" x=0.02, y=0.98, xref=\"paper\", yref=\"paper\", showarrow=False, align=\"left\",\n",
|
||
" text=(f\"NPV {html_money(client['npv'])} · ROI {client['roi_pct']:.0f}% · \"\n",
|
||
" f\"payback {client['payback_label']}\"),\n",
|
||
" font=dict(size=12, color=INK), bgcolor=SURFACE,\n",
|
||
" bordercolor=GRID, borderwidth=1)\n",
|
||
"tei_layout(fig, \"Cash flow — risk-adjusted\",\n",
|
||
" subtitle=\"benefits up, costs down; initial outlay undiscounted at time 0\")\n",
|
||
"fig.show()\n"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": 12,
|
||
"id": "cell-17",
|
||
"metadata": {
|
||
"execution": {
|
||
"iopub.execute_input": "2026-07-09T18:15:43.329401Z",
|
||
"iopub.status.busy": "2026-07-09T18:15:43.329247Z",
|
||
"iopub.status.idle": "2026-07-09T18:15:43.348656Z",
|
||
"shell.execute_reply": "2026-07-09T18:15:43.348131Z"
|
||
}
|
||
},
|
||
"outputs": [
|
||
{
|
||
"data": {
|
||
"application/vnd.plotly.v1+json": {
|
||
"config": {
|
||
"plotlyServerURL": "https://plot.ly"
|
||
},
|
||
"data": [
|
||
{
|
||
"connector": {
|
||
"line": {
|
||
"color": "#e1e0d9"
|
||
}
|
||
},
|
||
"decreasing": {
|
||
"marker": {
|
||
"color": "#e34948"
|
||
}
|
||
},
|
||
"increasing": {
|
||
"marker": {
|
||
"color": "#1baf7a"
|
||
}
|
||
},
|
||
"measure": [
|
||
"relative",
|
||
"relative",
|
||
"total"
|
||
],
|
||
"text": [
|
||
"$101.7M",
|
||
"-$23.0M",
|
||
"$78.7M"
|
||
],
|
||
"textposition": "outside",
|
||
"totals": {
|
||
"marker": {
|
||
"color": "#2a78d6"
|
||
}
|
||
},
|
||
"type": "waterfall",
|
||
"x": [
|
||
"Benefits PV",
|
||
"Costs PV",
|
||
"NPV"
|
||
],
|
||
"y": [
|
||
101696567.55071373,
|
||
-22983075.775356874,
|
||
0
|
||
]
|
||
}
|
||
],
|
||
"layout": {
|
||
"bargap": 0.45,
|
||
"font": {
|
||
"color": "#52514e",
|
||
"family": "system-ui, -apple-system, \"Segoe UI\", sans-serif",
|
||
"size": 12
|
||
},
|
||
"height": 420,
|
||
"hovermode": "x unified",
|
||
"legend": {
|
||
"font": {
|
||
"color": "#52514e",
|
||
"size": 11
|
||
},
|
||
"orientation": "h",
|
||
"x": 0,
|
||
"y": -0.1,
|
||
"yanchor": "top"
|
||
},
|
||
"margin": {
|
||
"b": 80,
|
||
"l": 70,
|
||
"r": 30,
|
||
"t": 70
|
||
},
|
||
"paper_bgcolor": "#fcfcfb",
|
||
"plot_bgcolor": "#fcfcfb",
|
||
"showlegend": false,
|
||
"template": {
|
||
"data": {
|
||
"bar": [
|
||
{
|
||
"error_x": {
|
||
"color": "#2a3f5f"
|
||
},
|
||
"error_y": {
|
||
"color": "#2a3f5f"
|
||
},
|
||
"marker": {
|
||
"line": {
|
||
"color": "#E5ECF6",
|
||
"width": 0.5
|
||
},
|
||
"pattern": {
|
||
"fillmode": "overlay",
|
||
"size": 10,
|
||
"solidity": 0.2
|
||
}
|
||
},
|
||
"type": "bar"
|
||
}
|
||
],
|
||
"barpolar": [
|
||
{
|
||
"marker": {
|
||
"line": {
|
||
"color": "#E5ECF6",
|
||
"width": 0.5
|
||
},
|
||
"pattern": {
|
||
"fillmode": "overlay",
|
||
"size": 10,
|
||
"solidity": 0.2
|
||
}
|
||
},
|
||
"type": "barpolar"
|
||
}
|
||
],
|
||
"carpet": [
|
||
{
|
||
"aaxis": {
|
||
"endlinecolor": "#2a3f5f",
|
||
"gridcolor": "white",
|
||
"linecolor": "white",
|
||
"minorgridcolor": "white",
|
||
"startlinecolor": "#2a3f5f"
|
||
},
|
||
"baxis": {
|
||
"endlinecolor": "#2a3f5f",
|
||
"gridcolor": "white",
|
||
"linecolor": "white",
|
||
"minorgridcolor": "white",
|
||
"startlinecolor": "#2a3f5f"
|
||
},
|
||
"type": "carpet"
|
||
}
|
||
],
|
||
"choropleth": [
|
||
{
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
},
|
||
"type": "choropleth"
|
||
}
|
||
],
|
||
"contour": [
|
||
{
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
},
|
||
"colorscale": [
|
||
[
|
||
0.0,
|
||
"#0d0887"
|
||
],
|
||
[
|
||
0.1111111111111111,
|
||
"#46039f"
|
||
],
|
||
[
|
||
0.2222222222222222,
|
||
"#7201a8"
|
||
],
|
||
[
|
||
0.3333333333333333,
|
||
"#9c179e"
|
||
],
|
||
[
|
||
0.4444444444444444,
|
||
"#bd3786"
|
||
],
|
||
[
|
||
0.5555555555555556,
|
||
"#d8576b"
|
||
],
|
||
[
|
||
0.6666666666666666,
|
||
"#ed7953"
|
||
],
|
||
[
|
||
0.7777777777777778,
|
||
"#fb9f3a"
|
||
],
|
||
[
|
||
0.8888888888888888,
|
||
"#fdca26"
|
||
],
|
||
[
|
||
1.0,
|
||
"#f0f921"
|
||
]
|
||
],
|
||
"type": "contour"
|
||
}
|
||
],
|
||
"contourcarpet": [
|
||
{
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
},
|
||
"type": "contourcarpet"
|
||
}
|
||
],
|
||
"heatmap": [
|
||
{
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
},
|
||
"colorscale": [
|
||
[
|
||
0.0,
|
||
"#0d0887"
|
||
],
|
||
[
|
||
0.1111111111111111,
|
||
"#46039f"
|
||
],
|
||
[
|
||
0.2222222222222222,
|
||
"#7201a8"
|
||
],
|
||
[
|
||
0.3333333333333333,
|
||
"#9c179e"
|
||
],
|
||
[
|
||
0.4444444444444444,
|
||
"#bd3786"
|
||
],
|
||
[
|
||
0.5555555555555556,
|
||
"#d8576b"
|
||
],
|
||
[
|
||
0.6666666666666666,
|
||
"#ed7953"
|
||
],
|
||
[
|
||
0.7777777777777778,
|
||
"#fb9f3a"
|
||
],
|
||
[
|
||
0.8888888888888888,
|
||
"#fdca26"
|
||
],
|
||
[
|
||
1.0,
|
||
"#f0f921"
|
||
]
|
||
],
|
||
"type": "heatmap"
|
||
}
|
||
],
|
||
"histogram": [
|
||
{
|
||
"marker": {
|
||
"pattern": {
|
||
"fillmode": "overlay",
|
||
"size": 10,
|
||
"solidity": 0.2
|
||
}
|
||
},
|
||
"type": "histogram"
|
||
}
|
||
],
|
||
"histogram2d": [
|
||
{
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
},
|
||
"colorscale": [
|
||
[
|
||
0.0,
|
||
"#0d0887"
|
||
],
|
||
[
|
||
0.1111111111111111,
|
||
"#46039f"
|
||
],
|
||
[
|
||
0.2222222222222222,
|
||
"#7201a8"
|
||
],
|
||
[
|
||
0.3333333333333333,
|
||
"#9c179e"
|
||
],
|
||
[
|
||
0.4444444444444444,
|
||
"#bd3786"
|
||
],
|
||
[
|
||
0.5555555555555556,
|
||
"#d8576b"
|
||
],
|
||
[
|
||
0.6666666666666666,
|
||
"#ed7953"
|
||
],
|
||
[
|
||
0.7777777777777778,
|
||
"#fb9f3a"
|
||
],
|
||
[
|
||
0.8888888888888888,
|
||
"#fdca26"
|
||
],
|
||
[
|
||
1.0,
|
||
"#f0f921"
|
||
]
|
||
],
|
||
"type": "histogram2d"
|
||
}
|
||
],
|
||
"histogram2dcontour": [
|
||
{
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
},
|
||
"colorscale": [
|
||
[
|
||
0.0,
|
||
"#0d0887"
|
||
],
|
||
[
|
||
0.1111111111111111,
|
||
"#46039f"
|
||
],
|
||
[
|
||
0.2222222222222222,
|
||
"#7201a8"
|
||
],
|
||
[
|
||
0.3333333333333333,
|
||
"#9c179e"
|
||
],
|
||
[
|
||
0.4444444444444444,
|
||
"#bd3786"
|
||
],
|
||
[
|
||
0.5555555555555556,
|
||
"#d8576b"
|
||
],
|
||
[
|
||
0.6666666666666666,
|
||
"#ed7953"
|
||
],
|
||
[
|
||
0.7777777777777778,
|
||
"#fb9f3a"
|
||
],
|
||
[
|
||
0.8888888888888888,
|
||
"#fdca26"
|
||
],
|
||
[
|
||
1.0,
|
||
"#f0f921"
|
||
]
|
||
],
|
||
"type": "histogram2dcontour"
|
||
}
|
||
],
|
||
"mesh3d": [
|
||
{
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
},
|
||
"type": "mesh3d"
|
||
}
|
||
],
|
||
"parcoords": [
|
||
{
|
||
"line": {
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
}
|
||
},
|
||
"type": "parcoords"
|
||
}
|
||
],
|
||
"pie": [
|
||
{
|
||
"automargin": true,
|
||
"type": "pie"
|
||
}
|
||
],
|
||
"scatter": [
|
||
{
|
||
"fillpattern": {
|
||
"fillmode": "overlay",
|
||
"size": 10,
|
||
"solidity": 0.2
|
||
},
|
||
"type": "scatter"
|
||
}
|
||
],
|
||
"scatter3d": [
|
||
{
|
||
"line": {
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
}
|
||
},
|
||
"marker": {
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
}
|
||
},
|
||
"type": "scatter3d"
|
||
}
|
||
],
|
||
"scattercarpet": [
|
||
{
|
||
"marker": {
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
}
|
||
},
|
||
"type": "scattercarpet"
|
||
}
|
||
],
|
||
"scattergeo": [
|
||
{
|
||
"marker": {
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
}
|
||
},
|
||
"type": "scattergeo"
|
||
}
|
||
],
|
||
"scattergl": [
|
||
{
|
||
"marker": {
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
}
|
||
},
|
||
"type": "scattergl"
|
||
}
|
||
],
|
||
"scattermap": [
|
||
{
|
||
"marker": {
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
}
|
||
},
|
||
"type": "scattermap"
|
||
}
|
||
],
|
||
"scattermapbox": [
|
||
{
|
||
"marker": {
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
}
|
||
},
|
||
"type": "scattermapbox"
|
||
}
|
||
],
|
||
"scatterpolar": [
|
||
{
|
||
"marker": {
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
}
|
||
},
|
||
"type": "scatterpolar"
|
||
}
|
||
],
|
||
"scatterpolargl": [
|
||
{
|
||
"marker": {
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
}
|
||
},
|
||
"type": "scatterpolargl"
|
||
}
|
||
],
|
||
"scatterternary": [
|
||
{
|
||
"marker": {
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
}
|
||
},
|
||
"type": "scatterternary"
|
||
}
|
||
],
|
||
"surface": [
|
||
{
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
},
|
||
"colorscale": [
|
||
[
|
||
0.0,
|
||
"#0d0887"
|
||
],
|
||
[
|
||
0.1111111111111111,
|
||
"#46039f"
|
||
],
|
||
[
|
||
0.2222222222222222,
|
||
"#7201a8"
|
||
],
|
||
[
|
||
0.3333333333333333,
|
||
"#9c179e"
|
||
],
|
||
[
|
||
0.4444444444444444,
|
||
"#bd3786"
|
||
],
|
||
[
|
||
0.5555555555555556,
|
||
"#d8576b"
|
||
],
|
||
[
|
||
0.6666666666666666,
|
||
"#ed7953"
|
||
],
|
||
[
|
||
0.7777777777777778,
|
||
"#fb9f3a"
|
||
],
|
||
[
|
||
0.8888888888888888,
|
||
"#fdca26"
|
||
],
|
||
[
|
||
1.0,
|
||
"#f0f921"
|
||
]
|
||
],
|
||
"type": "surface"
|
||
}
|
||
],
|
||
"table": [
|
||
{
|
||
"cells": {
|
||
"fill": {
|
||
"color": "#EBF0F8"
|
||
},
|
||
"line": {
|
||
"color": "white"
|
||
}
|
||
},
|
||
"header": {
|
||
"fill": {
|
||
"color": "#C8D4E3"
|
||
},
|
||
"line": {
|
||
"color": "white"
|
||
}
|
||
},
|
||
"type": "table"
|
||
}
|
||
]
|
||
},
|
||
"layout": {
|
||
"annotationdefaults": {
|
||
"arrowcolor": "#2a3f5f",
|
||
"arrowhead": 0,
|
||
"arrowwidth": 1
|
||
},
|
||
"autotypenumbers": "strict",
|
||
"coloraxis": {
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
}
|
||
},
|
||
"colorscale": {
|
||
"diverging": [
|
||
[
|
||
0,
|
||
"#8e0152"
|
||
],
|
||
[
|
||
0.1,
|
||
"#c51b7d"
|
||
],
|
||
[
|
||
0.2,
|
||
"#de77ae"
|
||
],
|
||
[
|
||
0.3,
|
||
"#f1b6da"
|
||
],
|
||
[
|
||
0.4,
|
||
"#fde0ef"
|
||
],
|
||
[
|
||
0.5,
|
||
"#f7f7f7"
|
||
],
|
||
[
|
||
0.6,
|
||
"#e6f5d0"
|
||
],
|
||
[
|
||
0.7,
|
||
"#b8e186"
|
||
],
|
||
[
|
||
0.8,
|
||
"#7fbc41"
|
||
],
|
||
[
|
||
0.9,
|
||
"#4d9221"
|
||
],
|
||
[
|
||
1,
|
||
"#276419"
|
||
]
|
||
],
|
||
"sequential": [
|
||
[
|
||
0.0,
|
||
"#0d0887"
|
||
],
|
||
[
|
||
0.1111111111111111,
|
||
"#46039f"
|
||
],
|
||
[
|
||
0.2222222222222222,
|
||
"#7201a8"
|
||
],
|
||
[
|
||
0.3333333333333333,
|
||
"#9c179e"
|
||
],
|
||
[
|
||
0.4444444444444444,
|
||
"#bd3786"
|
||
],
|
||
[
|
||
0.5555555555555556,
|
||
"#d8576b"
|
||
],
|
||
[
|
||
0.6666666666666666,
|
||
"#ed7953"
|
||
],
|
||
[
|
||
0.7777777777777778,
|
||
"#fb9f3a"
|
||
],
|
||
[
|
||
0.8888888888888888,
|
||
"#fdca26"
|
||
],
|
||
[
|
||
1.0,
|
||
"#f0f921"
|
||
]
|
||
],
|
||
"sequentialminus": [
|
||
[
|
||
0.0,
|
||
"#0d0887"
|
||
],
|
||
[
|
||
0.1111111111111111,
|
||
"#46039f"
|
||
],
|
||
[
|
||
0.2222222222222222,
|
||
"#7201a8"
|
||
],
|
||
[
|
||
0.3333333333333333,
|
||
"#9c179e"
|
||
],
|
||
[
|
||
0.4444444444444444,
|
||
"#bd3786"
|
||
],
|
||
[
|
||
0.5555555555555556,
|
||
"#d8576b"
|
||
],
|
||
[
|
||
0.6666666666666666,
|
||
"#ed7953"
|
||
],
|
||
[
|
||
0.7777777777777778,
|
||
"#fb9f3a"
|
||
],
|
||
[
|
||
0.8888888888888888,
|
||
"#fdca26"
|
||
],
|
||
[
|
||
1.0,
|
||
"#f0f921"
|
||
]
|
||
]
|
||
},
|
||
"colorway": [
|
||
"#636efa",
|
||
"#EF553B",
|
||
"#00cc96",
|
||
"#ab63fa",
|
||
"#FFA15A",
|
||
"#19d3f3",
|
||
"#FF6692",
|
||
"#B6E880",
|
||
"#FF97FF",
|
||
"#FECB52"
|
||
],
|
||
"font": {
|
||
"color": "#2a3f5f"
|
||
},
|
||
"geo": {
|
||
"bgcolor": "white",
|
||
"lakecolor": "white",
|
||
"landcolor": "#E5ECF6",
|
||
"showlakes": true,
|
||
"showland": true,
|
||
"subunitcolor": "white"
|
||
},
|
||
"hoverlabel": {
|
||
"align": "left"
|
||
},
|
||
"hovermode": "closest",
|
||
"mapbox": {
|
||
"style": "light"
|
||
},
|
||
"paper_bgcolor": "white",
|
||
"plot_bgcolor": "#E5ECF6",
|
||
"polar": {
|
||
"angularaxis": {
|
||
"gridcolor": "white",
|
||
"linecolor": "white",
|
||
"ticks": ""
|
||
},
|
||
"bgcolor": "#E5ECF6",
|
||
"radialaxis": {
|
||
"gridcolor": "white",
|
||
"linecolor": "white",
|
||
"ticks": ""
|
||
}
|
||
},
|
||
"scene": {
|
||
"xaxis": {
|
||
"backgroundcolor": "#E5ECF6",
|
||
"gridcolor": "white",
|
||
"gridwidth": 2,
|
||
"linecolor": "white",
|
||
"showbackground": true,
|
||
"ticks": "",
|
||
"zerolinecolor": "white"
|
||
},
|
||
"yaxis": {
|
||
"backgroundcolor": "#E5ECF6",
|
||
"gridcolor": "white",
|
||
"gridwidth": 2,
|
||
"linecolor": "white",
|
||
"showbackground": true,
|
||
"ticks": "",
|
||
"zerolinecolor": "white"
|
||
},
|
||
"zaxis": {
|
||
"backgroundcolor": "#E5ECF6",
|
||
"gridcolor": "white",
|
||
"gridwidth": 2,
|
||
"linecolor": "white",
|
||
"showbackground": true,
|
||
"ticks": "",
|
||
"zerolinecolor": "white"
|
||
}
|
||
},
|
||
"shapedefaults": {
|
||
"line": {
|
||
"color": "#2a3f5f"
|
||
}
|
||
},
|
||
"ternary": {
|
||
"aaxis": {
|
||
"gridcolor": "white",
|
||
"linecolor": "white",
|
||
"ticks": ""
|
||
},
|
||
"baxis": {
|
||
"gridcolor": "white",
|
||
"linecolor": "white",
|
||
"ticks": ""
|
||
},
|
||
"bgcolor": "#E5ECF6",
|
||
"caxis": {
|
||
"gridcolor": "white",
|
||
"linecolor": "white",
|
||
"ticks": ""
|
||
}
|
||
},
|
||
"title": {
|
||
"x": 0.05
|
||
},
|
||
"xaxis": {
|
||
"automargin": true,
|
||
"gridcolor": "white",
|
||
"linecolor": "white",
|
||
"ticks": "",
|
||
"title": {
|
||
"standoff": 15
|
||
},
|
||
"zerolinecolor": "white",
|
||
"zerolinewidth": 2
|
||
},
|
||
"yaxis": {
|
||
"automargin": true,
|
||
"gridcolor": "white",
|
||
"linecolor": "white",
|
||
"ticks": "",
|
||
"title": {
|
||
"standoff": 15
|
||
},
|
||
"zerolinecolor": "white",
|
||
"zerolinewidth": 2
|
||
}
|
||
}
|
||
},
|
||
"title": {
|
||
"font": {
|
||
"color": "#0b0b0b",
|
||
"size": 16
|
||
},
|
||
"text": "<b>Present value walk — client overlay</b><br><span style='font-size:12px;color:#898781'>discounted at 10%, 3 years</span>",
|
||
"x": 0.02,
|
||
"xanchor": "left"
|
||
},
|
||
"xaxis": {
|
||
"linecolor": "#c3c2b7",
|
||
"showgrid": false,
|
||
"tickfont": {
|
||
"color": "#898781"
|
||
},
|
||
"type": "category"
|
||
},
|
||
"yaxis": {
|
||
"gridcolor": "#e1e0d9",
|
||
"tickfont": {
|
||
"color": "#898781"
|
||
},
|
||
"tickformat": "$~s",
|
||
"zerolinecolor": "#c3c2b7",
|
||
"zerolinewidth": 1.5
|
||
}
|
||
}
|
||
}
|
||
},
|
||
"metadata": {},
|
||
"output_type": "display_data"
|
||
}
|
||
],
|
||
"source": [
|
||
"fig = go.Figure(go.Waterfall(\n",
|
||
" x=[\"Benefits PV\", \"Costs PV\", \"NPV\"],\n",
|
||
" measure=[\"relative\", \"relative\", \"total\"],\n",
|
||
" y=[client[\"benefits_pv\"], -client[\"costs_pv\"], 0],\n",
|
||
" text=[html_money(client[\"benefits_pv\"]), html_money(-client[\"costs_pv\"]),\n",
|
||
" html_money(client[\"npv\"])],\n",
|
||
" textposition=\"outside\",\n",
|
||
" connector=dict(line=dict(color=GRID)),\n",
|
||
" increasing=dict(marker=dict(color=BEN_TOTAL)),\n",
|
||
" decreasing=dict(marker=dict(color=COST_TOTAL)),\n",
|
||
" totals=dict(marker=dict(color=NPV_COLOR)),\n",
|
||
"))\n",
|
||
"fig.update_layout(showlegend=False)\n",
|
||
"tei_layout(fig, \"Present value walk — client overlay\",\n",
|
||
" subtitle=f\"discounted at {'{:.0%}'.format(DISCOUNT_RATE)}, 3 years\",\n",
|
||
" height=420)\n",
|
||
"fig.show()\n"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"id": "cell-18",
|
||
"metadata": {},
|
||
"source": [
|
||
"<a id=\"section-6\"></a>\n",
|
||
"## 6 · Scenarios (🟡)\n",
|
||
"\n",
|
||
"Scenarios stress the overlay on two levers: **adoption** scales every\n",
|
||
"nominal value (including the initial outlay), and **risk delta** widens or\n",
|
||
"narrows the TEI risk adjustments — added to benefit risk, subtracted from\n",
|
||
"cost risk, clamped at zero.\n",
|
||
"\n",
|
||
"One counterintuitive consequence, worth stating: the **conservative**\n",
|
||
"scenario *lowers* costs PV as well as benefits — 80% adoption shrinks the\n",
|
||
"consumption-priced usage cost, and the clamp caps how much extra padding\n",
|
||
"the risk delta can add back. The case direction is still conservative:\n",
|
||
"NPV and ROI both fall.\n"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": 13,
|
||
"id": "cell-19",
|
||
"metadata": {
|
||
"execution": {
|
||
"iopub.execute_input": "2026-07-09T18:15:43.353547Z",
|
||
"iopub.status.busy": "2026-07-09T18:15:43.353383Z",
|
||
"iopub.status.idle": "2026-07-09T18:15:43.362921Z",
|
||
"shell.execute_reply": "2026-07-09T18:15:43.362320Z"
|
||
}
|
||
},
|
||
"outputs": [
|
||
{
|
||
"data": {
|
||
"text/html": [
|
||
"<div>\n",
|
||
"<style scoped>\n",
|
||
" .dataframe tbody tr th:only-of-type {\n",
|
||
" vertical-align: middle;\n",
|
||
" }\n",
|
||
"\n",
|
||
" .dataframe tbody tr th {\n",
|
||
" vertical-align: top;\n",
|
||
" }\n",
|
||
"\n",
|
||
" .dataframe thead th {\n",
|
||
" text-align: right;\n",
|
||
" }\n",
|
||
"</style>\n",
|
||
"<table border=\"1\" class=\"dataframe\">\n",
|
||
" <thead>\n",
|
||
" <tr style=\"text-align: right;\">\n",
|
||
" <th></th>\n",
|
||
" <th>Adoption</th>\n",
|
||
" <th>Risk Δ</th>\n",
|
||
" <th>Benefits PV</th>\n",
|
||
" <th>Costs PV</th>\n",
|
||
" <th>NPV</th>\n",
|
||
" <th>ROI %</th>\n",
|
||
" <th>Payback (months)</th>\n",
|
||
" </tr>\n",
|
||
" <tr>\n",
|
||
" <th>Scenario</th>\n",
|
||
" <th></th>\n",
|
||
" <th></th>\n",
|
||
" <th></th>\n",
|
||
" <th></th>\n",
|
||
" <th></th>\n",
|
||
" <th></th>\n",
|
||
" <th></th>\n",
|
||
" </tr>\n",
|
||
" </thead>\n",
|
||
" <tbody>\n",
|
||
" <tr>\n",
|
||
" <th>conservative</th>\n",
|
||
" <td>80%</td>\n",
|
||
" <td>+10%</td>\n",
|
||
" <td>71,672,868</td>\n",
|
||
" <td>17,437,916</td>\n",
|
||
" <td>54,234,951</td>\n",
|
||
" <td>311</td>\n",
|
||
" <td>1</td>\n",
|
||
" </tr>\n",
|
||
" <tr>\n",
|
||
" <th>moderate</th>\n",
|
||
" <td>100%</td>\n",
|
||
" <td>+0%</td>\n",
|
||
" <td>101,696,568</td>\n",
|
||
" <td>22,983,076</td>\n",
|
||
" <td>78,713,492</td>\n",
|
||
" <td>342</td>\n",
|
||
" <td>1</td>\n",
|
||
" </tr>\n",
|
||
" <tr>\n",
|
||
" <th>aggressive</th>\n",
|
||
" <td>115%</td>\n",
|
||
" <td>-5%</td>\n",
|
||
" <td>123,911,705</td>\n",
|
||
" <td>27,682,369</td>\n",
|
||
" <td>96,229,337</td>\n",
|
||
" <td>348</td>\n",
|
||
" <td>1</td>\n",
|
||
" </tr>\n",
|
||
" </tbody>\n",
|
||
"</table>\n",
|
||
"</div>"
|
||
],
|
||
"text/plain": [
|
||
" Adoption Risk Δ Benefits PV Costs PV NPV ROI % \\\n",
|
||
"Scenario \n",
|
||
"conservative 80% +10% 71,672,868 17,437,916 54,234,951 311 \n",
|
||
"moderate 100% +0% 101,696,568 22,983,076 78,713,492 342 \n",
|
||
"aggressive 115% -5% 123,911,705 27,682,369 96,229,337 348 \n",
|
||
"\n",
|
||
" Payback (months) \n",
|
||
"Scenario \n",
|
||
"conservative 1 \n",
|
||
"moderate 1 \n",
|
||
"aggressive 1 "
|
||
]
|
||
},
|
||
"metadata": {},
|
||
"output_type": "display_data"
|
||
}
|
||
],
|
||
"source": [
|
||
"# ── Scenario sweep over the client overlay ───────────────────────────\n",
|
||
"scen_summaries = {\n",
|
||
" s: compute_summary(apply_scenario(overlay_benefits, s),\n",
|
||
" apply_scenario(overlay_costs, s), DISCOUNT_RATE)\n",
|
||
" for s in SCENARIOS\n",
|
||
"}\n",
|
||
"scen_df = pd.DataFrame([{\n",
|
||
" \"Scenario\": s,\n",
|
||
" \"Adoption\": f\"{SCENARIOS[s]['adoption']:.0%}\",\n",
|
||
" \"Risk Δ\": f\"{SCENARIOS[s]['risk_delta']:+.0%}\",\n",
|
||
" \"Benefits PV\": r[\"benefits_pv\"],\n",
|
||
" \"Costs PV\": r[\"costs_pv\"],\n",
|
||
" \"NPV\": r[\"npv\"],\n",
|
||
" \"ROI %\": round(r[\"roi_pct\"], 1),\n",
|
||
" \"Payback (months)\": round(r[\"payback_months\"], 2)\n",
|
||
" if r[\"payback_months\"] is not None else float(\"nan\"),\n",
|
||
"} for s, r in scen_summaries.items()]).set_index(\"Scenario\")\n",
|
||
"display(scen_df)\n"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": 14,
|
||
"id": "cell-20",
|
||
"metadata": {
|
||
"execution": {
|
||
"iopub.execute_input": "2026-07-09T18:15:43.366882Z",
|
||
"iopub.status.busy": "2026-07-09T18:15:43.366731Z",
|
||
"iopub.status.idle": "2026-07-09T18:15:43.383646Z",
|
||
"shell.execute_reply": "2026-07-09T18:15:43.383094Z"
|
||
}
|
||
},
|
||
"outputs": [
|
||
{
|
||
"data": {
|
||
"application/vnd.plotly.v1+json": {
|
||
"config": {
|
||
"plotlyServerURL": "https://plot.ly"
|
||
},
|
||
"data": [
|
||
{
|
||
"hovertemplate": "%{fullData.name}: %{y:$,.0f}<extra></extra>",
|
||
"marker": {
|
||
"color": "#1baf7a",
|
||
"line": {
|
||
"color": "#fcfcfb",
|
||
"width": 2
|
||
}
|
||
},
|
||
"name": "Benefits PV",
|
||
"type": "bar",
|
||
"x": [
|
||
"conservative",
|
||
"moderate",
|
||
"aggressive"
|
||
],
|
||
"y": [
|
||
71672867.64838466,
|
||
101696567.55071373,
|
||
123911705.40270472
|
||
]
|
||
},
|
||
{
|
||
"hovertemplate": "%{fullData.name}: %{y:$,.0f}<extra></extra>",
|
||
"marker": {
|
||
"color": "#e34948",
|
||
"line": {
|
||
"color": "#fcfcfb",
|
||
"width": 2
|
||
}
|
||
},
|
||
"name": "Costs PV",
|
||
"type": "bar",
|
||
"x": [
|
||
"conservative",
|
||
"moderate",
|
||
"aggressive"
|
||
],
|
||
"y": [
|
||
17437916.33959429,
|
||
22983075.775356874,
|
||
27682368.613918103
|
||
]
|
||
},
|
||
{
|
||
"hovertemplate": "%{fullData.name}: %{y:$,.0f}<extra></extra>",
|
||
"marker": {
|
||
"color": "#2a78d6",
|
||
"line": {
|
||
"color": "#fcfcfb",
|
||
"width": 2
|
||
}
|
||
},
|
||
"name": "NPV",
|
||
"type": "bar",
|
||
"x": [
|
||
"conservative",
|
||
"moderate",
|
||
"aggressive"
|
||
],
|
||
"y": [
|
||
54234951.30879037,
|
||
78713491.77535686,
|
||
96229336.78878662
|
||
]
|
||
}
|
||
],
|
||
"layout": {
|
||
"bargap": 0.45,
|
||
"barmode": "group",
|
||
"font": {
|
||
"color": "#52514e",
|
||
"family": "system-ui, -apple-system, \"Segoe UI\", sans-serif",
|
||
"size": 12
|
||
},
|
||
"height": 420,
|
||
"hovermode": "x unified",
|
||
"legend": {
|
||
"font": {
|
||
"color": "#52514e",
|
||
"size": 11
|
||
},
|
||
"orientation": "h",
|
||
"x": 0,
|
||
"y": -0.1,
|
||
"yanchor": "top"
|
||
},
|
||
"margin": {
|
||
"b": 80,
|
||
"l": 70,
|
||
"r": 30,
|
||
"t": 70
|
||
},
|
||
"paper_bgcolor": "#fcfcfb",
|
||
"plot_bgcolor": "#fcfcfb",
|
||
"template": {
|
||
"data": {
|
||
"bar": [
|
||
{
|
||
"error_x": {
|
||
"color": "#2a3f5f"
|
||
},
|
||
"error_y": {
|
||
"color": "#2a3f5f"
|
||
},
|
||
"marker": {
|
||
"line": {
|
||
"color": "#E5ECF6",
|
||
"width": 0.5
|
||
},
|
||
"pattern": {
|
||
"fillmode": "overlay",
|
||
"size": 10,
|
||
"solidity": 0.2
|
||
}
|
||
},
|
||
"type": "bar"
|
||
}
|
||
],
|
||
"barpolar": [
|
||
{
|
||
"marker": {
|
||
"line": {
|
||
"color": "#E5ECF6",
|
||
"width": 0.5
|
||
},
|
||
"pattern": {
|
||
"fillmode": "overlay",
|
||
"size": 10,
|
||
"solidity": 0.2
|
||
}
|
||
},
|
||
"type": "barpolar"
|
||
}
|
||
],
|
||
"carpet": [
|
||
{
|
||
"aaxis": {
|
||
"endlinecolor": "#2a3f5f",
|
||
"gridcolor": "white",
|
||
"linecolor": "white",
|
||
"minorgridcolor": "white",
|
||
"startlinecolor": "#2a3f5f"
|
||
},
|
||
"baxis": {
|
||
"endlinecolor": "#2a3f5f",
|
||
"gridcolor": "white",
|
||
"linecolor": "white",
|
||
"minorgridcolor": "white",
|
||
"startlinecolor": "#2a3f5f"
|
||
},
|
||
"type": "carpet"
|
||
}
|
||
],
|
||
"choropleth": [
|
||
{
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
},
|
||
"type": "choropleth"
|
||
}
|
||
],
|
||
"contour": [
|
||
{
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
},
|
||
"colorscale": [
|
||
[
|
||
0.0,
|
||
"#0d0887"
|
||
],
|
||
[
|
||
0.1111111111111111,
|
||
"#46039f"
|
||
],
|
||
[
|
||
0.2222222222222222,
|
||
"#7201a8"
|
||
],
|
||
[
|
||
0.3333333333333333,
|
||
"#9c179e"
|
||
],
|
||
[
|
||
0.4444444444444444,
|
||
"#bd3786"
|
||
],
|
||
[
|
||
0.5555555555555556,
|
||
"#d8576b"
|
||
],
|
||
[
|
||
0.6666666666666666,
|
||
"#ed7953"
|
||
],
|
||
[
|
||
0.7777777777777778,
|
||
"#fb9f3a"
|
||
],
|
||
[
|
||
0.8888888888888888,
|
||
"#fdca26"
|
||
],
|
||
[
|
||
1.0,
|
||
"#f0f921"
|
||
]
|
||
],
|
||
"type": "contour"
|
||
}
|
||
],
|
||
"contourcarpet": [
|
||
{
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
},
|
||
"type": "contourcarpet"
|
||
}
|
||
],
|
||
"heatmap": [
|
||
{
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
},
|
||
"colorscale": [
|
||
[
|
||
0.0,
|
||
"#0d0887"
|
||
],
|
||
[
|
||
0.1111111111111111,
|
||
"#46039f"
|
||
],
|
||
[
|
||
0.2222222222222222,
|
||
"#7201a8"
|
||
],
|
||
[
|
||
0.3333333333333333,
|
||
"#9c179e"
|
||
],
|
||
[
|
||
0.4444444444444444,
|
||
"#bd3786"
|
||
],
|
||
[
|
||
0.5555555555555556,
|
||
"#d8576b"
|
||
],
|
||
[
|
||
0.6666666666666666,
|
||
"#ed7953"
|
||
],
|
||
[
|
||
0.7777777777777778,
|
||
"#fb9f3a"
|
||
],
|
||
[
|
||
0.8888888888888888,
|
||
"#fdca26"
|
||
],
|
||
[
|
||
1.0,
|
||
"#f0f921"
|
||
]
|
||
],
|
||
"type": "heatmap"
|
||
}
|
||
],
|
||
"histogram": [
|
||
{
|
||
"marker": {
|
||
"pattern": {
|
||
"fillmode": "overlay",
|
||
"size": 10,
|
||
"solidity": 0.2
|
||
}
|
||
},
|
||
"type": "histogram"
|
||
}
|
||
],
|
||
"histogram2d": [
|
||
{
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
},
|
||
"colorscale": [
|
||
[
|
||
0.0,
|
||
"#0d0887"
|
||
],
|
||
[
|
||
0.1111111111111111,
|
||
"#46039f"
|
||
],
|
||
[
|
||
0.2222222222222222,
|
||
"#7201a8"
|
||
],
|
||
[
|
||
0.3333333333333333,
|
||
"#9c179e"
|
||
],
|
||
[
|
||
0.4444444444444444,
|
||
"#bd3786"
|
||
],
|
||
[
|
||
0.5555555555555556,
|
||
"#d8576b"
|
||
],
|
||
[
|
||
0.6666666666666666,
|
||
"#ed7953"
|
||
],
|
||
[
|
||
0.7777777777777778,
|
||
"#fb9f3a"
|
||
],
|
||
[
|
||
0.8888888888888888,
|
||
"#fdca26"
|
||
],
|
||
[
|
||
1.0,
|
||
"#f0f921"
|
||
]
|
||
],
|
||
"type": "histogram2d"
|
||
}
|
||
],
|
||
"histogram2dcontour": [
|
||
{
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
},
|
||
"colorscale": [
|
||
[
|
||
0.0,
|
||
"#0d0887"
|
||
],
|
||
[
|
||
0.1111111111111111,
|
||
"#46039f"
|
||
],
|
||
[
|
||
0.2222222222222222,
|
||
"#7201a8"
|
||
],
|
||
[
|
||
0.3333333333333333,
|
||
"#9c179e"
|
||
],
|
||
[
|
||
0.4444444444444444,
|
||
"#bd3786"
|
||
],
|
||
[
|
||
0.5555555555555556,
|
||
"#d8576b"
|
||
],
|
||
[
|
||
0.6666666666666666,
|
||
"#ed7953"
|
||
],
|
||
[
|
||
0.7777777777777778,
|
||
"#fb9f3a"
|
||
],
|
||
[
|
||
0.8888888888888888,
|
||
"#fdca26"
|
||
],
|
||
[
|
||
1.0,
|
||
"#f0f921"
|
||
]
|
||
],
|
||
"type": "histogram2dcontour"
|
||
}
|
||
],
|
||
"mesh3d": [
|
||
{
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
},
|
||
"type": "mesh3d"
|
||
}
|
||
],
|
||
"parcoords": [
|
||
{
|
||
"line": {
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
}
|
||
},
|
||
"type": "parcoords"
|
||
}
|
||
],
|
||
"pie": [
|
||
{
|
||
"automargin": true,
|
||
"type": "pie"
|
||
}
|
||
],
|
||
"scatter": [
|
||
{
|
||
"fillpattern": {
|
||
"fillmode": "overlay",
|
||
"size": 10,
|
||
"solidity": 0.2
|
||
},
|
||
"type": "scatter"
|
||
}
|
||
],
|
||
"scatter3d": [
|
||
{
|
||
"line": {
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
}
|
||
},
|
||
"marker": {
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
}
|
||
},
|
||
"type": "scatter3d"
|
||
}
|
||
],
|
||
"scattercarpet": [
|
||
{
|
||
"marker": {
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
}
|
||
},
|
||
"type": "scattercarpet"
|
||
}
|
||
],
|
||
"scattergeo": [
|
||
{
|
||
"marker": {
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
}
|
||
},
|
||
"type": "scattergeo"
|
||
}
|
||
],
|
||
"scattergl": [
|
||
{
|
||
"marker": {
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
}
|
||
},
|
||
"type": "scattergl"
|
||
}
|
||
],
|
||
"scattermap": [
|
||
{
|
||
"marker": {
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
}
|
||
},
|
||
"type": "scattermap"
|
||
}
|
||
],
|
||
"scattermapbox": [
|
||
{
|
||
"marker": {
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
}
|
||
},
|
||
"type": "scattermapbox"
|
||
}
|
||
],
|
||
"scatterpolar": [
|
||
{
|
||
"marker": {
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
}
|
||
},
|
||
"type": "scatterpolar"
|
||
}
|
||
],
|
||
"scatterpolargl": [
|
||
{
|
||
"marker": {
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
}
|
||
},
|
||
"type": "scatterpolargl"
|
||
}
|
||
],
|
||
"scatterternary": [
|
||
{
|
||
"marker": {
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
}
|
||
},
|
||
"type": "scatterternary"
|
||
}
|
||
],
|
||
"surface": [
|
||
{
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
},
|
||
"colorscale": [
|
||
[
|
||
0.0,
|
||
"#0d0887"
|
||
],
|
||
[
|
||
0.1111111111111111,
|
||
"#46039f"
|
||
],
|
||
[
|
||
0.2222222222222222,
|
||
"#7201a8"
|
||
],
|
||
[
|
||
0.3333333333333333,
|
||
"#9c179e"
|
||
],
|
||
[
|
||
0.4444444444444444,
|
||
"#bd3786"
|
||
],
|
||
[
|
||
0.5555555555555556,
|
||
"#d8576b"
|
||
],
|
||
[
|
||
0.6666666666666666,
|
||
"#ed7953"
|
||
],
|
||
[
|
||
0.7777777777777778,
|
||
"#fb9f3a"
|
||
],
|
||
[
|
||
0.8888888888888888,
|
||
"#fdca26"
|
||
],
|
||
[
|
||
1.0,
|
||
"#f0f921"
|
||
]
|
||
],
|
||
"type": "surface"
|
||
}
|
||
],
|
||
"table": [
|
||
{
|
||
"cells": {
|
||
"fill": {
|
||
"color": "#EBF0F8"
|
||
},
|
||
"line": {
|
||
"color": "white"
|
||
}
|
||
},
|
||
"header": {
|
||
"fill": {
|
||
"color": "#C8D4E3"
|
||
},
|
||
"line": {
|
||
"color": "white"
|
||
}
|
||
},
|
||
"type": "table"
|
||
}
|
||
]
|
||
},
|
||
"layout": {
|
||
"annotationdefaults": {
|
||
"arrowcolor": "#2a3f5f",
|
||
"arrowhead": 0,
|
||
"arrowwidth": 1
|
||
},
|
||
"autotypenumbers": "strict",
|
||
"coloraxis": {
|
||
"colorbar": {
|
||
"outlinewidth": 0,
|
||
"ticks": ""
|
||
}
|
||
},
|
||
"colorscale": {
|
||
"diverging": [
|
||
[
|
||
0,
|
||
"#8e0152"
|
||
],
|
||
[
|
||
0.1,
|
||
"#c51b7d"
|
||
],
|
||
[
|
||
0.2,
|
||
"#de77ae"
|
||
],
|
||
[
|
||
0.3,
|
||
"#f1b6da"
|
||
],
|
||
[
|
||
0.4,
|
||
"#fde0ef"
|
||
],
|
||
[
|
||
0.5,
|
||
"#f7f7f7"
|
||
],
|
||
[
|
||
0.6,
|
||
"#e6f5d0"
|
||
],
|
||
[
|
||
0.7,
|
||
"#b8e186"
|
||
],
|
||
[
|
||
0.8,
|
||
"#7fbc41"
|
||
],
|
||
[
|
||
0.9,
|
||
"#4d9221"
|
||
],
|
||
[
|
||
1,
|
||
"#276419"
|
||
]
|
||
],
|
||
"sequential": [
|
||
[
|
||
0.0,
|
||
"#0d0887"
|
||
],
|
||
[
|
||
0.1111111111111111,
|
||
"#46039f"
|
||
],
|
||
[
|
||
0.2222222222222222,
|
||
"#7201a8"
|
||
],
|
||
[
|
||
0.3333333333333333,
|
||
"#9c179e"
|
||
],
|
||
[
|
||
0.4444444444444444,
|
||
"#bd3786"
|
||
],
|
||
[
|
||
0.5555555555555556,
|
||
"#d8576b"
|
||
],
|
||
[
|
||
0.6666666666666666,
|
||
"#ed7953"
|
||
],
|
||
[
|
||
0.7777777777777778,
|
||
"#fb9f3a"
|
||
],
|
||
[
|
||
0.8888888888888888,
|
||
"#fdca26"
|
||
],
|
||
[
|
||
1.0,
|
||
"#f0f921"
|
||
]
|
||
],
|
||
"sequentialminus": [
|
||
[
|
||
0.0,
|
||
"#0d0887"
|
||
],
|
||
[
|
||
0.1111111111111111,
|
||
"#46039f"
|
||
],
|
||
[
|
||
0.2222222222222222,
|
||
"#7201a8"
|
||
],
|
||
[
|
||
0.3333333333333333,
|
||
"#9c179e"
|
||
],
|
||
[
|
||
0.4444444444444444,
|
||
"#bd3786"
|
||
],
|
||
[
|
||
0.5555555555555556,
|
||
"#d8576b"
|
||
],
|
||
[
|
||
0.6666666666666666,
|
||
"#ed7953"
|
||
],
|
||
[
|
||
0.7777777777777778,
|
||
"#fb9f3a"
|
||
],
|
||
[
|
||
0.8888888888888888,
|
||
"#fdca26"
|
||
],
|
||
[
|
||
1.0,
|
||
"#f0f921"
|
||
]
|
||
]
|
||
},
|
||
"colorway": [
|
||
"#636efa",
|
||
"#EF553B",
|
||
"#00cc96",
|
||
"#ab63fa",
|
||
"#FFA15A",
|
||
"#19d3f3",
|
||
"#FF6692",
|
||
"#B6E880",
|
||
"#FF97FF",
|
||
"#FECB52"
|
||
],
|
||
"font": {
|
||
"color": "#2a3f5f"
|
||
},
|
||
"geo": {
|
||
"bgcolor": "white",
|
||
"lakecolor": "white",
|
||
"landcolor": "#E5ECF6",
|
||
"showlakes": true,
|
||
"showland": true,
|
||
"subunitcolor": "white"
|
||
},
|
||
"hoverlabel": {
|
||
"align": "left"
|
||
},
|
||
"hovermode": "closest",
|
||
"mapbox": {
|
||
"style": "light"
|
||
},
|
||
"paper_bgcolor": "white",
|
||
"plot_bgcolor": "#E5ECF6",
|
||
"polar": {
|
||
"angularaxis": {
|
||
"gridcolor": "white",
|
||
"linecolor": "white",
|
||
"ticks": ""
|
||
},
|
||
"bgcolor": "#E5ECF6",
|
||
"radialaxis": {
|
||
"gridcolor": "white",
|
||
"linecolor": "white",
|
||
"ticks": ""
|
||
}
|
||
},
|
||
"scene": {
|
||
"xaxis": {
|
||
"backgroundcolor": "#E5ECF6",
|
||
"gridcolor": "white",
|
||
"gridwidth": 2,
|
||
"linecolor": "white",
|
||
"showbackground": true,
|
||
"ticks": "",
|
||
"zerolinecolor": "white"
|
||
},
|
||
"yaxis": {
|
||
"backgroundcolor": "#E5ECF6",
|
||
"gridcolor": "white",
|
||
"gridwidth": 2,
|
||
"linecolor": "white",
|
||
"showbackground": true,
|
||
"ticks": "",
|
||
"zerolinecolor": "white"
|
||
},
|
||
"zaxis": {
|
||
"backgroundcolor": "#E5ECF6",
|
||
"gridcolor": "white",
|
||
"gridwidth": 2,
|
||
"linecolor": "white",
|
||
"showbackground": true,
|
||
"ticks": "",
|
||
"zerolinecolor": "white"
|
||
}
|
||
},
|
||
"shapedefaults": {
|
||
"line": {
|
||
"color": "#2a3f5f"
|
||
}
|
||
},
|
||
"ternary": {
|
||
"aaxis": {
|
||
"gridcolor": "white",
|
||
"linecolor": "white",
|
||
"ticks": ""
|
||
},
|
||
"baxis": {
|
||
"gridcolor": "white",
|
||
"linecolor": "white",
|
||
"ticks": ""
|
||
},
|
||
"bgcolor": "#E5ECF6",
|
||
"caxis": {
|
||
"gridcolor": "white",
|
||
"linecolor": "white",
|
||
"ticks": ""
|
||
}
|
||
},
|
||
"title": {
|
||
"x": 0.05
|
||
},
|
||
"xaxis": {
|
||
"automargin": true,
|
||
"gridcolor": "white",
|
||
"linecolor": "white",
|
||
"ticks": "",
|
||
"title": {
|
||
"standoff": 15
|
||
},
|
||
"zerolinecolor": "white",
|
||
"zerolinewidth": 2
|
||
},
|
||
"yaxis": {
|
||
"automargin": true,
|
||
"gridcolor": "white",
|
||
"linecolor": "white",
|
||
"ticks": "",
|
||
"title": {
|
||
"standoff": 15
|
||
},
|
||
"zerolinecolor": "white",
|
||
"zerolinewidth": 2
|
||
}
|
||
}
|
||
},
|
||
"title": {
|
||
"font": {
|
||
"color": "#0b0b0b",
|
||
"size": 16
|
||
},
|
||
"text": "<b>Scenario comparison — client overlay</b><br><span style='font-size:12px;color:#898781'>adoption × risk-delta stress on the same drivers</span>",
|
||
"x": 0.02,
|
||
"xanchor": "left"
|
||
},
|
||
"xaxis": {
|
||
"linecolor": "#c3c2b7",
|
||
"showgrid": false,
|
||
"tickfont": {
|
||
"color": "#898781"
|
||
},
|
||
"type": "category"
|
||
},
|
||
"yaxis": {
|
||
"gridcolor": "#e1e0d9",
|
||
"tickfont": {
|
||
"color": "#898781"
|
||
},
|
||
"tickformat": "$~s",
|
||
"zerolinecolor": "#c3c2b7",
|
||
"zerolinewidth": 1.5
|
||
}
|
||
}
|
||
}
|
||
},
|
||
"metadata": {},
|
||
"output_type": "display_data"
|
||
}
|
||
],
|
||
"source": [
|
||
"fig = go.Figure()\n",
|
||
"_scen = list(scen_summaries)\n",
|
||
"for _name, _key, _color in [(\"Benefits PV\", \"benefits_pv\", BEN_TOTAL),\n",
|
||
" (\"Costs PV\", \"costs_pv\", COST_TOTAL),\n",
|
||
" (\"NPV\", \"npv\", NPV_COLOR)]:\n",
|
||
" fig.add_trace(bar(_scen, [scen_summaries[s][_key] for s in _scen],\n",
|
||
" _name, _color))\n",
|
||
"fig.update_layout(barmode=\"group\")\n",
|
||
"tei_layout(fig, \"Scenario comparison — client overlay\",\n",
|
||
" subtitle=\"adoption × risk-delta stress on the same drivers\",\n",
|
||
" height=420)\n",
|
||
"fig.show()\n"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"id": "cell-21",
|
||
"metadata": {},
|
||
"source": [
|
||
"<a id=\"section-7\"></a>\n",
|
||
"## 7 · Verification & assertions\n",
|
||
"\n",
|
||
"The gate re-derives the case from the engine and asserts: the verbatim\n",
|
||
"anchor is intact; the engine reproduces Forrester's published totals\n",
|
||
"within table rounding (±$1,000); the overlay is the identity at composite\n",
|
||
"scale; and the structural identities hold at **any** widget setting. It\n",
|
||
"must pass in a headless `nbconvert --execute` run — that is this study's\n",
|
||
"regression check.\n"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": 15,
|
||
"id": "cell-22",
|
||
"metadata": {
|
||
"execution": {
|
||
"iopub.execute_input": "2026-07-09T18:15:43.387984Z",
|
||
"iopub.status.busy": "2026-07-09T18:15:43.387815Z",
|
||
"iopub.status.idle": "2026-07-09T18:15:43.399773Z",
|
||
"shell.execute_reply": "2026-07-09T18:15:43.399196Z"
|
||
}
|
||
},
|
||
"outputs": [
|
||
{
|
||
"name": "stdout",
|
||
"output_type": "stream",
|
||
"text": [
|
||
"All assertions passed.\n",
|
||
" reproduction Δ vs PDF: benefits -223.45 · costs -0.22 · npv -223.22\n"
|
||
]
|
||
}
|
||
],
|
||
"source": [
|
||
"def _approx(got, want, tol=0.5):\n",
|
||
" assert abs(got - want) <= tol, f\"got {got:,.2f}, want {want:,.2f}\"\n",
|
||
"\n",
|
||
"\n",
|
||
"# ── Anchor integrity — the verbatim record is intact (unconditional) ─\n",
|
||
"_approx(BENEFITS_VERBATIM[0][\"year_values\"][\"1\"], 13_911_040)\n",
|
||
"_approx(COSTS_VERBATIM[1][\"initial\"], 1_087_500)\n",
|
||
"assert ASSUMPTIONS[\"agents_fte\"] == 2_000\n",
|
||
"assert ASSUMPTIONS[\"annual_contacts_y1\"] == 20_000_000\n",
|
||
"assert (COMPOSITE.agents_fte, COMPOSITE.annual_contacts_y1) == (2_000, 20_000_000)\n",
|
||
"\n",
|
||
"# ── Published reproduction — engine defaults, explicit args ──────────\n",
|
||
"_c = compute_summary(BENEFITS_VERBATIM, COSTS_VERBATIM, 0.10)\n",
|
||
"_approx(_c[\"benefits_pv\"], PUBLISHED[\"benefits_pv\"], tol=1_000) # Δ −223.45 (PDF rounding)\n",
|
||
"_approx(_c[\"costs_pv\"], PUBLISHED[\"costs_pv\"], tol=1_000) # Δ −0.22\n",
|
||
"_approx(_c[\"npv\"], PUBLISHED[\"npv\"], tol=1_000) # Δ −223.22\n",
|
||
"assert round(_c[\"roi_pct\"]) == PUBLISHED[\"roi_pct\"] # 342.48 → 342\n",
|
||
"assert _c[\"payback_months\"] < PUBLISHED[\"payback_months_max\"] # \"<6 months\"\n",
|
||
"_approx(_c[\"payback_months\"], 0.72, tol=0.01)\n",
|
||
"_approx(_c[\"initial_costs\"], 1_196_250)\n",
|
||
"_approx(_c[\"benefits_by_year\"][2026], 27_279_019, tol=1)\n",
|
||
"_approx(_c[\"costs_by_year\"][2028], 10_539_889.05, tol=1)\n",
|
||
"\n",
|
||
"# ── Overlay identity + scaling behaviour (explicit args) ─────────────\n",
|
||
"_ob, _oc = overlay_rows(COMPOSITE)\n",
|
||
"_id = compute_summary(_ob, _oc, 0.10)\n",
|
||
"_approx(_id[\"benefits_pv\"], _c[\"benefits_pv\"], tol=0.01) # identity at composite\n",
|
||
"_hb, _ = overlay_rows(ClientDrivers(agents_fte=1_000))\n",
|
||
"_approx(next(r for r in _hb if r[\"field_key\"] == \"ai_forecasting_supervision\")\n",
|
||
" [\"year_values\"][\"1\"], 6_651_680 / 2) # agents-driven halves\n",
|
||
"_approx(next(r for r in _hb if r[\"field_key\"] == \"ai_contact_resolution\")\n",
|
||
" [\"year_values\"][\"1\"], 13_911_040) # contacts-driven unmoved\n",
|
||
"\n",
|
||
"# ── Scenario pin (explicit args) ─────────────────────────────────────\n",
|
||
"_s = compute_summary(apply_scenario(BENEFITS_VERBATIM, \"conservative\"),\n",
|
||
" apply_scenario(COSTS_VERBATIM, \"conservative\"), 0.10)\n",
|
||
"_approx(_s[\"npv\"], 54_234_951.31, tol=1)\n",
|
||
"\n",
|
||
"# ── Structural ties — hold at ANY widget state (unconditional) ───────\n",
|
||
"_approx(client[\"npv\"], client[\"benefits_pv\"] - client[\"costs_pv\"], tol=0.01)\n",
|
||
"_approx(client[\"roi_pct\"], client[\"npv\"] / client[\"costs_pv\"] * 100, tol=0.01)\n",
|
||
"for _y in YEARS:\n",
|
||
" _approx(client[\"net_by_year\"][_y],\n",
|
||
" client[\"benefits_by_year\"][_y] - client[\"costs_by_year\"][_y], tol=0.01)\n",
|
||
"_approx(client[\"cumulative_net_by_year\"][YEARS[-1]],\n",
|
||
" sum(client[\"net_by_year\"].values()) - client[\"initial_costs\"], tol=0.01)\n",
|
||
"_approx(sum(r[\"pv\"] for r in client[\"rows\"][\"benefits\"]), client[\"benefits_pv\"], tol=0.01)\n",
|
||
"_approx(sum(r[\"pv\"] for r in client[\"rows\"][\"costs\"]), client[\"costs_pv\"], tol=0.01)\n",
|
||
"\n",
|
||
"# ── Live state — only when the sidebar sits at the composite defaults ─\n",
|
||
"if _at_default:\n",
|
||
" _approx(client[\"benefits_pv\"], 101_696_567.55, tol=1) # engine-exact\n",
|
||
" _approx(client[\"npv\"], PUBLISHED[\"npv\"], tol=1_000)\n",
|
||
" _approx(client[\"payback_months\"], 0.72, tol=0.01)\n",
|
||
"\n",
|
||
"backstage(\"All assertions passed.\")\n",
|
||
"backstage(f\" reproduction Δ vs PDF: benefits \"\n",
|
||
" f\"{_c['benefits_pv'] - PUBLISHED['benefits_pv']:+,.2f} · \"\n",
|
||
" f\"costs {_c['costs_pv'] - PUBLISHED['costs_pv']:+,.2f} · \"\n",
|
||
" f\"npv {_c['npv'] - PUBLISHED['npv']:+,.2f}\")\n"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "markdown",
|
||
"id": "cell-23",
|
||
"metadata": {},
|
||
"source": [
|
||
"<a id=\"section-8\"></a>\n",
|
||
"## 8 · Data appendix — for the machines\n",
|
||
"\n",
|
||
"Everything below renders **backstage only** (JupyterLab / nbconvert\n",
|
||
"exports): markdown tables plus one JSON block of model state. Carried in\n",
|
||
"the exports, this is the payload a downstream LLM — or, on the roadmap,\n",
|
||
"Athena as the study repository — consumes directly. On the Mercury stage\n",
|
||
"it stays hidden.\n"
|
||
]
|
||
},
|
||
{
|
||
"cell_type": "code",
|
||
"execution_count": 16,
|
||
"id": "cell-24",
|
||
"metadata": {
|
||
"execution": {
|
||
"iopub.execute_input": "2026-07-09T18:15:43.404067Z",
|
||
"iopub.status.busy": "2026-07-09T18:15:43.403921Z",
|
||
"iopub.status.idle": "2026-07-09T18:15:43.419260Z",
|
||
"shell.execute_reply": "2026-07-09T18:15:43.418439Z"
|
||
}
|
||
},
|
||
"outputs": [
|
||
{
|
||
"name": "stdout",
|
||
"output_type": "stream",
|
||
"text": [
|
||
"\n",
|
||
"#### Composite organization (verbatim assumptions 🟢)\n",
|
||
"\n",
|
||
"| Assumption | Value 🟢 |\n",
|
||
"|:-------------------|:-----------|\n",
|
||
"| agents fte | 2,000 |\n",
|
||
"| supervisors fte | 200 |\n",
|
||
"| annual contacts y1 | 20,000,000 |\n",
|
||
"| growth rate | 30% |\n",
|
||
"| call share | 75% |\n",
|
||
"| aht legacy minutes | 10 min |\n",
|
||
"| agent salary | $45,760 |\n",
|
||
"| supervisor salary | $55,800 |\n",
|
||
"| discount rate | 10% |\n",
|
||
"| analysis years | 3 years |\n",
|
||
"\n",
|
||
"#### Benefits — client overlay (risk-adjusted $)\n",
|
||
"\n",
|
||
"| Benefit | Driver | Risk adj | 2026 | 2027 | 2028 | 3-yr RA | PV |\n",
|
||
"|:----------------------------------------------------------|:---------|:-----------|-----------:|-----------:|-----------:|------------:|------------:|\n",
|
||
"| AI-driven contact resolution efficiency | contacts | -15% | 11,824,384 | 20,342,608 | 32,128,096 | 64,295,088 | 51,699,827 |\n",
|
||
"| AI-powered content and sentiment analysis savings | contacts | -15% | 3,898,627 | 4,554,650 | 5,347,928 | 13,801,205 | 11,326,358 |\n",
|
||
"| AI-enabled forecasting, agent scheduling, and supervision | agents | -15% | 5,653,928 | 7,763,696 | 10,532,955 | 23,950,579 | 19,469,777 |\n",
|
||
"| Data-driven profit lift with increased conversion | contacts | -20% | 960,000 | 1,248,000 | 1,622,400 | 3,830,400 | 3,123,065 |\n",
|
||
"| Legacy solution cost savings | agents | -20% | 4,942,080 | 6,424,704 | 8,352,115 | 19,718,899 | 16,077,540 |\n",
|
||
"| TOTAL | | | 27,279,019 | 40,333,658 | 57,983,494 | 125,596,172 | 101,696,568 |\n",
|
||
"\n",
|
||
"#### Costs — client overlay (risk-adjusted $)\n",
|
||
"\n",
|
||
"| Cost | Driver | Risk adj | Initial | 2026 | 2027 | 2028 | PV |\n",
|
||
"|:----------------------------------|:---------|:-----------|----------:|----------:|----------:|-----------:|-----------:|\n",
|
||
"| Amazon Connect usage cost | contacts | +5% | 0 | 6,779,270 | 8,348,722 | 10,324,609 | 20,819,775 |\n",
|
||
"| Implementation and migration cost | fixed | +10% | 1,196,250 | 207,166 | 207,166 | 0 | 1,555,795 |\n",
|
||
"| Ongoing management | fixed | +15% | 0 | 294,630 | 215,280 | 215,280 | 607,506 |\n",
|
||
"| TOTAL | | | 1,196,250 | 7,281,067 | 8,771,168 | 10,539,889 | 22,983,076 |\n",
|
||
"\n",
|
||
"#### KPIs — published composite vs client overlay\n",
|
||
"\n",
|
||
"| | Forrester composite (published 🟢) | Client overlay (🟡) |\n",
|
||
"|:--------------|:-------------------------------------|:-----------------------|\n",
|
||
"| Benefits PV | $101,696,791 | $101,696,568 |\n",
|
||
"| Costs PV | $22,983,076 | $22,983,076 |\n",
|
||
"| NPV | $78,713,715 | $78,713,492 |\n",
|
||
"| ROI | 342% | 342% |\n",
|
||
"| Payback | <6 months | 0.7 months (~Jan 2026) |\n",
|
||
"| Discount rate | 10% | 10% |\n",
|
||
"\n",
|
||
"#### Scenarios (client overlay)\n",
|
||
"\n",
|
||
"| Scenario | Adoption | Risk Δ | Benefits PV | Costs PV | NPV | ROI % | Payback (months) |\n",
|
||
"|:-------------|:-----------|:---------|--------------:|-----------:|-----------:|--------:|-------------------:|\n",
|
||
"| conservative | 80% | +10% | 71,672,868 | 17,437,916 | 54,234,951 | 311 | 1 |\n",
|
||
"| moderate | 100% | +0% | 101,696,568 | 22,983,076 | 78,713,492 | 342 | 1 |\n",
|
||
"| aggressive | 115% | -5% | 123,911,705 | 27,682,369 | 96,229,337 | 348 | 1 |\n",
|
||
"\n",
|
||
"#### Model state (JSON)\n",
|
||
"\n",
|
||
"```json\n",
|
||
"{\n",
|
||
" \"study\": \"202602_TEI_Amazon_Connect\",\n",
|
||
" \"source\": \"Forrester TEI of Amazon Connect (Feb 2026, commissioned by AWS)\",\n",
|
||
" \"published\": {\n",
|
||
" \"benefits_pv\": 101696791,\n",
|
||
" \"costs_pv\": 22983076,\n",
|
||
" \"npv\": 78713715,\n",
|
||
" \"roi_pct\": 342,\n",
|
||
" \"payback_months_max\": 6,\n",
|
||
" \"discount_rate\": 0.1,\n",
|
||
" \"analysis_years\": 3\n",
|
||
" },\n",
|
||
" \"reproduction\": {\n",
|
||
" \"benefits_pv\": 101696567.55,\n",
|
||
" \"costs_pv\": 22983075.78,\n",
|
||
" \"npv\": 78713491.78,\n",
|
||
" \"roi_pct\": 342.48,\n",
|
||
" \"payback_months\": 0.72\n",
|
||
" },\n",
|
||
" \"client\": {\n",
|
||
" \"drivers\": {\n",
|
||
" \"agents_fte\": 2000,\n",
|
||
" \"annual_contacts_y1\": 20000000,\n",
|
||
" \"growth_rate\": 0.3,\n",
|
||
" \"discount_rate\": 0.1,\n",
|
||
" \"scenario\": \"moderate\"\n",
|
||
" },\n",
|
||
" \"benefits_by_year\": {\n",
|
||
" \"2026\": 27279019,\n",
|
||
" \"2027\": 40333658,\n",
|
||
" \"2028\": 57983494\n",
|
||
" },\n",
|
||
" \"costs_by_year\": {\n",
|
||
" \"2026\": 7281067,\n",
|
||
" \"2027\": 8771168,\n",
|
||
" \"2028\": 10539889\n",
|
||
" },\n",
|
||
" \"net_by_year\": {\n",
|
||
" \"2026\": 19997952,\n",
|
||
" \"2027\": 31562490,\n",
|
||
" \"2028\": 47443605\n",
|
||
" },\n",
|
||
" \"cumulative_net_by_year\": {\n",
|
||
" \"2026\": 18801702,\n",
|
||
" \"2027\": 50364192,\n",
|
||
" \"2028\": 97807797\n",
|
||
" },\n",
|
||
" \"initial_costs\": 1196250,\n",
|
||
" \"kpis\": {\n",
|
||
" \"benefits_pv\": 101696568,\n",
|
||
" \"costs_pv\": 22983076,\n",
|
||
" \"npv\": 78713492,\n",
|
||
" \"roi_pct\": 342.48,\n",
|
||
" \"payback_months\": 0.72,\n",
|
||
" \"payback_label\": \"0.7 months (~Jan 2026)\"\n",
|
||
" },\n",
|
||
" \"scenarios\": {\n",
|
||
" \"conservative\": {\n",
|
||
" \"benefits_pv\": 71672868,\n",
|
||
" \"costs_pv\": 17437916,\n",
|
||
" \"npv\": 54234951,\n",
|
||
" \"roi_pct\": 311.02\n",
|
||
" },\n",
|
||
" \"moderate\": {\n",
|
||
" \"benefits_pv\": 101696568,\n",
|
||
" \"costs_pv\": 22983076,\n",
|
||
" \"npv\": 78713492,\n",
|
||
" \"roi_pct\": 342.48\n",
|
||
" },\n",
|
||
" \"aggressive\": {\n",
|
||
" \"benefits_pv\": 123911705,\n",
|
||
" \"costs_pv\": 27682369,\n",
|
||
" \"npv\": 96229337,\n",
|
||
" \"roi_pct\": 347.62\n",
|
||
" }\n",
|
||
" }\n",
|
||
" },\n",
|
||
" \"driver_map\": {\n",
|
||
" \"benefits\": {\n",
|
||
" \"ai_contact_resolution\": \"contacts\",\n",
|
||
" \"ai_content_sentiment\": \"contacts\",\n",
|
||
" \"ai_forecasting_supervision\": \"agents\",\n",
|
||
" \"data_driven_profit_lift\": \"contacts\",\n",
|
||
" \"legacy_solution_savings\": \"agents\"\n",
|
||
" },\n",
|
||
" \"costs\": {\n",
|
||
" \"amazon_connect_usage\": \"contacts\",\n",
|
||
" \"implementation_migration\": \"fixed\",\n",
|
||
" \"ongoing_management\": \"fixed\"\n",
|
||
" }\n",
|
||
" }\n",
|
||
"}\n",
|
||
"```\n"
|
||
]
|
||
}
|
||
],
|
||
"source": [
|
||
"# ── Data appendix — LLM-readable dump of every model output ──────────\n",
|
||
"# Renders backstage only (JupyterLab / nbconvert exports) — hidden on\n",
|
||
"# the Mercury stage, where the narrative and figures carry the story.\n",
|
||
"import json as _json\n",
|
||
"\n",
|
||
"\n",
|
||
"def _section(title, df, **kw):\n",
|
||
" backstage(f\"\\n#### {title}\\n\")\n",
|
||
" backstage(df.to_markdown(floatfmt=\",.0f\", **kw))\n",
|
||
"\n",
|
||
"\n",
|
||
"_section(\"Composite organization (verbatim assumptions 🟢)\",\n",
|
||
" assumptions_df, index=False)\n",
|
||
"_section(\"Benefits — client overlay (risk-adjusted $)\", benefits_df)\n",
|
||
"_section(\"Costs — client overlay (risk-adjusted $)\", costs_df)\n",
|
||
"_section(\"KPIs — published composite vs client overlay\", kpis_fmt)\n",
|
||
"_section(\"Scenarios (client overlay)\", scen_df)\n",
|
||
"\n",
|
||
"backstage(\"\\n#### Model state (JSON)\\n\")\n",
|
||
"backstage(\"```json\")\n",
|
||
"backstage(_json.dumps({\n",
|
||
" \"study\": \"202602_TEI_Amazon_Connect\",\n",
|
||
" \"source\": \"Forrester TEI of Amazon Connect (Feb 2026, commissioned by AWS)\",\n",
|
||
" \"published\": PUBLISHED,\n",
|
||
" \"reproduction\": {\n",
|
||
" \"benefits_pv\": round(composite[\"benefits_pv\"], 2),\n",
|
||
" \"costs_pv\": round(composite[\"costs_pv\"], 2),\n",
|
||
" \"npv\": round(composite[\"npv\"], 2),\n",
|
||
" \"roi_pct\": round(composite[\"roi_pct\"], 2),\n",
|
||
" \"payback_months\": round(composite[\"payback_months\"], 2),\n",
|
||
" },\n",
|
||
" \"client\": {\n",
|
||
" \"drivers\": {\n",
|
||
" \"agents_fte\": AGENTS_FTE,\n",
|
||
" \"annual_contacts_y1\": CONTACTS_Y1,\n",
|
||
" \"growth_rate\": GROWTH_RATE,\n",
|
||
" \"discount_rate\": DISCOUNT_RATE,\n",
|
||
" \"scenario\": SCENARIO,\n",
|
||
" },\n",
|
||
" \"benefits_by_year\": {str(y): round(client[\"benefits_by_year\"][y]) for y in YEARS},\n",
|
||
" \"costs_by_year\": {str(y): round(client[\"costs_by_year\"][y]) for y in YEARS},\n",
|
||
" \"net_by_year\": {str(y): round(client[\"net_by_year\"][y]) for y in YEARS},\n",
|
||
" \"cumulative_net_by_year\": {str(y): round(client[\"cumulative_net_by_year\"][y]) for y in YEARS},\n",
|
||
" \"initial_costs\": round(client[\"initial_costs\"]),\n",
|
||
" \"kpis\": {\n",
|
||
" \"benefits_pv\": round(client[\"benefits_pv\"]),\n",
|
||
" \"costs_pv\": round(client[\"costs_pv\"]),\n",
|
||
" \"npv\": round(client[\"npv\"]),\n",
|
||
" \"roi_pct\": round(client[\"roi_pct\"], 2),\n",
|
||
" \"payback_months\": round(client[\"payback_months\"], 2)\n",
|
||
" if client[\"payback_months\"] is not None else None,\n",
|
||
" \"payback_label\": client[\"payback_label\"],\n",
|
||
" },\n",
|
||
" \"scenarios\": {\n",
|
||
" s: {\"benefits_pv\": round(r[\"benefits_pv\"]),\n",
|
||
" \"costs_pv\": round(r[\"costs_pv\"]),\n",
|
||
" \"npv\": round(r[\"npv\"]),\n",
|
||
" \"roi_pct\": round(r[\"roi_pct\"], 2)}\n",
|
||
" for s, r in scen_summaries.items()\n",
|
||
" },\n",
|
||
" },\n",
|
||
" \"driver_map\": {\"benefits\": BENEFIT_DRIVERS, \"costs\": COST_DRIVERS},\n",
|
||
"}, indent=2))\n",
|
||
"backstage(\"```\")\n"
|
||
]
|
||
}
|
||
],
|
||
"metadata": {
|
||
"kernelspec": {
|
||
"display_name": "Python 3 (ipykernel)",
|
||
"language": "python",
|
||
"name": "python3"
|
||
},
|
||
"language_info": {
|
||
"codemirror_mode": {
|
||
"name": "ipython",
|
||
"version": 3
|
||
},
|
||
"file_extension": ".py",
|
||
"mimetype": "text/x-python",
|
||
"name": "python",
|
||
"nbconvert_exporter": "python",
|
||
"pygments_lexer": "ipython3",
|
||
"version": "3.13.7"
|
||
},
|
||
"widgets": {
|
||
"application/vnd.jupyter.widget-state+json": {
|
||
"state": {
|
||
"01c230d30aed4aa58c5a005c44e65f84": {
|
||
"model_module": "anywidget",
|
||
"model_module_version": "~0.11.*",
|
||
"model_name": "AnyModel",
|
||
"state": {
|
||
"_anywidget_id": "mercury.number.NumberInputWidget",
|
||
"_css": "\n .mljar-number-container {\n display: flex;\n flex-direction: column;\n width: 100%;\n font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;\n font-size: 14px;\n color: #0f172a;\n padding-left: 4px;\n padding-right: 4px;\n box-sizing: border-box;\n }\n\n .mljar-number-label {\n padding-top: 6px;\n margin-bottom: 4px;\n font-weight: 600;\n }\n\n .mljar-number-field-row {\n display: flex;\n align-items: stretch;\n width: 100%;\n min-height: 40px;\n border: 1px solid #cfd1d5;\n border-radius: 6px;\n background: #ffffff;\n box-sizing: border-box;\n overflow: hidden;\n }\n\n .mljar-number-input {\n flex: 1 1 auto;\n min-width: 0;\n min-height: 100%;\n padding: 7px 10px;\n border: 0;\n border-radius: 0;\n background: #ffffff;\n box-sizing: border-box;\n background-color: #ffffff !important;\n color: #0f172a !important;\n font: inherit;\n line-height: 1.2;\n -moz-appearance: textfield;\n }\n\n .mljar-number-input::-webkit-outer-spin-button,\n .mljar-number-input::-webkit-inner-spin-button {\n -webkit-appearance: none;\n margin: 0;\n }\n\n .mljar-number-input:disabled {\n background: #f5f5f5;\n color: #888;\n cursor: not-allowed;\n }\n\n .mljar-number-input:focus {\n outline: none;\n }\n\n .mljar-number-field-row:focus-within {\n border-color: #007bff;\n box-shadow: none;\n }\n\n .mljar-number-field-row:focus-within .mljar-number-controls {\n border-left-color: #007bff;\n }\n\n .mljar-number-controls {\n display: flex;\n align-items: stretch;\n flex: 0 0 auto;\n border-left: 1px solid #cfd1d5;\n background: #f1f1f2;\n }\n\n .mljar-number-step-btn {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 38px;\n min-width: 38px;\n min-height: 100%;\n border: 0;\n border-radius: 0;\n background: transparent;\n color: #0f172a;\n font: inherit;\n font-size: 18px;\n font-weight: 700;\n line-height: 1;\n cursor: pointer;\n padding: 0;\n user-select: none;\n -webkit-user-select: none;\n touch-action: manipulation;\n transition: background-color 0.14s ease, color 0.14s ease;\n }\n\n .mljar-number-step-up {\n border-left: 1px solid #cfd1d5;\n }\n\n .mljar-number-step-btn:hover {\n background: #f3f3f4;\n }\n\n .mljar-number-step-btn:active {\n background: #e6f2ff;\n color: #007bff;\n }\n\n .mljar-number-step-btn:focus-visible {\n outline: none;\n background: #e6f2ff;\n color: #007bff;\n }\n\n .mljar-number-step-btn:disabled {\n background: #f5f5f5;\n color: #aaa;\n cursor: not-allowed;\n }\n\n @media (max-width: 768px) {\n .mljar-number-field-row {\n min-height: 44px;\n }\n\n .mljar-number-input {\n min-height: 44px;\n padding: 8px 12px;\n }\n\n .mljar-number-step-btn {\n font-size: 19px;\n width: 44px;\n min-width: 44px;\n }\n }\n ",
|
||
"_dom_classes": [],
|
||
"_esm": "\n function render({ model, el }) {\n const container = document.createElement(\"div\");\n container.classList.add(\"mljar-number-container\");\n\n const topLabel = document.createElement(\"div\");\n topLabel.classList.add(\"mljar-number-label\");\n\n const fieldRow = document.createElement(\"div\");\n fieldRow.classList.add(\"mljar-number-field-row\");\n\n const input = document.createElement(\"input\");\n input.type = \"number\";\n input.classList.add(\"mljar-number-input\");\n\n const decrementBtn = document.createElement(\"button\");\n decrementBtn.type = \"button\";\n decrementBtn.classList.add(\"mljar-number-step-btn\", \"mljar-number-step-down\");\n decrementBtn.textContent = \"-\";\n decrementBtn.setAttribute(\"aria-label\", \"Decrease value\");\n\n const incrementBtn = document.createElement(\"button\");\n incrementBtn.type = \"button\";\n incrementBtn.classList.add(\"mljar-number-step-btn\", \"mljar-number-step-up\");\n incrementBtn.textContent = \"+\";\n incrementBtn.setAttribute(\"aria-label\", \"Increase value\");\n\n const controls = document.createElement(\"div\");\n controls.classList.add(\"mljar-number-controls\");\n\n controls.appendChild(decrementBtn);\n controls.appendChild(incrementBtn);\n fieldRow.appendChild(input);\n fieldRow.appendChild(controls);\n\n container.appendChild(topLabel);\n container.appendChild(fieldRow);\n el.appendChild(container);\n\n function clamp(val, min, max) {\n if (Number.isFinite(min) && val < min) return min;\n if (Number.isFinite(max) && val > max) return max;\n return val;\n }\n\n function normalizeStep(step) {\n return Number.isFinite(step) && step > 0 ? step : 1;\n }\n\n function snapToStep(value, min, step) {\n const safeStep = normalizeStep(step);\n const base = Number.isFinite(min) ? min : 0;\n const steps = Math.round((value - base) / safeStep);\n const snapped = base + steps * safeStep;\n const precision = Math.max(\n 0,\n (String(safeStep).split(\".\")[1] || \"\").length\n );\n\n return Number(snapped.toFixed(precision + 2));\n }\n\n function isOnStepGrid(value, min, step) {\n const safeStep = normalizeStep(step);\n const base = Number.isFinite(min) ? min : 0;\n const steps = (value - base) / safeStep;\n const nearest = Math.round(steps);\n const epsilon = Math.max(1e-9, safeStep * 1e-9);\n\n return Math.abs(steps - nearest) <= epsilon;\n }\n\n function getCurrentBounds() {\n return {\n min: Number(model.get(\"min\")),\n max: Number(model.get(\"max\")),\n };\n }\n\n function isTransientDraft(raw) {\n return raw === \"\" || raw === \"-\" || raw === \".\" || raw === \"-.\";\n }\n\n let isEditing = false;\n const INPUT_COMMIT_DEBOUNCE_MS = 400;\n\n function clearPendingDraftCommit() {\n if (debounceTimer) clearTimeout(debounceTimer);\n pendingDraftValue = null;\n }\n\n function parseDraftValue(rawValue) {\n const raw = String(rawValue).trim();\n if (isTransientDraft(raw)) {\n return { kind: \"transient\" };\n }\n\n const value = Number(raw);\n if (!Number.isFinite(value)) {\n return { kind: \"invalid\" };\n }\n\n return { kind: \"number\", value };\n }\n\n function commitValue(nextValue, saveNow = true) {\n const { min, max } = getCurrentBounds();\n const step = Number(model.get(\"step\"));\n const parsed = parseDraftValue(nextValue);\n if (parsed.kind !== \"number\") {\n syncFromModel();\n return;\n }\n\n let v = parsed.value;\n v = clamp(v, min, max);\n v = snapToStep(v, min, step);\n v = clamp(v, min, max);\n input.value = String(v);\n model.set(\"value\", v);\n\n if (saveNow) {\n model.save_changes();\n }\n }\n\n function syncFromModel() {\n topLabel.innerHTML = model.get(\"label\") || \"Enter number\";\n\n const min = Number(model.get(\"min\"));\n const max = Number(model.get(\"max\"));\n const step = Number(model.get(\"step\"));\n\n if (Number.isFinite(min)) input.min = String(min); else input.removeAttribute(\"min\");\n if (Number.isFinite(max)) input.max = String(max); else input.removeAttribute(\"max\");\n if (Number.isFinite(step)) input.step = String(step); else input.removeAttribute(\"step\");\n\n const v = Number(model.get(\"value\"));\n if (!isEditing) {\n input.value = Number.isFinite(v) ? String(v) : \"\";\n }\n\n const disabled = !!model.get(\"disabled\");\n input.disabled = disabled;\n incrementBtn.disabled = disabled;\n decrementBtn.disabled = disabled;\n\n const hidden = !!model.get(\"hidden\");\n container.style.display = hidden ? \"none\" : \"flex\";\n }\n\n let debounceTimer = null;\n let pendingDraftValue = null;\n input.addEventListener(\"focus\", () => {\n isEditing = true;\n });\n\n input.addEventListener(\"input\", () => {\n if (model.get(\"disabled\")) return;\n\n const parsed = parseDraftValue(input.value);\n if (parsed.kind !== \"number\") {\n clearPendingDraftCommit();\n return;\n }\n\n const v = parsed.value;\n const { min, max } = getCurrentBounds();\n const step = Number(model.get(\"step\"));\n if (Number.isFinite(min) && v < min) {\n clearPendingDraftCommit();\n return;\n }\n if (Number.isFinite(max) && v > max) {\n clearPendingDraftCommit();\n return;\n }\n if (!isOnStepGrid(v, min, step)) {\n clearPendingDraftCommit();\n return;\n }\n\n pendingDraftValue = v;\n if (debounceTimer) clearTimeout(debounceTimer);\n debounceTimer = setTimeout(() => {\n if (pendingDraftValue === null) return;\n model.set(\"value\", pendingDraftValue);\n model.save_changes();\n pendingDraftValue = null;\n }, INPUT_COMMIT_DEBOUNCE_MS);\n });\n\n input.addEventListener(\"blur\", () => {\n isEditing = false;\n clearPendingDraftCommit();\n commitValue(input.value, true);\n });\n\n input.addEventListener(\"keydown\", event => {\n if (event.key === \"Enter\") {\n event.preventDefault();\n input.blur();\n }\n });\n\n incrementBtn.addEventListener(\"click\", () => {\n if (model.get(\"disabled\")) return;\n const current = Number(model.get(\"value\"));\n const step = normalizeStep(Number(model.get(\"step\")));\n const min = Number(model.get(\"min\"));\n const base = Number.isFinite(current) ? current : 0;\n commitValue(snapToStep(base + step, min, step));\n });\n\n decrementBtn.addEventListener(\"click\", () => {\n if (model.get(\"disabled\")) return;\n const current = Number(model.get(\"value\"));\n const step = normalizeStep(Number(model.get(\"step\")));\n const min = Number(model.get(\"min\"));\n const base = Number.isFinite(current) ? current : 0;\n commitValue(snapToStep(base - step, min, step));\n });\n\n model.on(\"change:value\", syncFromModel);\n model.on(\"change:min\", syncFromModel);\n model.on(\"change:max\", syncFromModel);\n model.on(\"change:step\", syncFromModel);\n model.on(\"change:label\", syncFromModel);\n model.on(\"change:disabled\", syncFromModel);\n model.on(\"change:hidden\", syncFromModel);\n\n syncFromModel();\n\n // ---- read cell id (no DOM modifications) ----\n /*\n const ID_ATTR = \"data-cell-id\";\n const hostWithId = el.closest(`[${ID_ATTR}]`);\n const cellId = hostWithId ? hostWithId.getAttribute(ID_ATTR) : null;\n\n if (cellId) {\n model.set(\"cell_id\", cellId);\n model.save_changes();\n model.send({ type: \"cell_id_detected\", value: cellId });\n } else {\n const mo = new MutationObserver(() => {\n const host = el.closest(`[${ID_ATTR}]`);\n const newId = host?.getAttribute(ID_ATTR);\n if (newId) {\n model.set(\"cell_id\", newId);\n model.save_changes();\n model.send({ type: \"cell_id_detected\", value: newId });\n mo.disconnect();\n }\n });\n mo.observe(document.body, { attributes: true, subtree: true, attributeFilter: [ID_ATTR] });\n }*/\n }\n export default { render };\n ",
|
||
"_model_module": "anywidget",
|
||
"_model_module_version": "~0.11.*",
|
||
"_model_name": "AnyModel",
|
||
"_view_count": null,
|
||
"_view_module": "anywidget",
|
||
"_view_module_version": "~0.11.*",
|
||
"_view_name": "AnyView",
|
||
"cell_id": "",
|
||
"disabled": false,
|
||
"hidden": false,
|
||
"label": "Annual contacts, year 1 — composite 20M",
|
||
"layout": "IPY_MODEL_cc4716028c0c4335b1d70f4e29e905f6",
|
||
"layout_path": null,
|
||
"max": 500000000.0,
|
||
"min": 100000.0,
|
||
"position": "sidebar",
|
||
"render_slot_id": null,
|
||
"source_cell_id": null,
|
||
"step": 1000000.0,
|
||
"tabbable": null,
|
||
"tooltip": null,
|
||
"url_key": "",
|
||
"value": 20000000.0
|
||
}
|
||
},
|
||
"0833fb2c5bd3466aba5ff466dec6ff1b": {
|
||
"model_module": "@jupyter-widgets/controls",
|
||
"model_module_version": "2.0.0",
|
||
"model_name": "HTMLModel",
|
||
"state": {
|
||
"_dom_classes": [],
|
||
"_model_module": "@jupyter-widgets/controls",
|
||
"_model_module_version": "2.0.0",
|
||
"_model_name": "HTMLModel",
|
||
"_view_count": null,
|
||
"_view_module": "@jupyter-widgets/controls",
|
||
"_view_module_version": "2.0.0",
|
||
"_view_name": "HTMLView",
|
||
"cell_id": "",
|
||
"description": "",
|
||
"description_allow_html": false,
|
||
"layout": "IPY_MODEL_ae315fb739f643f7a1fbe0f3678a6002",
|
||
"layout_path": null,
|
||
"placeholder": "",
|
||
"position": "sidebar",
|
||
"render_slot_id": null,
|
||
"source_cell_id": null,
|
||
"style": "IPY_MODEL_f9174ef9b9674e9d8c64bd888d70130b",
|
||
"tabbable": null,
|
||
"tooltip": null,
|
||
"value": "<div style=\"font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; font-size: 14px; font-weight: normal; line-height: 1.65; color: #0f172a; word-break: break-word;\"><p style=\"margin: 0 0 1em;\"><b>Jump to section</b><ol style=\"padding-left:1.2em;margin:6px 0; margin: 0 0 1em; padding-left: 1.4em;\"><li style=\"margin:2px 0\"><a style=\"cursor:pointer;text-decoration:underline; color: #007bff; text-decoration: underline; text-underline-offset: 0.14em;\" onclick=\"var el=document.getElementById('section-1');if(!el){document.querySelectorAll('h1,h2').forEach(function(h){if(!el&&h.textContent.trim().indexOf('1 ')===0){el=h;}});}if(el)el.scrollIntoView({behavior:'smooth',block:'start'});\">Composite organization</a></li><li style=\"margin:2px 0\"><a style=\"cursor:pointer;text-decoration:underline; color: #007bff; text-decoration: underline; text-underline-offset: 0.14em;\" onclick=\"var el=document.getElementById('section-2');if(!el){document.querySelectorAll('h1,h2').forEach(function(h){if(!el&&h.textContent.trim().indexOf('2 ')===0){el=h;}});}if(el)el.scrollIntoView({behavior:'smooth',block:'start'});\">Client inputs</a></li><li style=\"margin:2px 0\"><a style=\"cursor:pointer;text-decoration:underline; color: #007bff; text-decoration: underline; text-underline-offset: 0.14em;\" onclick=\"var el=document.getElementById('section-3');if(!el){document.querySelectorAll('h1,h2').forEach(function(h){if(!el&&h.textContent.trim().indexOf('3 ')===0){el=h;}});}if(el)el.scrollIntoView({behavior:'smooth',block:'start'});\">Benefits</a></li><li style=\"margin:2px 0\"><a style=\"cursor:pointer;text-decoration:underline; color: #007bff; text-decoration: underline; text-underline-offset: 0.14em;\" onclick=\"var el=document.getElementById('section-4');if(!el){document.querySelectorAll('h1,h2').forEach(function(h){if(!el&&h.textContent.trim().indexOf('4 ')===0){el=h;}});}if(el)el.scrollIntoView({behavior:'smooth',block:'start'});\">Costs</a></li><li style=\"margin:2px 0\"><a style=\"cursor:pointer;text-decoration:underline; color: #007bff; text-decoration: underline; text-underline-offset: 0.14em;\" onclick=\"var el=document.getElementById('section-5');if(!el){document.querySelectorAll('h1,h2').forEach(function(h){if(!el&&h.textContent.trim().indexOf('5 ')===0){el=h;}});}if(el)el.scrollIntoView({behavior:'smooth',block:'start'});\">Business case</a></li><li style=\"margin:2px 0\"><a style=\"cursor:pointer;text-decoration:underline; color: #007bff; text-decoration: underline; text-underline-offset: 0.14em;\" onclick=\"var el=document.getElementById('section-6');if(!el){document.querySelectorAll('h1,h2').forEach(function(h){if(!el&&h.textContent.trim().indexOf('6 ')===0){el=h;}});}if(el)el.scrollIntoView({behavior:'smooth',block:'start'});\">Scenarios</a></li><li style=\"margin:2px 0\"><a style=\"cursor:pointer;text-decoration:underline; color: #007bff; text-decoration: underline; text-underline-offset: 0.14em;\" onclick=\"var el=document.getElementById('section-7');if(!el){document.querySelectorAll('h1,h2').forEach(function(h){if(!el&&h.textContent.trim().indexOf('7 ')===0){el=h;}});}if(el)el.scrollIntoView({behavior:'smooth',block:'start'});\">Verification & assertions</a></li><li style=\"margin:2px 0\"><a style=\"cursor:pointer;text-decoration:underline; color: #007bff; text-decoration: underline; text-underline-offset: 0.14em;\" onclick=\"var el=document.getElementById('section-8');if(!el){document.querySelectorAll('h1,h2').forEach(function(h){if(!el&&h.textContent.trim().indexOf('8 ')===0){el=h;}});}if(el)el.scrollIntoView({behavior:'smooth',block:'start'});\">Data appendix</a></li></ol></p></div>"
|
||
}
|
||
},
|
||
"48a9c76ed0fb498984234409f404ac3f": {
|
||
"model_module": "@jupyter-widgets/base",
|
||
"model_module_version": "2.0.0",
|
||
"model_name": "LayoutModel",
|
||
"state": {
|
||
"_model_module": "@jupyter-widgets/base",
|
||
"_model_module_version": "2.0.0",
|
||
"_model_name": "LayoutModel",
|
||
"_view_count": null,
|
||
"_view_module": "@jupyter-widgets/base",
|
||
"_view_module_version": "2.0.0",
|
||
"_view_name": "LayoutView",
|
||
"align_content": null,
|
||
"align_items": null,
|
||
"align_self": null,
|
||
"border_bottom": null,
|
||
"border_left": null,
|
||
"border_right": null,
|
||
"border_top": null,
|
||
"bottom": null,
|
||
"display": null,
|
||
"flex": null,
|
||
"flex_flow": null,
|
||
"grid_area": null,
|
||
"grid_auto_columns": null,
|
||
"grid_auto_flow": null,
|
||
"grid_auto_rows": null,
|
||
"grid_column": null,
|
||
"grid_gap": null,
|
||
"grid_row": null,
|
||
"grid_template_areas": null,
|
||
"grid_template_columns": null,
|
||
"grid_template_rows": null,
|
||
"height": null,
|
||
"justify_content": null,
|
||
"justify_items": null,
|
||
"left": null,
|
||
"margin": null,
|
||
"max_height": null,
|
||
"max_width": null,
|
||
"min_height": null,
|
||
"min_width": null,
|
||
"object_fit": null,
|
||
"object_position": null,
|
||
"order": null,
|
||
"overflow": null,
|
||
"padding": null,
|
||
"right": null,
|
||
"top": null,
|
||
"visibility": null,
|
||
"width": null
|
||
}
|
||
},
|
||
"ace9c00dc3a84babb5b29ae61e2be62c": {
|
||
"model_module": "anywidget",
|
||
"model_module_version": "~0.11.*",
|
||
"model_name": "AnyModel",
|
||
"state": {
|
||
"_anywidget_id": "mercury.number.NumberInputWidget",
|
||
"_css": "\n .mljar-number-container {\n display: flex;\n flex-direction: column;\n width: 100%;\n font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;\n font-size: 14px;\n color: #0f172a;\n padding-left: 4px;\n padding-right: 4px;\n box-sizing: border-box;\n }\n\n .mljar-number-label {\n padding-top: 6px;\n margin-bottom: 4px;\n font-weight: 600;\n }\n\n .mljar-number-field-row {\n display: flex;\n align-items: stretch;\n width: 100%;\n min-height: 40px;\n border: 1px solid #cfd1d5;\n border-radius: 6px;\n background: #ffffff;\n box-sizing: border-box;\n overflow: hidden;\n }\n\n .mljar-number-input {\n flex: 1 1 auto;\n min-width: 0;\n min-height: 100%;\n padding: 7px 10px;\n border: 0;\n border-radius: 0;\n background: #ffffff;\n box-sizing: border-box;\n background-color: #ffffff !important;\n color: #0f172a !important;\n font: inherit;\n line-height: 1.2;\n -moz-appearance: textfield;\n }\n\n .mljar-number-input::-webkit-outer-spin-button,\n .mljar-number-input::-webkit-inner-spin-button {\n -webkit-appearance: none;\n margin: 0;\n }\n\n .mljar-number-input:disabled {\n background: #f5f5f5;\n color: #888;\n cursor: not-allowed;\n }\n\n .mljar-number-input:focus {\n outline: none;\n }\n\n .mljar-number-field-row:focus-within {\n border-color: #007bff;\n box-shadow: none;\n }\n\n .mljar-number-field-row:focus-within .mljar-number-controls {\n border-left-color: #007bff;\n }\n\n .mljar-number-controls {\n display: flex;\n align-items: stretch;\n flex: 0 0 auto;\n border-left: 1px solid #cfd1d5;\n background: #f1f1f2;\n }\n\n .mljar-number-step-btn {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 38px;\n min-width: 38px;\n min-height: 100%;\n border: 0;\n border-radius: 0;\n background: transparent;\n color: #0f172a;\n font: inherit;\n font-size: 18px;\n font-weight: 700;\n line-height: 1;\n cursor: pointer;\n padding: 0;\n user-select: none;\n -webkit-user-select: none;\n touch-action: manipulation;\n transition: background-color 0.14s ease, color 0.14s ease;\n }\n\n .mljar-number-step-up {\n border-left: 1px solid #cfd1d5;\n }\n\n .mljar-number-step-btn:hover {\n background: #f3f3f4;\n }\n\n .mljar-number-step-btn:active {\n background: #e6f2ff;\n color: #007bff;\n }\n\n .mljar-number-step-btn:focus-visible {\n outline: none;\n background: #e6f2ff;\n color: #007bff;\n }\n\n .mljar-number-step-btn:disabled {\n background: #f5f5f5;\n color: #aaa;\n cursor: not-allowed;\n }\n\n @media (max-width: 768px) {\n .mljar-number-field-row {\n min-height: 44px;\n }\n\n .mljar-number-input {\n min-height: 44px;\n padding: 8px 12px;\n }\n\n .mljar-number-step-btn {\n font-size: 19px;\n width: 44px;\n min-width: 44px;\n }\n }\n ",
|
||
"_dom_classes": [],
|
||
"_esm": "\n function render({ model, el }) {\n const container = document.createElement(\"div\");\n container.classList.add(\"mljar-number-container\");\n\n const topLabel = document.createElement(\"div\");\n topLabel.classList.add(\"mljar-number-label\");\n\n const fieldRow = document.createElement(\"div\");\n fieldRow.classList.add(\"mljar-number-field-row\");\n\n const input = document.createElement(\"input\");\n input.type = \"number\";\n input.classList.add(\"mljar-number-input\");\n\n const decrementBtn = document.createElement(\"button\");\n decrementBtn.type = \"button\";\n decrementBtn.classList.add(\"mljar-number-step-btn\", \"mljar-number-step-down\");\n decrementBtn.textContent = \"-\";\n decrementBtn.setAttribute(\"aria-label\", \"Decrease value\");\n\n const incrementBtn = document.createElement(\"button\");\n incrementBtn.type = \"button\";\n incrementBtn.classList.add(\"mljar-number-step-btn\", \"mljar-number-step-up\");\n incrementBtn.textContent = \"+\";\n incrementBtn.setAttribute(\"aria-label\", \"Increase value\");\n\n const controls = document.createElement(\"div\");\n controls.classList.add(\"mljar-number-controls\");\n\n controls.appendChild(decrementBtn);\n controls.appendChild(incrementBtn);\n fieldRow.appendChild(input);\n fieldRow.appendChild(controls);\n\n container.appendChild(topLabel);\n container.appendChild(fieldRow);\n el.appendChild(container);\n\n function clamp(val, min, max) {\n if (Number.isFinite(min) && val < min) return min;\n if (Number.isFinite(max) && val > max) return max;\n return val;\n }\n\n function normalizeStep(step) {\n return Number.isFinite(step) && step > 0 ? step : 1;\n }\n\n function snapToStep(value, min, step) {\n const safeStep = normalizeStep(step);\n const base = Number.isFinite(min) ? min : 0;\n const steps = Math.round((value - base) / safeStep);\n const snapped = base + steps * safeStep;\n const precision = Math.max(\n 0,\n (String(safeStep).split(\".\")[1] || \"\").length\n );\n\n return Number(snapped.toFixed(precision + 2));\n }\n\n function isOnStepGrid(value, min, step) {\n const safeStep = normalizeStep(step);\n const base = Number.isFinite(min) ? min : 0;\n const steps = (value - base) / safeStep;\n const nearest = Math.round(steps);\n const epsilon = Math.max(1e-9, safeStep * 1e-9);\n\n return Math.abs(steps - nearest) <= epsilon;\n }\n\n function getCurrentBounds() {\n return {\n min: Number(model.get(\"min\")),\n max: Number(model.get(\"max\")),\n };\n }\n\n function isTransientDraft(raw) {\n return raw === \"\" || raw === \"-\" || raw === \".\" || raw === \"-.\";\n }\n\n let isEditing = false;\n const INPUT_COMMIT_DEBOUNCE_MS = 400;\n\n function clearPendingDraftCommit() {\n if (debounceTimer) clearTimeout(debounceTimer);\n pendingDraftValue = null;\n }\n\n function parseDraftValue(rawValue) {\n const raw = String(rawValue).trim();\n if (isTransientDraft(raw)) {\n return { kind: \"transient\" };\n }\n\n const value = Number(raw);\n if (!Number.isFinite(value)) {\n return { kind: \"invalid\" };\n }\n\n return { kind: \"number\", value };\n }\n\n function commitValue(nextValue, saveNow = true) {\n const { min, max } = getCurrentBounds();\n const step = Number(model.get(\"step\"));\n const parsed = parseDraftValue(nextValue);\n if (parsed.kind !== \"number\") {\n syncFromModel();\n return;\n }\n\n let v = parsed.value;\n v = clamp(v, min, max);\n v = snapToStep(v, min, step);\n v = clamp(v, min, max);\n input.value = String(v);\n model.set(\"value\", v);\n\n if (saveNow) {\n model.save_changes();\n }\n }\n\n function syncFromModel() {\n topLabel.innerHTML = model.get(\"label\") || \"Enter number\";\n\n const min = Number(model.get(\"min\"));\n const max = Number(model.get(\"max\"));\n const step = Number(model.get(\"step\"));\n\n if (Number.isFinite(min)) input.min = String(min); else input.removeAttribute(\"min\");\n if (Number.isFinite(max)) input.max = String(max); else input.removeAttribute(\"max\");\n if (Number.isFinite(step)) input.step = String(step); else input.removeAttribute(\"step\");\n\n const v = Number(model.get(\"value\"));\n if (!isEditing) {\n input.value = Number.isFinite(v) ? String(v) : \"\";\n }\n\n const disabled = !!model.get(\"disabled\");\n input.disabled = disabled;\n incrementBtn.disabled = disabled;\n decrementBtn.disabled = disabled;\n\n const hidden = !!model.get(\"hidden\");\n container.style.display = hidden ? \"none\" : \"flex\";\n }\n\n let debounceTimer = null;\n let pendingDraftValue = null;\n input.addEventListener(\"focus\", () => {\n isEditing = true;\n });\n\n input.addEventListener(\"input\", () => {\n if (model.get(\"disabled\")) return;\n\n const parsed = parseDraftValue(input.value);\n if (parsed.kind !== \"number\") {\n clearPendingDraftCommit();\n return;\n }\n\n const v = parsed.value;\n const { min, max } = getCurrentBounds();\n const step = Number(model.get(\"step\"));\n if (Number.isFinite(min) && v < min) {\n clearPendingDraftCommit();\n return;\n }\n if (Number.isFinite(max) && v > max) {\n clearPendingDraftCommit();\n return;\n }\n if (!isOnStepGrid(v, min, step)) {\n clearPendingDraftCommit();\n return;\n }\n\n pendingDraftValue = v;\n if (debounceTimer) clearTimeout(debounceTimer);\n debounceTimer = setTimeout(() => {\n if (pendingDraftValue === null) return;\n model.set(\"value\", pendingDraftValue);\n model.save_changes();\n pendingDraftValue = null;\n }, INPUT_COMMIT_DEBOUNCE_MS);\n });\n\n input.addEventListener(\"blur\", () => {\n isEditing = false;\n clearPendingDraftCommit();\n commitValue(input.value, true);\n });\n\n input.addEventListener(\"keydown\", event => {\n if (event.key === \"Enter\") {\n event.preventDefault();\n input.blur();\n }\n });\n\n incrementBtn.addEventListener(\"click\", () => {\n if (model.get(\"disabled\")) return;\n const current = Number(model.get(\"value\"));\n const step = normalizeStep(Number(model.get(\"step\")));\n const min = Number(model.get(\"min\"));\n const base = Number.isFinite(current) ? current : 0;\n commitValue(snapToStep(base + step, min, step));\n });\n\n decrementBtn.addEventListener(\"click\", () => {\n if (model.get(\"disabled\")) return;\n const current = Number(model.get(\"value\"));\n const step = normalizeStep(Number(model.get(\"step\")));\n const min = Number(model.get(\"min\"));\n const base = Number.isFinite(current) ? current : 0;\n commitValue(snapToStep(base - step, min, step));\n });\n\n model.on(\"change:value\", syncFromModel);\n model.on(\"change:min\", syncFromModel);\n model.on(\"change:max\", syncFromModel);\n model.on(\"change:step\", syncFromModel);\n model.on(\"change:label\", syncFromModel);\n model.on(\"change:disabled\", syncFromModel);\n model.on(\"change:hidden\", syncFromModel);\n\n syncFromModel();\n\n // ---- read cell id (no DOM modifications) ----\n /*\n const ID_ATTR = \"data-cell-id\";\n const hostWithId = el.closest(`[${ID_ATTR}]`);\n const cellId = hostWithId ? hostWithId.getAttribute(ID_ATTR) : null;\n\n if (cellId) {\n model.set(\"cell_id\", cellId);\n model.save_changes();\n model.send({ type: \"cell_id_detected\", value: cellId });\n } else {\n const mo = new MutationObserver(() => {\n const host = el.closest(`[${ID_ATTR}]`);\n const newId = host?.getAttribute(ID_ATTR);\n if (newId) {\n model.set(\"cell_id\", newId);\n model.save_changes();\n model.send({ type: \"cell_id_detected\", value: newId });\n mo.disconnect();\n }\n });\n mo.observe(document.body, { attributes: true, subtree: true, attributeFilter: [ID_ATTR] });\n }*/\n }\n export default { render };\n ",
|
||
"_model_module": "anywidget",
|
||
"_model_module_version": "~0.11.*",
|
||
"_model_name": "AnyModel",
|
||
"_view_count": null,
|
||
"_view_module": "anywidget",
|
||
"_view_module_version": "~0.11.*",
|
||
"_view_name": "AnyView",
|
||
"cell_id": "",
|
||
"disabled": false,
|
||
"hidden": false,
|
||
"label": "Contact-center agents (FTE) — composite 2,000",
|
||
"layout": "IPY_MODEL_ce0e81f1ea384a79897a9eb663bda565",
|
||
"layout_path": null,
|
||
"max": 50000.0,
|
||
"min": 50.0,
|
||
"position": "sidebar",
|
||
"render_slot_id": null,
|
||
"source_cell_id": null,
|
||
"step": 50.0,
|
||
"tabbable": null,
|
||
"tooltip": null,
|
||
"url_key": "",
|
||
"value": 2000.0
|
||
}
|
||
},
|
||
"ae315fb739f643f7a1fbe0f3678a6002": {
|
||
"model_module": "@jupyter-widgets/base",
|
||
"model_module_version": "2.0.0",
|
||
"model_name": "LayoutModel",
|
||
"state": {
|
||
"_model_module": "@jupyter-widgets/base",
|
||
"_model_module_version": "2.0.0",
|
||
"_model_name": "LayoutModel",
|
||
"_view_count": null,
|
||
"_view_module": "@jupyter-widgets/base",
|
||
"_view_module_version": "2.0.0",
|
||
"_view_name": "LayoutView",
|
||
"align_content": null,
|
||
"align_items": null,
|
||
"align_self": null,
|
||
"border_bottom": null,
|
||
"border_left": null,
|
||
"border_right": null,
|
||
"border_top": null,
|
||
"bottom": null,
|
||
"display": null,
|
||
"flex": null,
|
||
"flex_flow": null,
|
||
"grid_area": null,
|
||
"grid_auto_columns": null,
|
||
"grid_auto_flow": null,
|
||
"grid_auto_rows": null,
|
||
"grid_column": null,
|
||
"grid_gap": null,
|
||
"grid_row": null,
|
||
"grid_template_areas": null,
|
||
"grid_template_columns": null,
|
||
"grid_template_rows": null,
|
||
"height": null,
|
||
"justify_content": null,
|
||
"justify_items": null,
|
||
"left": null,
|
||
"margin": null,
|
||
"max_height": null,
|
||
"max_width": null,
|
||
"min_height": null,
|
||
"min_width": null,
|
||
"object_fit": null,
|
||
"object_position": null,
|
||
"order": null,
|
||
"overflow": null,
|
||
"padding": null,
|
||
"right": null,
|
||
"top": null,
|
||
"visibility": null,
|
||
"width": null
|
||
}
|
||
},
|
||
"b0de84b09c5743b9a4e7a5dbdf1973d9": {
|
||
"model_module": "@jupyter-widgets/base",
|
||
"model_module_version": "2.0.0",
|
||
"model_name": "LayoutModel",
|
||
"state": {
|
||
"_model_module": "@jupyter-widgets/base",
|
||
"_model_module_version": "2.0.0",
|
||
"_model_name": "LayoutModel",
|
||
"_view_count": null,
|
||
"_view_module": "@jupyter-widgets/base",
|
||
"_view_module_version": "2.0.0",
|
||
"_view_name": "LayoutView",
|
||
"align_content": null,
|
||
"align_items": null,
|
||
"align_self": null,
|
||
"border_bottom": null,
|
||
"border_left": null,
|
||
"border_right": null,
|
||
"border_top": null,
|
||
"bottom": null,
|
||
"display": null,
|
||
"flex": null,
|
||
"flex_flow": null,
|
||
"grid_area": null,
|
||
"grid_auto_columns": null,
|
||
"grid_auto_flow": null,
|
||
"grid_auto_rows": null,
|
||
"grid_column": null,
|
||
"grid_gap": null,
|
||
"grid_row": null,
|
||
"grid_template_areas": null,
|
||
"grid_template_columns": null,
|
||
"grid_template_rows": null,
|
||
"height": null,
|
||
"justify_content": null,
|
||
"justify_items": null,
|
||
"left": null,
|
||
"margin": null,
|
||
"max_height": null,
|
||
"max_width": null,
|
||
"min_height": null,
|
||
"min_width": null,
|
||
"object_fit": null,
|
||
"object_position": null,
|
||
"order": null,
|
||
"overflow": null,
|
||
"padding": null,
|
||
"right": null,
|
||
"top": null,
|
||
"visibility": null,
|
||
"width": null
|
||
}
|
||
},
|
||
"b1e82327c29a44a8a4a45e8100b3d857": {
|
||
"model_module": "anywidget",
|
||
"model_module_version": "~0.11.*",
|
||
"model_name": "AnyModel",
|
||
"state": {
|
||
"_anywidget_id": "mercury.select.SelectWidget",
|
||
"_css": "\n .mljar-select-container {\n position: relative;\n display: flex;\n flex-direction: column;\n font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;\n font-size: 14px;\n color: #0f172a;\n padding-left: 4px;\n padding-right: 4px;\n overflow: visible;\n }\n\n .mljar-select-label {\n padding-top: 6px;\n margin-bottom: 4px;\n font-weight: 600;\n }\n\n .mljar-select-control {\n position: relative;\n display: flex;\n align-items: center;\n cursor: default;\n overflow: visible;\n }\n\n .mljar-select-container.is-open {\n z-index: 20;\n }\n\n .mljar-select-widget-input {\n width: 100%;\n min-height: 40px;\n padding: 9px 36px 9px 10px;\n border: 1px solid #cfd1d5;\n border-radius: 6px;\n background: #ffffff;\n box-sizing: border-box;\n line-height: 1.4;\n transition: border-color 0.15s ease, box-shadow 0.15s ease;\n\n appearance: none !important;\n background-color: #ffffff !important;\n color: #0f172a !important;\n cursor: default;\n }\n\n .mljar-select-widget-input:focus {\n outline: none;\n border-color: #007bff;\n box-shadow: none;\n cursor: text;\n }\n\n .mljar-select-caret {\n position: absolute;\n right: 12px;\n top: 50%;\n width: 8px;\n height: 8px;\n border-right: 1.5px solid #0f172a;\n border-bottom: 1.5px solid #0f172a;\n transform: translateY(-65%) rotate(45deg);\n pointer-events: auto;\n opacity: 0.5;\n transition: transform 0.18s ease, opacity 0.18s ease;\n }\n\n .mljar-select-container.is-open .mljar-select-caret {\n opacity: 1;\n transform: translateY(-35%) rotate(225deg);\n }\n\n .mljar-select-dropdown {\n display: none;\n position: fixed;\n z-index: 10000;\n border: 1px solid #cfd1d5;\n border-radius: 6px;\n background: #ffffff;\n box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12);\n overflow: hidden;\n }\n\n .mljar-select-list {\n max-height: 260px;\n overflow-y: auto;\n }\n\n .mljar-select-option {\n display: block;\n width: 100%;\n padding: 9px 10px;\n border: 0;\n background: transparent;\n color: #0f172a;\n text-align: left;\n cursor: pointer;\n font: inherit;\n transition: background-color 0.14s ease, color 0.14s ease;\n }\n\n .mljar-select-option:hover {\n background: #f3f3f4;\n }\n\n .mljar-select-option:active {\n background: #e6f2ff;\n color: #007bff;\n }\n\n .mljar-select-option.is-selected {\n background: #e6f2ff;\n color: #007bff;\n font-weight: 600;\n }\n\n .mljar-select-option.is-selected:hover,\n .mljar-select-option.is-selected:active {\n background: #e6f2ff;\n color: #007bff;\n }\n\n .mljar-select-empty {\n display: none;\n padding: 10px;\n color: #616673;\n font-size: 0.95em;\n }\n\n .mljar-select-widget-input:disabled {\n background: #f5f5f5;\n color: #888;\n cursor: not-allowed;\n }\n\n .mljar-select-control.is-disabled .mljar-select-caret {\n opacity: 0.45;\n }\n ",
|
||
"_dom_classes": [],
|
||
"_esm": "\n function render({ model, el }) {\n const normalize = value => String(value ?? \"\").toLowerCase().trim();\n const getChoices = () =>\n Array.isArray(model.get(\"choices\")) ? [...model.get(\"choices\")] : [];\n const isDisabled = () => !!model.get(\"disabled\");\n const isHidden = () => !!model.get(\"hidden\");\n\n const container = document.createElement(\"div\");\n container.classList.add(\"mljar-select-container\");\n\n if (model.get(\"label\")) {\n const topLabel = document.createElement(\"div\");\n topLabel.classList.add(\"mljar-select-label\");\n topLabel.innerHTML = model.get(\"label\");\n container.appendChild(topLabel);\n }\n\n const control = document.createElement(\"div\");\n control.classList.add(\"mljar-select-control\");\n\n const input = document.createElement(\"input\");\n input.type = \"text\";\n input.classList.add(\"mljar-select-widget-input\");\n input.autocomplete = \"off\";\n input.spellcheck = false;\n\n const caret = document.createElement(\"div\");\n caret.classList.add(\"mljar-select-caret\");\n\n control.appendChild(input);\n control.appendChild(caret);\n\n const dropdown = document.createElement(\"div\");\n dropdown.classList.add(\"mljar-select-dropdown\");\n\n const list = document.createElement(\"div\");\n list.classList.add(\"mljar-select-list\");\n\n const emptyState = document.createElement(\"div\");\n emptyState.classList.add(\"mljar-select-empty\");\n emptyState.textContent = \"No matches\";\n\n dropdown.appendChild(list);\n dropdown.appendChild(emptyState);\n\n container.appendChild(control);\n el.appendChild(container);\n\n let isOpen = false;\n let filteredChoices = [];\n let lastCommittedValue = \"\";\n let isEditing = false;\n document.body.appendChild(dropdown);\n\n const updateDropdownPosition = () => {\n if (!isOpen) {\n return;\n }\n const rect = control.getBoundingClientRect();\n dropdown.style.top = `${rect.bottom + 6}px`;\n dropdown.style.left = `${rect.left}px`;\n dropdown.style.width = `${rect.width}px`;\n };\n\n const setOpen = next => {\n if (isDisabled()) {\n isOpen = false;\n } else {\n isOpen = !!next;\n }\n container.classList.toggle(\"is-open\", isOpen);\n dropdown.style.display = isOpen ? \"block\" : \"none\";\n if (isOpen) {\n updateDropdownPosition();\n }\n };\n\n const updateDisabledState = () => {\n const disabled = isDisabled();\n input.disabled = disabled;\n control.classList.toggle(\"is-disabled\", disabled);\n };\n\n const updateHiddenState = () => {\n container.style.display = isHidden() ? \"none\" : \"\";\n };\n\n const syncInputWithValue = () => {\n const value = model.get(\"value\") || \"\";\n lastCommittedValue = value;\n if (!isEditing) {\n input.value = value;\n }\n };\n\n const filterChoices = query => {\n const normalizedQuery = normalize(query);\n const allChoices = getChoices();\n if (!normalizedQuery) {\n return allChoices;\n }\n return allChoices.filter(choice =>\n normalize(choice).includes(normalizedQuery)\n );\n };\n\n const renderList = () => {\n list.innerHTML = \"\";\n filteredChoices.forEach(choice => {\n const option = document.createElement(\"button\");\n option.type = \"button\";\n option.classList.add(\"mljar-select-option\");\n if (choice === model.get(\"value\")) {\n option.classList.add(\"is-selected\");\n }\n option.textContent = choice;\n option.addEventListener(\"mousedown\", event => {\n event.preventDefault();\n event.stopPropagation();\n model.set(\"value\", choice);\n model.save_changes();\n isEditing = false;\n syncInputWithValue();\n renderList();\n setOpen(false);\n });\n list.appendChild(option);\n });\n\n const hasMatches = filteredChoices.length > 0;\n list.style.display = hasMatches ? \"block\" : \"none\";\n emptyState.style.display = hasMatches ? \"none\" : \"block\";\n };\n\n const refreshList = () => {\n filteredChoices = filterChoices(input.value);\n renderList();\n };\n\n const openWithCurrentQuery = () => {\n isEditing = true;\n input.value = \"\";\n refreshList();\n setOpen(true);\n };\n\n const closeDropdown = () => {\n isEditing = false;\n setOpen(false);\n input.value = lastCommittedValue;\n };\n\n control.addEventListener(\"click\", event => {\n event.stopPropagation();\n if (isDisabled()) {\n return;\n }\n if (event.target === caret && isOpen) {\n closeDropdown();\n input.blur();\n return;\n }\n openWithCurrentQuery();\n input.focus();\n });\n\n input.addEventListener(\"input\", () => {\n if (isDisabled()) {\n return;\n }\n refreshList();\n setOpen(true);\n });\n\n input.addEventListener(\"focus\", () => {\n if (isDisabled()) {\n return;\n }\n openWithCurrentQuery();\n });\n\n input.addEventListener(\"blur\", () => {\n isEditing = false;\n input.value = lastCommittedValue;\n });\n\n const handleDocumentClick = event => {\n if (!container.contains(event.target) && !dropdown.contains(event.target)) {\n closeDropdown();\n }\n };\n\n document.addEventListener(\"click\", handleDocumentClick);\n window.addEventListener(\"resize\", updateDropdownPosition);\n document.addEventListener(\"scroll\", updateDropdownPosition, true);\n\n model.on(\"change:value\", () => {\n syncInputWithValue();\n refreshList();\n });\n\n model.on(\"change:choices\", () => {\n const choices = getChoices();\n if (!choices.includes(model.get(\"value\")) && choices.length > 0) {\n model.set(\"value\", choices[0]);\n model.save_changes();\n return;\n }\n syncInputWithValue();\n refreshList();\n });\n\n model.on(\"change:disabled\", () => {\n updateDisabledState();\n if (isDisabled()) {\n closeDropdown();\n }\n });\n\n model.on(\"change:hidden\", () => {\n updateHiddenState();\n });\n\n updateDisabledState();\n updateHiddenState();\n syncInputWithValue();\n refreshList();\n setOpen(false);\n\n return () => {\n dropdown.remove();\n document.removeEventListener(\"click\", handleDocumentClick);\n window.removeEventListener(\"resize\", updateDropdownPosition);\n document.removeEventListener(\"scroll\", updateDropdownPosition, true);\n };\n }\n export default { render };\n ",
|
||
"_model_module": "anywidget",
|
||
"_model_module_version": "~0.11.*",
|
||
"_model_name": "AnyModel",
|
||
"_view_count": null,
|
||
"_view_module": "anywidget",
|
||
"_view_module_version": "~0.11.*",
|
||
"_view_name": "AnyView",
|
||
"cell_id": "",
|
||
"choices": [
|
||
"conservative",
|
||
"moderate",
|
||
"aggressive"
|
||
],
|
||
"disabled": false,
|
||
"hidden": false,
|
||
"label": "Scenario",
|
||
"layout": "IPY_MODEL_b0de84b09c5743b9a4e7a5dbdf1973d9",
|
||
"layout_path": null,
|
||
"position": "sidebar",
|
||
"render_slot_id": null,
|
||
"source_cell_id": null,
|
||
"tabbable": null,
|
||
"tooltip": null,
|
||
"url_key": "",
|
||
"value": "moderate"
|
||
}
|
||
},
|
||
"cc4716028c0c4335b1d70f4e29e905f6": {
|
||
"model_module": "@jupyter-widgets/base",
|
||
"model_module_version": "2.0.0",
|
||
"model_name": "LayoutModel",
|
||
"state": {
|
||
"_model_module": "@jupyter-widgets/base",
|
||
"_model_module_version": "2.0.0",
|
||
"_model_name": "LayoutModel",
|
||
"_view_count": null,
|
||
"_view_module": "@jupyter-widgets/base",
|
||
"_view_module_version": "2.0.0",
|
||
"_view_name": "LayoutView",
|
||
"align_content": null,
|
||
"align_items": null,
|
||
"align_self": null,
|
||
"border_bottom": null,
|
||
"border_left": null,
|
||
"border_right": null,
|
||
"border_top": null,
|
||
"bottom": null,
|
||
"display": null,
|
||
"flex": null,
|
||
"flex_flow": null,
|
||
"grid_area": null,
|
||
"grid_auto_columns": null,
|
||
"grid_auto_flow": null,
|
||
"grid_auto_rows": null,
|
||
"grid_column": null,
|
||
"grid_gap": null,
|
||
"grid_row": null,
|
||
"grid_template_areas": null,
|
||
"grid_template_columns": null,
|
||
"grid_template_rows": null,
|
||
"height": null,
|
||
"justify_content": null,
|
||
"justify_items": null,
|
||
"left": null,
|
||
"margin": null,
|
||
"max_height": null,
|
||
"max_width": null,
|
||
"min_height": null,
|
||
"min_width": null,
|
||
"object_fit": null,
|
||
"object_position": null,
|
||
"order": null,
|
||
"overflow": null,
|
||
"padding": null,
|
||
"right": null,
|
||
"top": null,
|
||
"visibility": null,
|
||
"width": null
|
||
}
|
||
},
|
||
"ce0e81f1ea384a79897a9eb663bda565": {
|
||
"model_module": "@jupyter-widgets/base",
|
||
"model_module_version": "2.0.0",
|
||
"model_name": "LayoutModel",
|
||
"state": {
|
||
"_model_module": "@jupyter-widgets/base",
|
||
"_model_module_version": "2.0.0",
|
||
"_model_name": "LayoutModel",
|
||
"_view_count": null,
|
||
"_view_module": "@jupyter-widgets/base",
|
||
"_view_module_version": "2.0.0",
|
||
"_view_name": "LayoutView",
|
||
"align_content": null,
|
||
"align_items": null,
|
||
"align_self": null,
|
||
"border_bottom": null,
|
||
"border_left": null,
|
||
"border_right": null,
|
||
"border_top": null,
|
||
"bottom": null,
|
||
"display": null,
|
||
"flex": null,
|
||
"flex_flow": null,
|
||
"grid_area": null,
|
||
"grid_auto_columns": null,
|
||
"grid_auto_flow": null,
|
||
"grid_auto_rows": null,
|
||
"grid_column": null,
|
||
"grid_gap": null,
|
||
"grid_row": null,
|
||
"grid_template_areas": null,
|
||
"grid_template_columns": null,
|
||
"grid_template_rows": null,
|
||
"height": null,
|
||
"justify_content": null,
|
||
"justify_items": null,
|
||
"left": null,
|
||
"margin": null,
|
||
"max_height": null,
|
||
"max_width": null,
|
||
"min_height": null,
|
||
"min_width": null,
|
||
"object_fit": null,
|
||
"object_position": null,
|
||
"order": null,
|
||
"overflow": null,
|
||
"padding": null,
|
||
"right": null,
|
||
"top": null,
|
||
"visibility": null,
|
||
"width": null
|
||
}
|
||
},
|
||
"d4ccace17e824e4a85c55ff6ef95afb0": {
|
||
"model_module": "anywidget",
|
||
"model_module_version": "~0.11.*",
|
||
"model_name": "AnyModel",
|
||
"state": {
|
||
"_anywidget_id": "mercury.number.NumberInputWidget",
|
||
"_css": "\n .mljar-number-container {\n display: flex;\n flex-direction: column;\n width: 100%;\n font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;\n font-size: 14px;\n color: #0f172a;\n padding-left: 4px;\n padding-right: 4px;\n box-sizing: border-box;\n }\n\n .mljar-number-label {\n padding-top: 6px;\n margin-bottom: 4px;\n font-weight: 600;\n }\n\n .mljar-number-field-row {\n display: flex;\n align-items: stretch;\n width: 100%;\n min-height: 40px;\n border: 1px solid #cfd1d5;\n border-radius: 6px;\n background: #ffffff;\n box-sizing: border-box;\n overflow: hidden;\n }\n\n .mljar-number-input {\n flex: 1 1 auto;\n min-width: 0;\n min-height: 100%;\n padding: 7px 10px;\n border: 0;\n border-radius: 0;\n background: #ffffff;\n box-sizing: border-box;\n background-color: #ffffff !important;\n color: #0f172a !important;\n font: inherit;\n line-height: 1.2;\n -moz-appearance: textfield;\n }\n\n .mljar-number-input::-webkit-outer-spin-button,\n .mljar-number-input::-webkit-inner-spin-button {\n -webkit-appearance: none;\n margin: 0;\n }\n\n .mljar-number-input:disabled {\n background: #f5f5f5;\n color: #888;\n cursor: not-allowed;\n }\n\n .mljar-number-input:focus {\n outline: none;\n }\n\n .mljar-number-field-row:focus-within {\n border-color: #007bff;\n box-shadow: none;\n }\n\n .mljar-number-field-row:focus-within .mljar-number-controls {\n border-left-color: #007bff;\n }\n\n .mljar-number-controls {\n display: flex;\n align-items: stretch;\n flex: 0 0 auto;\n border-left: 1px solid #cfd1d5;\n background: #f1f1f2;\n }\n\n .mljar-number-step-btn {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 38px;\n min-width: 38px;\n min-height: 100%;\n border: 0;\n border-radius: 0;\n background: transparent;\n color: #0f172a;\n font: inherit;\n font-size: 18px;\n font-weight: 700;\n line-height: 1;\n cursor: pointer;\n padding: 0;\n user-select: none;\n -webkit-user-select: none;\n touch-action: manipulation;\n transition: background-color 0.14s ease, color 0.14s ease;\n }\n\n .mljar-number-step-up {\n border-left: 1px solid #cfd1d5;\n }\n\n .mljar-number-step-btn:hover {\n background: #f3f3f4;\n }\n\n .mljar-number-step-btn:active {\n background: #e6f2ff;\n color: #007bff;\n }\n\n .mljar-number-step-btn:focus-visible {\n outline: none;\n background: #e6f2ff;\n color: #007bff;\n }\n\n .mljar-number-step-btn:disabled {\n background: #f5f5f5;\n color: #aaa;\n cursor: not-allowed;\n }\n\n @media (max-width: 768px) {\n .mljar-number-field-row {\n min-height: 44px;\n }\n\n .mljar-number-input {\n min-height: 44px;\n padding: 8px 12px;\n }\n\n .mljar-number-step-btn {\n font-size: 19px;\n width: 44px;\n min-width: 44px;\n }\n }\n ",
|
||
"_dom_classes": [],
|
||
"_esm": "\n function render({ model, el }) {\n const container = document.createElement(\"div\");\n container.classList.add(\"mljar-number-container\");\n\n const topLabel = document.createElement(\"div\");\n topLabel.classList.add(\"mljar-number-label\");\n\n const fieldRow = document.createElement(\"div\");\n fieldRow.classList.add(\"mljar-number-field-row\");\n\n const input = document.createElement(\"input\");\n input.type = \"number\";\n input.classList.add(\"mljar-number-input\");\n\n const decrementBtn = document.createElement(\"button\");\n decrementBtn.type = \"button\";\n decrementBtn.classList.add(\"mljar-number-step-btn\", \"mljar-number-step-down\");\n decrementBtn.textContent = \"-\";\n decrementBtn.setAttribute(\"aria-label\", \"Decrease value\");\n\n const incrementBtn = document.createElement(\"button\");\n incrementBtn.type = \"button\";\n incrementBtn.classList.add(\"mljar-number-step-btn\", \"mljar-number-step-up\");\n incrementBtn.textContent = \"+\";\n incrementBtn.setAttribute(\"aria-label\", \"Increase value\");\n\n const controls = document.createElement(\"div\");\n controls.classList.add(\"mljar-number-controls\");\n\n controls.appendChild(decrementBtn);\n controls.appendChild(incrementBtn);\n fieldRow.appendChild(input);\n fieldRow.appendChild(controls);\n\n container.appendChild(topLabel);\n container.appendChild(fieldRow);\n el.appendChild(container);\n\n function clamp(val, min, max) {\n if (Number.isFinite(min) && val < min) return min;\n if (Number.isFinite(max) && val > max) return max;\n return val;\n }\n\n function normalizeStep(step) {\n return Number.isFinite(step) && step > 0 ? step : 1;\n }\n\n function snapToStep(value, min, step) {\n const safeStep = normalizeStep(step);\n const base = Number.isFinite(min) ? min : 0;\n const steps = Math.round((value - base) / safeStep);\n const snapped = base + steps * safeStep;\n const precision = Math.max(\n 0,\n (String(safeStep).split(\".\")[1] || \"\").length\n );\n\n return Number(snapped.toFixed(precision + 2));\n }\n\n function isOnStepGrid(value, min, step) {\n const safeStep = normalizeStep(step);\n const base = Number.isFinite(min) ? min : 0;\n const steps = (value - base) / safeStep;\n const nearest = Math.round(steps);\n const epsilon = Math.max(1e-9, safeStep * 1e-9);\n\n return Math.abs(steps - nearest) <= epsilon;\n }\n\n function getCurrentBounds() {\n return {\n min: Number(model.get(\"min\")),\n max: Number(model.get(\"max\")),\n };\n }\n\n function isTransientDraft(raw) {\n return raw === \"\" || raw === \"-\" || raw === \".\" || raw === \"-.\";\n }\n\n let isEditing = false;\n const INPUT_COMMIT_DEBOUNCE_MS = 400;\n\n function clearPendingDraftCommit() {\n if (debounceTimer) clearTimeout(debounceTimer);\n pendingDraftValue = null;\n }\n\n function parseDraftValue(rawValue) {\n const raw = String(rawValue).trim();\n if (isTransientDraft(raw)) {\n return { kind: \"transient\" };\n }\n\n const value = Number(raw);\n if (!Number.isFinite(value)) {\n return { kind: \"invalid\" };\n }\n\n return { kind: \"number\", value };\n }\n\n function commitValue(nextValue, saveNow = true) {\n const { min, max } = getCurrentBounds();\n const step = Number(model.get(\"step\"));\n const parsed = parseDraftValue(nextValue);\n if (parsed.kind !== \"number\") {\n syncFromModel();\n return;\n }\n\n let v = parsed.value;\n v = clamp(v, min, max);\n v = snapToStep(v, min, step);\n v = clamp(v, min, max);\n input.value = String(v);\n model.set(\"value\", v);\n\n if (saveNow) {\n model.save_changes();\n }\n }\n\n function syncFromModel() {\n topLabel.innerHTML = model.get(\"label\") || \"Enter number\";\n\n const min = Number(model.get(\"min\"));\n const max = Number(model.get(\"max\"));\n const step = Number(model.get(\"step\"));\n\n if (Number.isFinite(min)) input.min = String(min); else input.removeAttribute(\"min\");\n if (Number.isFinite(max)) input.max = String(max); else input.removeAttribute(\"max\");\n if (Number.isFinite(step)) input.step = String(step); else input.removeAttribute(\"step\");\n\n const v = Number(model.get(\"value\"));\n if (!isEditing) {\n input.value = Number.isFinite(v) ? String(v) : \"\";\n }\n\n const disabled = !!model.get(\"disabled\");\n input.disabled = disabled;\n incrementBtn.disabled = disabled;\n decrementBtn.disabled = disabled;\n\n const hidden = !!model.get(\"hidden\");\n container.style.display = hidden ? \"none\" : \"flex\";\n }\n\n let debounceTimer = null;\n let pendingDraftValue = null;\n input.addEventListener(\"focus\", () => {\n isEditing = true;\n });\n\n input.addEventListener(\"input\", () => {\n if (model.get(\"disabled\")) return;\n\n const parsed = parseDraftValue(input.value);\n if (parsed.kind !== \"number\") {\n clearPendingDraftCommit();\n return;\n }\n\n const v = parsed.value;\n const { min, max } = getCurrentBounds();\n const step = Number(model.get(\"step\"));\n if (Number.isFinite(min) && v < min) {\n clearPendingDraftCommit();\n return;\n }\n if (Number.isFinite(max) && v > max) {\n clearPendingDraftCommit();\n return;\n }\n if (!isOnStepGrid(v, min, step)) {\n clearPendingDraftCommit();\n return;\n }\n\n pendingDraftValue = v;\n if (debounceTimer) clearTimeout(debounceTimer);\n debounceTimer = setTimeout(() => {\n if (pendingDraftValue === null) return;\n model.set(\"value\", pendingDraftValue);\n model.save_changes();\n pendingDraftValue = null;\n }, INPUT_COMMIT_DEBOUNCE_MS);\n });\n\n input.addEventListener(\"blur\", () => {\n isEditing = false;\n clearPendingDraftCommit();\n commitValue(input.value, true);\n });\n\n input.addEventListener(\"keydown\", event => {\n if (event.key === \"Enter\") {\n event.preventDefault();\n input.blur();\n }\n });\n\n incrementBtn.addEventListener(\"click\", () => {\n if (model.get(\"disabled\")) return;\n const current = Number(model.get(\"value\"));\n const step = normalizeStep(Number(model.get(\"step\")));\n const min = Number(model.get(\"min\"));\n const base = Number.isFinite(current) ? current : 0;\n commitValue(snapToStep(base + step, min, step));\n });\n\n decrementBtn.addEventListener(\"click\", () => {\n if (model.get(\"disabled\")) return;\n const current = Number(model.get(\"value\"));\n const step = normalizeStep(Number(model.get(\"step\")));\n const min = Number(model.get(\"min\"));\n const base = Number.isFinite(current) ? current : 0;\n commitValue(snapToStep(base - step, min, step));\n });\n\n model.on(\"change:value\", syncFromModel);\n model.on(\"change:min\", syncFromModel);\n model.on(\"change:max\", syncFromModel);\n model.on(\"change:step\", syncFromModel);\n model.on(\"change:label\", syncFromModel);\n model.on(\"change:disabled\", syncFromModel);\n model.on(\"change:hidden\", syncFromModel);\n\n syncFromModel();\n\n // ---- read cell id (no DOM modifications) ----\n /*\n const ID_ATTR = \"data-cell-id\";\n const hostWithId = el.closest(`[${ID_ATTR}]`);\n const cellId = hostWithId ? hostWithId.getAttribute(ID_ATTR) : null;\n\n if (cellId) {\n model.set(\"cell_id\", cellId);\n model.save_changes();\n model.send({ type: \"cell_id_detected\", value: cellId });\n } else {\n const mo = new MutationObserver(() => {\n const host = el.closest(`[${ID_ATTR}]`);\n const newId = host?.getAttribute(ID_ATTR);\n if (newId) {\n model.set(\"cell_id\", newId);\n model.save_changes();\n model.send({ type: \"cell_id_detected\", value: newId });\n mo.disconnect();\n }\n });\n mo.observe(document.body, { attributes: true, subtree: true, attributeFilter: [ID_ATTR] });\n }*/\n }\n export default { render };\n ",
|
||
"_model_module": "anywidget",
|
||
"_model_module_version": "~0.11.*",
|
||
"_model_name": "AnyModel",
|
||
"_view_count": null,
|
||
"_view_module": "anywidget",
|
||
"_view_module_version": "~0.11.*",
|
||
"_view_name": "AnyView",
|
||
"cell_id": "",
|
||
"disabled": false,
|
||
"hidden": false,
|
||
"label": "Contact growth (%/yr) — composite 30",
|
||
"layout": "IPY_MODEL_48a9c76ed0fb498984234409f404ac3f",
|
||
"layout_path": null,
|
||
"max": 100.0,
|
||
"min": 0.0,
|
||
"position": "sidebar",
|
||
"render_slot_id": null,
|
||
"source_cell_id": null,
|
||
"step": 5.0,
|
||
"tabbable": null,
|
||
"tooltip": null,
|
||
"url_key": "",
|
||
"value": 30.0
|
||
}
|
||
},
|
||
"d67347bf93954b6abc75a466c3259dd3": {
|
||
"model_module": "@jupyter-widgets/base",
|
||
"model_module_version": "2.0.0",
|
||
"model_name": "LayoutModel",
|
||
"state": {
|
||
"_model_module": "@jupyter-widgets/base",
|
||
"_model_module_version": "2.0.0",
|
||
"_model_name": "LayoutModel",
|
||
"_view_count": null,
|
||
"_view_module": "@jupyter-widgets/base",
|
||
"_view_module_version": "2.0.0",
|
||
"_view_name": "LayoutView",
|
||
"align_content": null,
|
||
"align_items": null,
|
||
"align_self": null,
|
||
"border_bottom": null,
|
||
"border_left": null,
|
||
"border_right": null,
|
||
"border_top": null,
|
||
"bottom": null,
|
||
"display": null,
|
||
"flex": null,
|
||
"flex_flow": null,
|
||
"grid_area": null,
|
||
"grid_auto_columns": null,
|
||
"grid_auto_flow": null,
|
||
"grid_auto_rows": null,
|
||
"grid_column": null,
|
||
"grid_gap": null,
|
||
"grid_row": null,
|
||
"grid_template_areas": null,
|
||
"grid_template_columns": null,
|
||
"grid_template_rows": null,
|
||
"height": null,
|
||
"justify_content": null,
|
||
"justify_items": null,
|
||
"left": null,
|
||
"margin": null,
|
||
"max_height": null,
|
||
"max_width": null,
|
||
"min_height": null,
|
||
"min_width": null,
|
||
"object_fit": null,
|
||
"object_position": null,
|
||
"order": null,
|
||
"overflow": null,
|
||
"padding": null,
|
||
"right": null,
|
||
"top": null,
|
||
"visibility": null,
|
||
"width": null
|
||
}
|
||
},
|
||
"eb0056195e9b444dbb8ef3c08e744446": {
|
||
"model_module": "anywidget",
|
||
"model_module_version": "~0.11.*",
|
||
"model_name": "AnyModel",
|
||
"state": {
|
||
"_anywidget_id": "mercury.select.SelectWidget",
|
||
"_css": "\n .mljar-select-container {\n position: relative;\n display: flex;\n flex-direction: column;\n font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;\n font-size: 14px;\n color: #0f172a;\n padding-left: 4px;\n padding-right: 4px;\n overflow: visible;\n }\n\n .mljar-select-label {\n padding-top: 6px;\n margin-bottom: 4px;\n font-weight: 600;\n }\n\n .mljar-select-control {\n position: relative;\n display: flex;\n align-items: center;\n cursor: default;\n overflow: visible;\n }\n\n .mljar-select-container.is-open {\n z-index: 20;\n }\n\n .mljar-select-widget-input {\n width: 100%;\n min-height: 40px;\n padding: 9px 36px 9px 10px;\n border: 1px solid #cfd1d5;\n border-radius: 6px;\n background: #ffffff;\n box-sizing: border-box;\n line-height: 1.4;\n transition: border-color 0.15s ease, box-shadow 0.15s ease;\n\n appearance: none !important;\n background-color: #ffffff !important;\n color: #0f172a !important;\n cursor: default;\n }\n\n .mljar-select-widget-input:focus {\n outline: none;\n border-color: #007bff;\n box-shadow: none;\n cursor: text;\n }\n\n .mljar-select-caret {\n position: absolute;\n right: 12px;\n top: 50%;\n width: 8px;\n height: 8px;\n border-right: 1.5px solid #0f172a;\n border-bottom: 1.5px solid #0f172a;\n transform: translateY(-65%) rotate(45deg);\n pointer-events: auto;\n opacity: 0.5;\n transition: transform 0.18s ease, opacity 0.18s ease;\n }\n\n .mljar-select-container.is-open .mljar-select-caret {\n opacity: 1;\n transform: translateY(-35%) rotate(225deg);\n }\n\n .mljar-select-dropdown {\n display: none;\n position: fixed;\n z-index: 10000;\n border: 1px solid #cfd1d5;\n border-radius: 6px;\n background: #ffffff;\n box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12);\n overflow: hidden;\n }\n\n .mljar-select-list {\n max-height: 260px;\n overflow-y: auto;\n }\n\n .mljar-select-option {\n display: block;\n width: 100%;\n padding: 9px 10px;\n border: 0;\n background: transparent;\n color: #0f172a;\n text-align: left;\n cursor: pointer;\n font: inherit;\n transition: background-color 0.14s ease, color 0.14s ease;\n }\n\n .mljar-select-option:hover {\n background: #f3f3f4;\n }\n\n .mljar-select-option:active {\n background: #e6f2ff;\n color: #007bff;\n }\n\n .mljar-select-option.is-selected {\n background: #e6f2ff;\n color: #007bff;\n font-weight: 600;\n }\n\n .mljar-select-option.is-selected:hover,\n .mljar-select-option.is-selected:active {\n background: #e6f2ff;\n color: #007bff;\n }\n\n .mljar-select-empty {\n display: none;\n padding: 10px;\n color: #616673;\n font-size: 0.95em;\n }\n\n .mljar-select-widget-input:disabled {\n background: #f5f5f5;\n color: #888;\n cursor: not-allowed;\n }\n\n .mljar-select-control.is-disabled .mljar-select-caret {\n opacity: 0.45;\n }\n ",
|
||
"_dom_classes": [],
|
||
"_esm": "\n function render({ model, el }) {\n const normalize = value => String(value ?? \"\").toLowerCase().trim();\n const getChoices = () =>\n Array.isArray(model.get(\"choices\")) ? [...model.get(\"choices\")] : [];\n const isDisabled = () => !!model.get(\"disabled\");\n const isHidden = () => !!model.get(\"hidden\");\n\n const container = document.createElement(\"div\");\n container.classList.add(\"mljar-select-container\");\n\n if (model.get(\"label\")) {\n const topLabel = document.createElement(\"div\");\n topLabel.classList.add(\"mljar-select-label\");\n topLabel.innerHTML = model.get(\"label\");\n container.appendChild(topLabel);\n }\n\n const control = document.createElement(\"div\");\n control.classList.add(\"mljar-select-control\");\n\n const input = document.createElement(\"input\");\n input.type = \"text\";\n input.classList.add(\"mljar-select-widget-input\");\n input.autocomplete = \"off\";\n input.spellcheck = false;\n\n const caret = document.createElement(\"div\");\n caret.classList.add(\"mljar-select-caret\");\n\n control.appendChild(input);\n control.appendChild(caret);\n\n const dropdown = document.createElement(\"div\");\n dropdown.classList.add(\"mljar-select-dropdown\");\n\n const list = document.createElement(\"div\");\n list.classList.add(\"mljar-select-list\");\n\n const emptyState = document.createElement(\"div\");\n emptyState.classList.add(\"mljar-select-empty\");\n emptyState.textContent = \"No matches\";\n\n dropdown.appendChild(list);\n dropdown.appendChild(emptyState);\n\n container.appendChild(control);\n el.appendChild(container);\n\n let isOpen = false;\n let filteredChoices = [];\n let lastCommittedValue = \"\";\n let isEditing = false;\n document.body.appendChild(dropdown);\n\n const updateDropdownPosition = () => {\n if (!isOpen) {\n return;\n }\n const rect = control.getBoundingClientRect();\n dropdown.style.top = `${rect.bottom + 6}px`;\n dropdown.style.left = `${rect.left}px`;\n dropdown.style.width = `${rect.width}px`;\n };\n\n const setOpen = next => {\n if (isDisabled()) {\n isOpen = false;\n } else {\n isOpen = !!next;\n }\n container.classList.toggle(\"is-open\", isOpen);\n dropdown.style.display = isOpen ? \"block\" : \"none\";\n if (isOpen) {\n updateDropdownPosition();\n }\n };\n\n const updateDisabledState = () => {\n const disabled = isDisabled();\n input.disabled = disabled;\n control.classList.toggle(\"is-disabled\", disabled);\n };\n\n const updateHiddenState = () => {\n container.style.display = isHidden() ? \"none\" : \"\";\n };\n\n const syncInputWithValue = () => {\n const value = model.get(\"value\") || \"\";\n lastCommittedValue = value;\n if (!isEditing) {\n input.value = value;\n }\n };\n\n const filterChoices = query => {\n const normalizedQuery = normalize(query);\n const allChoices = getChoices();\n if (!normalizedQuery) {\n return allChoices;\n }\n return allChoices.filter(choice =>\n normalize(choice).includes(normalizedQuery)\n );\n };\n\n const renderList = () => {\n list.innerHTML = \"\";\n filteredChoices.forEach(choice => {\n const option = document.createElement(\"button\");\n option.type = \"button\";\n option.classList.add(\"mljar-select-option\");\n if (choice === model.get(\"value\")) {\n option.classList.add(\"is-selected\");\n }\n option.textContent = choice;\n option.addEventListener(\"mousedown\", event => {\n event.preventDefault();\n event.stopPropagation();\n model.set(\"value\", choice);\n model.save_changes();\n isEditing = false;\n syncInputWithValue();\n renderList();\n setOpen(false);\n });\n list.appendChild(option);\n });\n\n const hasMatches = filteredChoices.length > 0;\n list.style.display = hasMatches ? \"block\" : \"none\";\n emptyState.style.display = hasMatches ? \"none\" : \"block\";\n };\n\n const refreshList = () => {\n filteredChoices = filterChoices(input.value);\n renderList();\n };\n\n const openWithCurrentQuery = () => {\n isEditing = true;\n input.value = \"\";\n refreshList();\n setOpen(true);\n };\n\n const closeDropdown = () => {\n isEditing = false;\n setOpen(false);\n input.value = lastCommittedValue;\n };\n\n control.addEventListener(\"click\", event => {\n event.stopPropagation();\n if (isDisabled()) {\n return;\n }\n if (event.target === caret && isOpen) {\n closeDropdown();\n input.blur();\n return;\n }\n openWithCurrentQuery();\n input.focus();\n });\n\n input.addEventListener(\"input\", () => {\n if (isDisabled()) {\n return;\n }\n refreshList();\n setOpen(true);\n });\n\n input.addEventListener(\"focus\", () => {\n if (isDisabled()) {\n return;\n }\n openWithCurrentQuery();\n });\n\n input.addEventListener(\"blur\", () => {\n isEditing = false;\n input.value = lastCommittedValue;\n });\n\n const handleDocumentClick = event => {\n if (!container.contains(event.target) && !dropdown.contains(event.target)) {\n closeDropdown();\n }\n };\n\n document.addEventListener(\"click\", handleDocumentClick);\n window.addEventListener(\"resize\", updateDropdownPosition);\n document.addEventListener(\"scroll\", updateDropdownPosition, true);\n\n model.on(\"change:value\", () => {\n syncInputWithValue();\n refreshList();\n });\n\n model.on(\"change:choices\", () => {\n const choices = getChoices();\n if (!choices.includes(model.get(\"value\")) && choices.length > 0) {\n model.set(\"value\", choices[0]);\n model.save_changes();\n return;\n }\n syncInputWithValue();\n refreshList();\n });\n\n model.on(\"change:disabled\", () => {\n updateDisabledState();\n if (isDisabled()) {\n closeDropdown();\n }\n });\n\n model.on(\"change:hidden\", () => {\n updateHiddenState();\n });\n\n updateDisabledState();\n updateHiddenState();\n syncInputWithValue();\n refreshList();\n setOpen(false);\n\n return () => {\n dropdown.remove();\n document.removeEventListener(\"click\", handleDocumentClick);\n window.removeEventListener(\"resize\", updateDropdownPosition);\n document.removeEventListener(\"scroll\", updateDropdownPosition, true);\n };\n }\n export default { render };\n ",
|
||
"_model_module": "anywidget",
|
||
"_model_module_version": "~0.11.*",
|
||
"_model_name": "AnyModel",
|
||
"_view_count": null,
|
||
"_view_module": "anywidget",
|
||
"_view_module_version": "~0.11.*",
|
||
"_view_name": "AnyView",
|
||
"cell_id": "",
|
||
"choices": [
|
||
"8%",
|
||
"10% (Forrester)",
|
||
"12%"
|
||
],
|
||
"disabled": false,
|
||
"hidden": false,
|
||
"label": "Discount rate",
|
||
"layout": "IPY_MODEL_d67347bf93954b6abc75a466c3259dd3",
|
||
"layout_path": null,
|
||
"position": "sidebar",
|
||
"render_slot_id": null,
|
||
"source_cell_id": null,
|
||
"tabbable": null,
|
||
"tooltip": null,
|
||
"url_key": "",
|
||
"value": "10% (Forrester)"
|
||
}
|
||
},
|
||
"f9174ef9b9674e9d8c64bd888d70130b": {
|
||
"model_module": "@jupyter-widgets/controls",
|
||
"model_module_version": "2.0.0",
|
||
"model_name": "HTMLStyleModel",
|
||
"state": {
|
||
"_model_module": "@jupyter-widgets/controls",
|
||
"_model_module_version": "2.0.0",
|
||
"_model_name": "HTMLStyleModel",
|
||
"_view_count": null,
|
||
"_view_module": "@jupyter-widgets/base",
|
||
"_view_module_version": "2.0.0",
|
||
"_view_name": "StyleView",
|
||
"background": null,
|
||
"description_width": "",
|
||
"font_size": null,
|
||
"text_color": null
|
||
}
|
||
}
|
||
},
|
||
"version_major": 2,
|
||
"version_minor": 0
|
||
}
|
||
}
|
||
},
|
||
"nbformat": 4,
|
||
"nbformat_minor": 5
|
||
}
|