{ "cells": [ { "cell_type": "markdown", "id": "cell-0", "metadata": {}, "source": [ "# [Study Title] โ€” Business Case\n", "\n", "**Thesis:** one paragraph stating what this notebook demonstrates and the frame it uses\n", "(here: a platform migration priced against *doing nothing*, with the vendor's pitched\n", "numbers kept verbatim as the anchor and the signed contract layered over them).\n", "\n", "This notebook **is** the deliverable: serve it interactively with\n", "`mercury --working-dir notebooks/`, tune the ๐ŸŸก inputs live for the client, then export\n", "an LLM-readable report source with `python scripts/export_report.py`. All math lives in\n", "`studylib/` โ€” the notebook renders it.\n", "\n", "Confidence legend: ๐ŸŸข confirmed (published/contractual) ยท ๐ŸŸก estimated (working\n", "assumption) ยท ๐Ÿ”ด unknown." ] }, { "cell_type": "code", "execution_count": 1, "id": "cell-1", "metadata": { "execution": { "iopub.execute_input": "2026-07-08T16:36:50.535111Z", "iopub.status.busy": "2026-07-08T16:36:50.534789Z", "iopub.status.idle": "2026-07-08T16:36:50.959725Z", "shell.execute_reply": "2026-07-08T16:36:50.956659Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "studylib loaded โ€” window 2026โ€“2028\n" ] } ], "source": [ "# โ”€โ”€ Setup โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€\n", "import sys, pathlib\n", "_ROOT = pathlib.Path.cwd()\n", "if not (_ROOT / \"studylib\").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 library; only\n", "# presentation (and Mercury input widgets) lives here.\n", "from studylib.model import (\n", " YEARS, ANCHOR_VERBATIM, DEFAULT_RAMP_MONTHS,\n", " anchor, benefits_by_year, case_flows, case_kpis, current_costs_by_year,\n", " money, html_money, payback_label, platform_costs_by_year, services_by_year,\n", ")\n", "from studylib.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 = \"#fcfcfb\", \"#e1e0d9\"\n", "CUMULATIVE, CONTEXT = \"#52514e\", \"#c3c2b7\"\n", "FONT_STACK = 'system-ui, -apple-system, \"Segoe UI\", sans-serif'\n", "\n", "\n", "def tei_layout(fig, title, subtitle=None, height=420):\n", " \"\"\"House chart chrome โ€” recessive grid, ink text, title top-left.\"\"\"\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, xanchor=\"left\"),\n", " font=dict(family=FONT_STACK, size=12, color=INK),\n", " paper_bgcolor=SURFACE, plot_bgcolor=SURFACE,\n", " margin=dict(l=60, r=30, t=80, b=45), height=height,\n", " legend=dict(orientation=\"h\", yanchor=\"bottom\", y=1.0, x=0,\n", " bgcolor=\"rgba(0,0,0,0)\"),\n", " xaxis=dict(showgrid=False, zeroline=False),\n", " yaxis=dict(gridcolor=GRID, zeroline=False, tickformat=\"$,.0f\"),\n", " hovermode=\"x unified\",\n", " )\n", " return fig\n", "\n", "\n", "def bar(x, y, name, color):\n", " return go.Bar(x=x, y=y, name=name, marker_color=color,\n", " marker_line=dict(color=SURFACE, width=2),\n", " hovertemplate=\"%{y:$,.0f}\" + name + \"\")\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),\n", " hovertemplate=\"%{y:$,.0f}\" + name + \"\")\n", "\n", "\n", "X = [str(y) for y in YEARS]\n", "backstage(f\"studylib loaded โ€” window {YEARS[0]}โ€“{YEARS[-1]}\")" ] }, { "cell_type": "markdown", "id": "cell-2", "metadata": {}, "source": [ "\n", "\n", "## 1 ยท Inputs\n", "\n", "Contract inputs collected as live widgets. Sidebar widgets are scenario knobs; use\n", "`position=\"inline\"` for data-collection tables that belong in the page flow.\n", "\n", "> **Reactivity contract:** Mercury re-executes only the cells *below* a changed\n", "> widget's cell โ€” never the defining cell itself. So the next cell constructs\n", "> widgets ONLY (no other output), and `.value` is read one cell further down." ] }, { "cell_type": "code", "execution_count": 2, "id": "cell-3", "metadata": { "execution": { "iopub.execute_input": "2026-07-08T16:36:50.962324Z", "iopub.status.busy": "2026-07-08T16:36:50.961887Z", "iopub.status.idle": "2026-07-08T16:36:50.977751Z", "shell.execute_reply": "2026-07-08T16:36:50.976977Z" } }, "outputs": [ { "data": { "application/mercury+json": { "model_id": "924aaf1b9a7244388d4fdc29e0f544e2", "position": "sidebar", "widget": "NumberInputWidget" }, "application/vnd.jupyter.widget-view+json": { "model_id": "924aaf1b9a7244388d4fdc29e0f544e2", "version_major": 2, "version_minor": 1 }, "text/plain": [ "" ] }, "metadata": {}, "output_type": "display_data" }, { "data": { "application/mercury+json": { "model_id": "60e56739936e4f2ab8ce7a25fe31c092", "position": "sidebar", "widget": "NumberInputWidget" }, "application/vnd.jupyter.widget-view+json": { "model_id": "60e56739936e4f2ab8ce7a25fe31c092", "version_major": 2, "version_minor": 1 }, "text/plain": [ "" ] }, "metadata": {}, "output_type": "display_data" }, { "data": { "application/mercury+json": { "model_id": "56c2f21d17cf40009b6d4bcee7bcdd9a", "position": "sidebar", "widget": "SelectWidget" }, "application/vnd.jupyter.widget-view+json": { "model_id": "56c2f21d17cf40009b6d4bcee7bcdd9a", "version_major": 2, "version_minor": 1 }, "text/plain": [ "" ] }, "metadata": {}, "output_type": "display_data" } ], "source": [ "# โ”€โ”€ Inputs (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", "_platform_w = mr.NumberInput(label=\"Platform run-rate ($/yr) โ€” contracted\",\n", " value=round(anchor(\"platform_annual\")),\n", " min=0, max=10_000_000, step=25_000)\n", "_ramp_w = mr.NumberInput(label=\"Ramp โ€” billing-free months\",\n", " value=DEFAULT_RAMP_MONTHS, min=0, max=24, step=3)\n", "_npv_w = mr.Select(label=\"NPV discount rate\", value=\"10% (vendor)\",\n", " choices=[\"10% (vendor)\", \"8% (treasury)\"])" ] }, { "cell_type": "code", "execution_count": 3, "id": "cell-4", "metadata": { "execution": { "iopub.execute_input": "2026-07-08T16:36:50.980200Z", "iopub.status.busy": "2026-07-08T16:36:50.979922Z", "iopub.status.idle": "2026-07-08T16:36:50.985441Z", "shell.execute_reply": "2026-07-08T16:36:50.984712Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "platform by year: {2026: '$250K', 2027: '$500K', 2028: '$500K'}\n", "platform run-rate: contracted $500K/yr (vendor pitched $600K/yr) ยท ramp 6 months\n" ] } ], "source": [ "# โ”€โ”€ Model state (re-runs on any change to the widgets above) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€\n", "PLATFORM_ANNUAL = float(_platform_w.value)\n", "RAMP_MONTHS = int(_ramp_w.value)\n", "DISCOUNT_RATE = 0.10 if _npv_w.value.startswith(\"10\") else 0.08\n", "\n", "platform_by_year = platform_costs_by_year(RAMP_MONTHS, PLATFORM_ANNUAL)\n", "services_by = services_by_year()\n", "current_by_year = current_costs_by_year()\n", "ben_by_year = benefits_by_year()\n", "BASELINE_ANNUAL = anchor(\"baseline_annual\")\n", "\n", "backstage(f\"platform by year: { {y: money(v) for y, v in platform_by_year.items()} }\")\n", "print(f\"platform run-rate: contracted {money(PLATFORM_ANNUAL)}/yr \"\n", " f\"(vendor pitched {money(ANCHOR_VERBATIM['platform_annual'])}/yr) ยท \"\n", " f\"ramp {RAMP_MONTHS} months\")" ] }, { "cell_type": "markdown", "id": "cell-5", "metadata": {}, "source": [ "\n", "\n", "## 2 ยท Business case vs doing nothing\n", "\n", "**Frame:** baseline-relative. Incremental cost = programme cost โˆ’ baseline\n", "(the do-nothing run-rate); net = benefits โˆ’ incremental cost. Double-billing\n", "while the old platform runs off and the post-termination cost-avoidance credit\n", "both fall out of this one frame.\n", "\n", "The KPI table keeps a **vendor-frame column** (pitched rate, verbatim anchors)\n", "beside the **contracted column**, so the client can walk from their own numbers\n", "to the corrected reality." ] }, { "cell_type": "code", "execution_count": 4, "id": "cell-6", "metadata": { "execution": { "iopub.execute_input": "2026-07-08T16:36:50.987674Z", "iopub.status.busy": "2026-07-08T16:36:50.987427Z", "iopub.status.idle": "2026-07-08T16:36:51.023013Z", "shell.execute_reply": "2026-07-08T16:36:51.022122Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "net by year: {2026: '-$500K', 2027: '-$200K', 2028: '$1.1M'}\n" ] }, { "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", "
2026202720283-yr
Platform (contracted, ramp-adjusted)250,000500,000500,0001,250,000
Services (year 1)250,00000250,000
Existing platform (term-contract run-off)1,000,0001,000,00002,000,000
\n", "
" ], "text/plain": [ " 2026 2027 2028 \\\n", "Platform (contracted, ramp-adjusted) 250,000 500,000 500,000 \n", "Services (year 1) 250,000 0 0 \n", "Existing platform (term-contract run-off) 1,000,000 1,000,000 0 \n", "\n", " 3-yr \n", "Platform (contracted, ramp-adjusted) 1,250,000 \n", "Services (year 1) 250,000 \n", "Existing platform (term-contract run-off) 2,000,000 " ] }, "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", " \n", " \n", " \n", " \n", " \n", " \n", " \n", "
Vendor frame ($600K/yr)Contracted ($500K/yr)
3-yr benefits$900K$900K
3-yr incremental cost$750K$500K
3-yr net$150K$400K
ROI20%80%
NPV @ 10%$3K$207K
Payback35 months (~Nov 2028)32 months (~Aug 2028)
\n", "
" ], "text/plain": [ " Vendor frame ($600K/yr) Contracted ($500K/yr)\n", "3-yr benefits $900K $900K\n", "3-yr incremental cost $750K $500K\n", "3-yr net $150K $400K\n", "ROI 20% 80%\n", "NPV @ 10% $3K $207K\n", "Payback 35 months (~Nov 2028) 32 months (~Aug 2028)" ] }, "metadata": {}, "output_type": "display_data" } ], "source": [ "study_costs = pd.DataFrame({\n", " \"Platform (contracted, ramp-adjusted)\": platform_by_year,\n", " \"Services (year 1)\": services_by,\n", " \"Existing platform (term-contract run-off)\": current_by_year,\n", "}).T[YEARS]\n", "study_costs[\"3-yr\"] = study_costs.sum(axis=1)\n", "total_by_year = {y: float(study_costs[y].sum()) for y in YEARS}\n", "\n", "inc, net_by = case_flows(total_by_year, ben_by_year)\n", "kpi = case_kpis(inc, net_by, DISCOUNT_RATE)\n", "\n", "# Vendor-anchored comparison: same frame at the pitched (verbatim) rate.\n", "_plat_vendor = platform_costs_by_year(RAMP_MONTHS, ANCHOR_VERBATIM[\"platform_annual\"])\n", "total_vendor = {y: current_by_year[y] + _plat_vendor[y] + services_by[y] for y in YEARS}\n", "inc_v, net_v = case_flows(total_vendor, ben_by_year)\n", "kpi_v = case_kpis(inc_v, net_v, DISCOUNT_RATE)\n", "\n", "\n", "def kpi_col(k):\n", " return {\n", " \"3-yr benefits\": money(k[\"benefits_3yr\"]),\n", " \"3-yr incremental cost\": money(k[\"incremental_cost_3yr\"]),\n", " \"3-yr net\": money(k[\"net_3yr\"]),\n", " \"ROI\": f\"{k['roi']:.0%}\" if k[\"roi\"] is not None else \"n/a โ€” net saving\",\n", " f\"NPV @ {k['discount_rate']:.0%}\": money(k[\"npv\"]),\n", " \"Payback\": k[\"payback\"],\n", " }\n", "\n", "\n", "kpis_fmt = pd.DataFrame({\n", " f\"Vendor frame ({money(ANCHOR_VERBATIM['platform_annual'])}/yr)\": kpi_col(kpi_v),\n", " f\"Contracted ({money(PLATFORM_ANNUAL)}/yr)\": kpi_col(kpi),\n", "})\n", "backstage(f\"net by year: { {y: money(v) for y, v in net_by.items()} }\")\n", "display(study_costs)\n", "display(kpis_fmt)" ] }, { "cell_type": "code", "execution_count": 5, "id": "cell-7", "metadata": { "execution": { "iopub.execute_input": "2026-07-08T16:36:51.025383Z", "iopub.status.busy": "2026-07-08T16:36:51.025145Z", "iopub.status.idle": "2026-07-08T16:36:52.252524Z", "shell.execute_reply": "2026-07-08T16:36:52.251601Z" } }, "outputs": [ { "data": { "application/vnd.plotly.v1+json": { "config": { "plotlyServerURL": "https://plot.ly" }, "data": [ { "hovertemplate": "%{y:$,.0f}Benefits", "marker": { "color": "#1baf7a", "line": { "color": "#fcfcfb", "width": 2 } }, "name": "Benefits", "type": "bar", "x": [ "2026", "2027", "2028" ], "y": [ 0.0, 300000.0, 600000.0 ] }, { "hovertemplate": "%{y:$,.0f}Incremental cost vs $1.0M/yr baseline", "marker": { "color": "#e34948", "line": { "color": "#fcfcfb", "width": 2 } }, "name": "Incremental cost vs $1.0M/yr baseline", "type": "bar", "x": [ "2026", "2027", "2028" ], "y": [ -500000.0, -500000.0, 500000.0 ] }, { "hovertemplate": "%{y:$,.0f}Cumulative net", "line": { "color": "#52514e", "width": 2 }, "marker": { "size": 8 }, "mode": "lines+markers", "name": "Cumulative net", "type": "scatter", "x": [ "2026", "2027", "2028" ], "y": { "bdata": "AAAAAICEHsEAAAAAwFwlwQAAAAAAahhB", "dtype": "f8" } } ], "layout": { "annotations": [ { "align": "left", "bgcolor": "#fcfcfb", "bordercolor": "#e1e0d9", "borderwidth": 1, "font": { "color": "#52514e", "size": 12 }, "showarrow": false, "text": "3-yr net $400K ยท NPV@10% $207K ยท payback 32 months (~Aug 2028)", "x": 0.01, "xref": "paper", "y": 0.98, "yref": "paper" } ], "barmode": "relative", "font": { "color": "#0b0b0b", "family": "system-ui, -apple-system, \"Segoe UI\", sans-serif", "size": 12 }, "height": 420, "hovermode": "x unified", "legend": { "bgcolor": "rgba(0,0,0,0)", "orientation": "h", "x": 0, "y": 1.0, "yanchor": "bottom" }, "margin": { "b": 45, "l": 60, "r": 30, "t": 80 }, "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": "Business case vs doing nothing
Baseline-relative: net = benefits โˆ’ (programme cost โˆ’ baseline)", "x": 0, "xanchor": "left" }, "xaxis": { "showgrid": false, "zeroline": false }, "yaxis": { "gridcolor": "#e1e0d9", "tickformat": "$,.0f", "zeroline": false } } } }, "metadata": {}, "output_type": "display_data" } ], "source": [ "fig = go.Figure()\n", "fig.add_trace(bar(X, [ben_by_year[y] for y in YEARS], \"Benefits\", \"#1baf7a\"))\n", "fig.add_trace(bar(X, [-inc[y] for y in YEARS],\n", " f\"Incremental cost vs {money(BASELINE_ANNUAL)}/yr baseline\",\n", " \"#e34948\"))\n", "cum_net = pd.Series([net_by[y] for y in YEARS]).cumsum()\n", "fig.add_trace(cum_line(X, cum_net, \"Cumulative net\"))\n", "fig.update_layout(barmode=\"relative\")\n", "# Several amounts in one annotation โ†’ html_money, or MathJax eats the text.\n", "fig.add_annotation(\n", " xref=\"paper\", yref=\"paper\", x=0.01, y=0.98, align=\"left\", showarrow=False,\n", " font=dict(size=12, color=INK2), bgcolor=SURFACE, bordercolor=GRID, borderwidth=1,\n", " text=(f\"3-yr net {html_money(kpi['net_3yr'])} ยท \"\n", " f\"NPV@{DISCOUNT_RATE:.0%} {html_money(kpi['npv'])} ยท \"\n", " f\"payback {kpi['payback']}\"))\n", "tei_layout(fig, \"Business case vs doing nothing\",\n", " \"Baseline-relative: net = benefits โˆ’ (programme cost โˆ’ baseline)\")\n", "fig.show()" ] }, { "cell_type": "markdown", "id": "cell-8", "metadata": {}, "source": [ "\n", "\n", "## 3 ยท Verification & assertions\n", "\n", "Engine pins use **explicit default arguments**, so the gate tests `studylib`, not the\n", "current widget state; live-state checks only run when the inputs sit at their defaults.\n", "This cell must pass under headless `nbconvert --execute` โ€” it is the study's smoke test.\n", "Output renders backstage only." ] }, { "cell_type": "code", "execution_count": 6, "id": "cell-9", "metadata": { "execution": { "iopub.execute_input": "2026-07-08T16:36:52.254794Z", "iopub.status.busy": "2026-07-08T16:36:52.254464Z", "iopub.status.idle": "2026-07-08T16:36:52.262674Z", "shell.execute_reply": "2026-07-08T16:36:52.261927Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "All assertions passed.\n", " net 3-yr $400K ยท payback 32 months (~Aug 2028)\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", "# Engine pins โ€” explicit defaults, independent of widget state\n", "_approx(anchor(\"platform_annual\"), 500_000) # signed overlay\n", "_approx(ANCHOR_VERBATIM[\"platform_annual\"], 600_000) # vendor record intact\n", "_p = platform_costs_by_year() # verbatim rate, 6-mo ramp\n", "_approx(_p[2026], 300_000)\n", "_approx(_p[2027], 600_000)\n", "_b = benefits_by_year()\n", "_approx(sum(_b.values()), anchor(\"benefit_3yr\"))\n", "_approx(_b[2026], 0) # nothing lands in year 1\n", "\n", "# Default-flow pins (contracted frame at engine defaults)\n", "_pc = platform_costs_by_year(annual=anchor(\"platform_annual\"))\n", "_tot = {y: current_costs_by_year()[y] + _pc[y] + services_by_year()[y] for y in YEARS}\n", "_, _net = case_flows(_tot, _b)\n", "_approx(sum(_net.values()), 400_000)\n", "assert payback_label(_net) == \"32 months (~Aug 2028)\"\n", "\n", "# Live state โ€” only checked when the widgets sit at their defaults\n", "_at_default = (PLATFORM_ANNUAL == round(anchor(\"platform_annual\"))\n", " and RAMP_MONTHS == DEFAULT_RAMP_MONTHS)\n", "if _at_default:\n", " _approx(kpi[\"net_3yr\"], 400_000)\n", "for y in YEARS:\n", " _approx(net_by[y], ben_by_year[y] - (total_by_year[y] - BASELINE_ANNUAL))\n", "\n", "backstage(\"All assertions passed.\")\n", "backstage(f\" net 3-yr {money(kpi['net_3yr'])} ยท payback {kpi['payback']}\")" ] }, { "cell_type": "markdown", "id": "cell-10", "metadata": {}, "source": [ "\n", "\n", "## 4 ยท Data appendix โ€” for the machines\n", "\n", "Everything above, dumped as markdown tables plus one JSON block of model state, so the\n", "exported report is complete LLM input without re-running anything. The dump renders\n", "**backstage** (JupyterLab and the exports) and stays hidden in the Mercury app." ] }, { "cell_type": "code", "execution_count": 7, "id": "cell-11", "metadata": { "execution": { "iopub.execute_input": "2026-07-08T16:36:52.264962Z", "iopub.status.busy": "2026-07-08T16:36:52.264712Z", "iopub.status.idle": "2026-07-08T16:36:52.284958Z", "shell.execute_reply": "2026-07-08T16:36:52.284097Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "\n", "#### Cost stack ($)\n", "\n", "| | 2026 | 2027 | 2028 | 3-yr |\n", "|:------------------------------------------|----------:|----------:|--------:|----------:|\n", "| Platform (contracted, ramp-adjusted) | 250,000 | 500,000 | 500,000 | 1,250,000 |\n", "| Services (year 1) | 250,000 | 0 | 0 | 250,000 |\n", "| Existing platform (term-contract run-off) | 1,000,000 | 1,000,000 | 0 | 2,000,000 |\n", "\n", "#### Business-case flows vs do-nothing baseline ($)\n", "\n", "| | 2026 | 2027 | 2028 |\n", "|:-----------------|----------:|----------:|----------:|\n", "| programme cost | 1,500,000 | 1,500,000 | 500,000 |\n", "| incremental cost | 500,000 | 500,000 | -500,000 |\n", "| net | -500,000 | -200,000 | 1,100,000 |\n", "\n", "#### KPIs โ€” vendor frame vs contracted\n", "\n", "| | Vendor frame ($600K/yr) | Contracted ($500K/yr) |\n", "|:----------------------|:--------------------------|:------------------------|\n", "| 3-yr benefits | $900K | $900K |\n", "| 3-yr incremental cost | $750K | $500K |\n", "| 3-yr net | $150K | $400K |\n", "| ROI | 20% | 80% |\n", "| NPV @ 10% | $3K | $207K |\n", "| Payback | 35 months (~Nov 2028) | 32 months (~Aug 2028) |\n", "\n", "#### Model state (JSON)\n", "\n", "```json\n", "{\n", " \"scenario\": \"TEMPLATE \\u2014 replace with the study's one-line scenario\",\n", " \"benefit_by_year\": {\n", " \"2026\": 0,\n", " \"2027\": 300000,\n", " \"2028\": 600000\n", " },\n", " \"cost_by_year\": {\n", " \"2026\": 1500000,\n", " \"2027\": 1500000,\n", " \"2028\": 500000\n", " },\n", " \"net_by_year\": {\n", " \"2026\": -500000,\n", " \"2027\": -200000,\n", " \"2028\": 1100000\n", " },\n", " \"kpis\": {\n", " \"benefits_3yr\": 900000.0,\n", " \"incremental_cost_3yr\": 500000.0,\n", " \"net_3yr\": 400000.0,\n", " \"roi\": 0.8,\n", " \"npv\": 206611.5702,\n", " \"discount_rate\": 0.1,\n", " \"payback\": \"32 months (~Aug 2028)\"\n", " },\n", " \"kpis_vendor_frame\": {\n", " \"benefits_3yr\": 900000.0,\n", " \"incremental_cost_3yr\": 750000.0,\n", " \"net_3yr\": 150000.0,\n", " \"roi\": 0.2,\n", " \"npv\": 3380.9166,\n", " \"discount_rate\": 0.1,\n", " \"payback\": \"35 months (~Nov 2028)\"\n", " },\n", " \"assumptions\": {\n", " \"platform_annual\": 500000,\n", " \"vendor_platform_annual\": 600000,\n", " \"ramp_months\": 6,\n", " \"discount_rate\": 0.1,\n", " \"baseline_annual\": 1000000\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(\"Cost stack ($)\", study_costs)\n", "_section(\"Business-case flows vs do-nothing baseline ($)\",\n", " pd.DataFrame({\"programme cost\": total_by_year,\n", " \"incremental cost\": inc, \"net\": net_by}).T[YEARS])\n", "_section(\"KPIs โ€” vendor frame vs contracted\", kpis_fmt)\n", "\n", "backstage(\"\\n#### Model state (JSON)\\n\")\n", "backstage(\"```json\")\n", "backstage(_json.dumps({\n", " \"scenario\": \"TEMPLATE โ€” replace with the study's one-line scenario\",\n", " \"benefit_by_year\": {str(y): round(ben_by_year[y]) for y in YEARS},\n", " \"cost_by_year\": {str(y): round(total_by_year[y]) for y in YEARS},\n", " \"net_by_year\": {str(y): round(net_by[y]) for y in YEARS},\n", " \"kpis\": {k: (round(v, 4) if isinstance(v, float) else v)\n", " for k, v in kpi.items()},\n", " \"kpis_vendor_frame\": {k: (round(v, 4) if isinstance(v, float) else v)\n", " for k, v in kpi_v.items()},\n", " \"assumptions\": {\n", " \"platform_annual\": round(PLATFORM_ANNUAL),\n", " \"vendor_platform_annual\": ANCHOR_VERBATIM[\"platform_annual\"],\n", " \"ramp_months\": RAMP_MONTHS,\n", " \"discount_rate\": DISCOUNT_RATE,\n", " \"baseline_annual\": round(BASELINE_ANNUAL),\n", " },\n", "}, indent=2))\n", "backstage(\"```\")" ] } ], "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": { "2cd79c44365840a4af77e279bf99a67f": { "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 } }, "56c2f21d17cf40009b6d4bcee7bcdd9a": { "model_module": "anywidget", "model_module_version": "~0.11.*", "model_name": "AnyModel", "state": { "_anywidget_id": "mercury.select.SelectWidget", "_css": "\n .mljar-select-container {\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 margin-bottom: 8px;\n padding-left: 4px;\n padding-right: 4px;\n }\n\n .mljar-select-label {\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 }\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 border-width: 2px;\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: none;\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 margin-top: 6px;\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 container.appendChild(dropdown);\n el.appendChild(container);\n\n let isOpen = false;\n let filteredChoices = [];\n let lastCommittedValue = \"\";\n let isEditing = false;\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 };\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 control.addEventListener(\"click\", event => {\n event.stopPropagation();\n if (isDisabled()) {\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)) {\n isEditing = false;\n setOpen(false);\n input.value = lastCommittedValue;\n }\n };\n\n document.addEventListener(\"click\", handleDocumentClick);\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 isEditing = false;\n setOpen(false);\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 document.removeEventListener(\"click\", handleDocumentClick);\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": [ "10% (vendor)", "8% (treasury)" ], "disabled": false, "hidden": false, "label": "NPV discount rate", "layout": "IPY_MODEL_2cd79c44365840a4af77e279bf99a67f", "layout_path": null, "position": "sidebar", "render_slot_id": null, "source_cell_id": null, "tabbable": null, "tooltip": null, "url_key": "", "value": "10% (vendor)" } }, "60e56739936e4f2ab8ce7a25fe31c092": { "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 margin-bottom: 8px;\n padding-left: 4px;\n padding-right: 4px;\n box-sizing: border-box;\n }\n\n .mljar-number-label {\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 border-width: 2px;\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": "Ramp โ€” billing-free months", "layout": "IPY_MODEL_e41731dbca994eeeb59b0b6edcfc9276", "layout_path": null, "max": 24.0, "min": 0.0, "position": "sidebar", "render_slot_id": null, "source_cell_id": null, "step": 3.0, "tabbable": null, "tooltip": null, "url_key": "", "value": 6.0 } }, "924aaf1b9a7244388d4fdc29e0f544e2": { "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 margin-bottom: 8px;\n padding-left: 4px;\n padding-right: 4px;\n box-sizing: border-box;\n }\n\n .mljar-number-label {\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 border-width: 2px;\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": "Platform run-rate ($/yr) โ€” contracted", "layout": "IPY_MODEL_dab8383f122d4e85b850290ec384a030", "layout_path": null, "max": 10000000.0, "min": 0.0, "position": "sidebar", "render_slot_id": null, "source_cell_id": null, "step": 25000.0, "tabbable": null, "tooltip": null, "url_key": "", "value": 500000.0 } }, "dab8383f122d4e85b850290ec384a030": { "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 } }, "e41731dbca994eeeb59b0b6edcfc9276": { "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 } } }, "version_major": 2, "version_minor": 0 } } }, "nbformat": 4, "nbformat_minor": 5 }