{ "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\"{title}\"\n", " if subtitle:\n", " t += f\"
{subtitle}\"\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}\")\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}\")\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='
{label}'\n", " for n, label in _TOC)\n", "_toc = mr.Markdown(\n", " text=(f'Jump to section'\n", " f'
    {_items}
'),\n", " position=\"sidebar\")\n" ] }, { "cell_type": "markdown", "id": "cell-3", "metadata": {}, "source": [ "\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": [ "
\n", "\n", "\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", "
Value 🟢
Assumption
agents fte2,000
supervisors fte200
annual contacts y120,000,000
growth rate30%
call share75%
aht legacy minutes10 min
agent salary$45,760
supervisor salary$55,800
discount rate10%
analysis years3 years
\n", "
" ], "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": [ "
\n", "\n", "\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", "
Published 🟢
Metric
Benefits PV (risk-adjusted)$101,696,791
Costs PV (risk-adjusted)$22,983,076
NPV$78,713,715
ROI342%
Payback<6 months
\n", "
" ], "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": [ "\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": [ "" ] }, "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": [ "" ] }, "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": [ "" ] }, "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": [ "" ] }, "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": [ "" ] }, "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": [ "\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": [ "
\n", "\n", "\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", "
DriverRisk adj2026202720283-yr RAPV
Benefit
AI-driven contact resolution efficiencycontacts-15%11,824,38420,342,60832,128,09664,295,08851,699,827
AI-powered content and sentiment analysis savingscontacts-15%3,898,6274,554,6505,347,92813,801,20511,326,358
AI-enabled forecasting, agent scheduling, and supervisionagents-15%5,653,9287,763,69610,532,95523,950,57919,469,777
Data-driven profit lift with increased conversioncontacts-20%960,0001,248,0001,622,4003,830,4003,123,065
Legacy solution cost savingsagents-20%4,942,0806,424,7048,352,11519,718,89916,077,540
TOTAL27,279,01940,333,65857,983,494125,596,172101,696,568
\n", "
" ], "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}", "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}", "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}", "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}", "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}", "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": "Benefits by year (risk-adjusted)
stacked by benefit stream — client overlay", "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": [ "\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": [ "
\n", "\n", "\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", "
DriverRisk adjInitial202620272028PV
Cost
Amazon Connect usage costcontacts+5%06,779,2708,348,72210,324,60920,819,775
Implementation and migration costfixed+10%1,196,250207,166207,16601,555,795
Ongoing managementfixed+15%0294,630215,280215,280607,506
TOTAL1,196,2507,281,0678,771,16810,539,88922,983,076
\n", "
" ], "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}", "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}", "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}", "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": "Costs by year (risk-adjusted)
Initial = undiscounted time-0 outlay (implementation)", "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": [ "\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": [ "
\n", "\n", "\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", "
Forrester composite (published 🟢)Client overlay (🟡)
Benefits PV$101,696,791$101,696,568
Costs PV$22,983,076$22,983,076
NPV$78,713,715$78,713,492
ROI342%342%
Payback<6 months0.7 months (~Jan 2026)
Discount rate10%10%
\n", "
" ], "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}", "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}", "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}", "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": "Cash flow — risk-adjusted
benefits up, costs down; initial outlay undiscounted at time 0", "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": "Present value walk — client overlay
discounted at 10%, 3 years", "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": [ "\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": [ "
\n", "\n", "\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", "
AdoptionRisk ΔBenefits PVCosts PVNPVROI %Payback (months)
Scenario
conservative80%+10%71,672,86817,437,91654,234,9513111
moderate100%+0%101,696,56822,983,07678,713,4923421
aggressive115%-5%123,911,70527,682,36996,229,3373481
\n", "
" ], "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}", "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}", "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}", "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": "Scenario comparison — client overlay
adoption × risk-delta stress on the same drivers", "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": [ "\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": [ "\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": "" } }, "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 }