1936 lines
89 KiB
Plaintext
1936 lines
89 KiB
Plaintext
{
|
||
"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\"<b>{title}</b>\"\n",
|
||
" if subtitle:\n",
|
||
" t += f\"<br><span style='font-size:12px;color:{INK2}'>{subtitle}</span>\"\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}<extra>\" + name + \"</extra>\")\n",
|
||
"\n",
|
||
"\n",
|
||
"def cum_line(x, y, name, color=CUMULATIVE, dash=None):\n",
|
||
" return go.Scatter(x=x, y=y, name=name, mode=\"lines+markers\",\n",
|
||
" line=dict(color=color, width=2, dash=dash),\n",
|
||
" marker=dict(size=8),\n",
|
||
" hovertemplate=\"%{y:$,.0f}<extra>\" + name + \"</extra>\")\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": [
|
||
"<a id=\"section-1\"></a>\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": [
|
||
"<mercury.number.NumberInputWidget object at 0x7f00bfe02cf0>"
|
||
]
|
||
},
|
||
"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": [
|
||
"<mercury.number.NumberInputWidget object at 0x7f00bfd15950>"
|
||
]
|
||
},
|
||
"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": [
|
||
"<mercury.select.SelectWidget object at 0x7f01201d9160>"
|
||
]
|
||
},
|
||
"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": [
|
||
"<a id=\"section-2\"></a>\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": [
|
||
"<div>\n",
|
||
"<style scoped>\n",
|
||
" .dataframe tbody tr th:only-of-type {\n",
|
||
" vertical-align: middle;\n",
|
||
" }\n",
|
||
"\n",
|
||
" .dataframe tbody tr th {\n",
|
||
" vertical-align: top;\n",
|
||
" }\n",
|
||
"\n",
|
||
" .dataframe thead th {\n",
|
||
" text-align: right;\n",
|
||
" }\n",
|
||
"</style>\n",
|
||
"<table border=\"1\" class=\"dataframe\">\n",
|
||
" <thead>\n",
|
||
" <tr style=\"text-align: right;\">\n",
|
||
" <th></th>\n",
|
||
" <th>2026</th>\n",
|
||
" <th>2027</th>\n",
|
||
" <th>2028</th>\n",
|
||
" <th>3-yr</th>\n",
|
||
" </tr>\n",
|
||
" </thead>\n",
|
||
" <tbody>\n",
|
||
" <tr>\n",
|
||
" <th>Platform (contracted, ramp-adjusted)</th>\n",
|
||
" <td>250,000</td>\n",
|
||
" <td>500,000</td>\n",
|
||
" <td>500,000</td>\n",
|
||
" <td>1,250,000</td>\n",
|
||
" </tr>\n",
|
||
" <tr>\n",
|
||
" <th>Services (year 1)</th>\n",
|
||
" <td>250,000</td>\n",
|
||
" <td>0</td>\n",
|
||
" <td>0</td>\n",
|
||
" <td>250,000</td>\n",
|
||
" </tr>\n",
|
||
" <tr>\n",
|
||
" <th>Existing platform (term-contract run-off)</th>\n",
|
||
" <td>1,000,000</td>\n",
|
||
" <td>1,000,000</td>\n",
|
||
" <td>0</td>\n",
|
||
" <td>2,000,000</td>\n",
|
||
" </tr>\n",
|
||
" </tbody>\n",
|
||
"</table>\n",
|
||
"</div>"
|
||
],
|
||
"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": [
|
||
"<div>\n",
|
||
"<style scoped>\n",
|
||
" .dataframe tbody tr th:only-of-type {\n",
|
||
" vertical-align: middle;\n",
|
||
" }\n",
|
||
"\n",
|
||
" .dataframe tbody tr th {\n",
|
||
" vertical-align: top;\n",
|
||
" }\n",
|
||
"\n",
|
||
" .dataframe thead th {\n",
|
||
" text-align: right;\n",
|
||
" }\n",
|
||
"</style>\n",
|
||
"<table border=\"1\" class=\"dataframe\">\n",
|
||
" <thead>\n",
|
||
" <tr style=\"text-align: right;\">\n",
|
||
" <th></th>\n",
|
||
" <th>Vendor frame ($600K/yr)</th>\n",
|
||
" <th>Contracted ($500K/yr)</th>\n",
|
||
" </tr>\n",
|
||
" </thead>\n",
|
||
" <tbody>\n",
|
||
" <tr>\n",
|
||
" <th>3-yr benefits</th>\n",
|
||
" <td>$900K</td>\n",
|
||
" <td>$900K</td>\n",
|
||
" </tr>\n",
|
||
" <tr>\n",
|
||
" <th>3-yr incremental cost</th>\n",
|
||
" <td>$750K</td>\n",
|
||
" <td>$500K</td>\n",
|
||
" </tr>\n",
|
||
" <tr>\n",
|
||
" <th>3-yr net</th>\n",
|
||
" <td>$150K</td>\n",
|
||
" <td>$400K</td>\n",
|
||
" </tr>\n",
|
||
" <tr>\n",
|
||
" <th>ROI</th>\n",
|
||
" <td>20%</td>\n",
|
||
" <td>80%</td>\n",
|
||
" </tr>\n",
|
||
" <tr>\n",
|
||
" <th>NPV @ 10%</th>\n",
|
||
" <td>$3K</td>\n",
|
||
" <td>$207K</td>\n",
|
||
" </tr>\n",
|
||
" <tr>\n",
|
||
" <th>Payback</th>\n",
|
||
" <td>35 months (~Nov 2028)</td>\n",
|
||
" <td>32 months (~Aug 2028)</td>\n",
|
||
" </tr>\n",
|
||
" </tbody>\n",
|
||
"</table>\n",
|
||
"</div>"
|
||
],
|
||
"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}<extra>Benefits</extra>",
|
||
"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}<extra>Incremental cost vs $1.0M/yr baseline</extra>",
|
||
"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}<extra>Cumulative net</extra>",
|
||
"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 <b>$400K</b> · NPV@10% <b>$207K</b> · payback <b>32 months (~Aug 2028)</b>",
|
||
"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": "<b>Business case vs doing nothing</b><br><span style='font-size:12px;color:#52514e'>Baseline-relative: net = benefits − (programme cost − baseline)</span>",
|
||
"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 <b>{html_money(kpi['net_3yr'])}</b> · \"\n",
|
||
" f\"NPV@{DISCOUNT_RATE:.0%} <b>{html_money(kpi['npv'])}</b> · \"\n",
|
||
" f\"payback <b>{kpi['payback']}</b>\"))\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": [
|
||
"<a id=\"section-3\"></a>\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": [
|
||
"<a id=\"section-4\"></a>\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
|
||
}
|