Files

510 lines
17 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# [Study Title] — Business Case
**Thesis:** one paragraph stating what this notebook demonstrates and the frame it uses
(here: a platform migration priced against *doing nothing*, with the vendor's pitched
numbers kept verbatim as the anchor and the signed contract layered over them).
This notebook **is** the deliverable: serve it interactively with
`mercury --working-dir notebooks/`, tune the 🟡 inputs live for the client, then export
an LLM-readable report source with `python scripts/export_report.py`. All math lives in
`studylib/` — the notebook renders it.
Confidence legend: 🟢 confirmed (published/contractual) · 🟡 estimated (working
assumption) · 🔴 unknown.
```python
# ── Setup ──────────────────────────────────────────────────────────
import sys, pathlib
_ROOT = pathlib.Path.cwd()
if not (_ROOT / "studylib").exists(): # notebook lives in notebooks/
_ROOT = _ROOT.parent
sys.path.insert(0, str(_ROOT))
import pandas as pd
import plotly.graph_objects as go
import mercury as mr
# Single source of truth — all math lives in the library; only
# presentation (and Mercury input widgets) lives here.
from studylib.model import (
YEARS, ANCHOR_VERBATIM, DEFAULT_RAMP_MONTHS,
anchor, benefits_by_year, case_flows, case_kpis, current_costs_by_year,
money, html_money, payback_label, platform_costs_by_year, services_by_year,
)
from studylib.staging import backstage
pd.options.display.float_format = "{:,.0f}".format
# ── Chart chrome (dataviz reference palette, light surface) ─────────
INK, INK2, MUTED = "#0b0b0b", "#52514e", "#898781"
SURFACE, GRID = "#fcfcfb", "#e1e0d9"
CUMULATIVE, CONTEXT = "#52514e", "#c3c2b7"
FONT_STACK = 'system-ui, -apple-system, "Segoe UI", sans-serif'
def tei_layout(fig, title, subtitle=None, height=420):
"""House chart chrome — recessive grid, ink text, title top-left."""
t = f"<b>{title}</b>"
if subtitle:
t += f"<br><span style='font-size:12px;color:{INK2}'>{subtitle}</span>"
fig.update_layout(
title=dict(text=t, font=dict(size=16, color=INK), x=0, xanchor="left"),
font=dict(family=FONT_STACK, size=12, color=INK),
paper_bgcolor=SURFACE, plot_bgcolor=SURFACE,
margin=dict(l=60, r=30, t=80, b=45), height=height,
legend=dict(orientation="h", yanchor="bottom", y=1.0, x=0,
bgcolor="rgba(0,0,0,0)"),
xaxis=dict(showgrid=False, zeroline=False),
yaxis=dict(gridcolor=GRID, zeroline=False, tickformat="$,.0f"),
hovermode="x unified",
)
return fig
def bar(x, y, name, color):
return go.Bar(x=x, y=y, name=name, marker_color=color,
marker_line=dict(color=SURFACE, width=2),
hovertemplate="%{y:$,.0f}<extra>" + name + "</extra>")
def cum_line(x, y, name, color=CUMULATIVE, dash=None):
return go.Scatter(x=x, y=y, name=name, mode="lines+markers",
line=dict(color=color, width=2, dash=dash),
marker=dict(size=8),
hovertemplate="%{y:$,.0f}<extra>" + name + "</extra>")
X = [str(y) for y in YEARS]
backstage(f"studylib loaded — window {YEARS[0]}{YEARS[-1]}")
```
studylib loaded — window 20262028
<a id="section-1"></a>
## 1 · Inputs
Contract inputs collected as live widgets. Sidebar widgets are scenario knobs; use
`position="inline"` for data-collection tables that belong in the page flow.
> **Reactivity contract:** Mercury re-executes only the cells *below* a changed
> widget's cell — never the defining cell itself. So the next cell constructs
> widgets ONLY (no other output), and `.value` is read one cell further down.
```python
# ── Inputs (Mercury sidebar — widgets only, NO other output) ────────
# NB: Mercury re-executes only cells BELOW a changed widget's cell, so
# this cell constructs widgets ONLY — .value is read downstream.
_platform_w = mr.NumberInput(label="Platform run-rate ($/yr) — contracted",
value=round(anchor("platform_annual")),
min=0, max=10_000_000, step=25_000)
_ramp_w = mr.NumberInput(label="Ramp — billing-free months",
value=DEFAULT_RAMP_MONTHS, min=0, max=24, step=3)
_npv_w = mr.Select(label="NPV discount rate", value="10% (vendor)",
choices=["10% (vendor)", "8% (treasury)"])
```
<mercury.number.NumberInputWidget object at 0x726bea6f2cf0>
<mercury.number.NumberInputWidget object at 0x726bea609950>
<mercury.select.SelectWidget object at 0x726c48215160>
```python
# ── Model state (re-runs on any change to the widgets above) ────────
PLATFORM_ANNUAL = float(_platform_w.value)
RAMP_MONTHS = int(_ramp_w.value)
DISCOUNT_RATE = 0.10 if _npv_w.value.startswith("10") else 0.08
platform_by_year = platform_costs_by_year(RAMP_MONTHS, PLATFORM_ANNUAL)
services_by = services_by_year()
current_by_year = current_costs_by_year()
ben_by_year = benefits_by_year()
BASELINE_ANNUAL = anchor("baseline_annual")
backstage(f"platform by year: { {y: money(v) for y, v in platform_by_year.items()} }")
print(f"platform run-rate: contracted {money(PLATFORM_ANNUAL)}/yr "
f"(vendor pitched {money(ANCHOR_VERBATIM['platform_annual'])}/yr) · "
f"ramp {RAMP_MONTHS} months")
```
platform by year: {2026: '$250K', 2027: '$500K', 2028: '$500K'}
platform run-rate: contracted $500K/yr (vendor pitched $600K/yr) · ramp 6 months
<a id="section-2"></a>
## 2 · Business case vs doing nothing
**Frame:** baseline-relative. Incremental cost = programme cost baseline
(the do-nothing run-rate); net = benefits incremental cost. Double-billing
while the old platform runs off and the post-termination cost-avoidance credit
both fall out of this one frame.
The KPI table keeps a **vendor-frame column** (pitched rate, verbatim anchors)
beside the **contracted column**, so the client can walk from their own numbers
to the corrected reality.
```python
study_costs = pd.DataFrame({
"Platform (contracted, ramp-adjusted)": platform_by_year,
"Services (year 1)": services_by,
"Existing platform (term-contract run-off)": current_by_year,
}).T[YEARS]
study_costs["3-yr"] = study_costs.sum(axis=1)
total_by_year = {y: float(study_costs[y].sum()) for y in YEARS}
inc, net_by = case_flows(total_by_year, ben_by_year)
kpi = case_kpis(inc, net_by, DISCOUNT_RATE)
# Vendor-anchored comparison: same frame at the pitched (verbatim) rate.
_plat_vendor = platform_costs_by_year(RAMP_MONTHS, ANCHOR_VERBATIM["platform_annual"])
total_vendor = {y: current_by_year[y] + _plat_vendor[y] + services_by[y] for y in YEARS}
inc_v, net_v = case_flows(total_vendor, ben_by_year)
kpi_v = case_kpis(inc_v, net_v, DISCOUNT_RATE)
def kpi_col(k):
return {
"3-yr benefits": money(k["benefits_3yr"]),
"3-yr incremental cost": money(k["incremental_cost_3yr"]),
"3-yr net": money(k["net_3yr"]),
"ROI": f"{k['roi']:.0%}" if k["roi"] is not None else "n/a — net saving",
f"NPV @ {k['discount_rate']:.0%}": money(k["npv"]),
"Payback": k["payback"],
}
kpis_fmt = pd.DataFrame({
f"Vendor frame ({money(ANCHOR_VERBATIM['platform_annual'])}/yr)": kpi_col(kpi_v),
f"Contracted ({money(PLATFORM_ANNUAL)}/yr)": kpi_col(kpi),
})
backstage(f"net by year: { {y: money(v) for y, v in net_by.items()} }")
display(study_costs)
display(kpis_fmt)
```
net by year: {2026: '-$500K', 2027: '-$200K', 2028: '$1.1M'}
<div>
<style scoped>
.dataframe tbody tr th:only-of-type {
vertical-align: middle;
}
.dataframe tbody tr th {
vertical-align: top;
}
.dataframe thead th {
text-align: right;
}
</style>
<table border="1" class="dataframe">
<thead>
<tr style="text-align: right;">
<th></th>
<th>2026</th>
<th>2027</th>
<th>2028</th>
<th>3-yr</th>
</tr>
</thead>
<tbody>
<tr>
<th>Platform (contracted, ramp-adjusted)</th>
<td>250,000</td>
<td>500,000</td>
<td>500,000</td>
<td>1,250,000</td>
</tr>
<tr>
<th>Services (year 1)</th>
<td>250,000</td>
<td>0</td>
<td>0</td>
<td>250,000</td>
</tr>
<tr>
<th>Existing platform (term-contract run-off)</th>
<td>1,000,000</td>
<td>1,000,000</td>
<td>0</td>
<td>2,000,000</td>
</tr>
</tbody>
</table>
</div>
<div>
<style scoped>
.dataframe tbody tr th:only-of-type {
vertical-align: middle;
}
.dataframe tbody tr th {
vertical-align: top;
}
.dataframe thead th {
text-align: right;
}
</style>
<table border="1" class="dataframe">
<thead>
<tr style="text-align: right;">
<th></th>
<th>Vendor frame ($600K/yr)</th>
<th>Contracted ($500K/yr)</th>
</tr>
</thead>
<tbody>
<tr>
<th>3-yr benefits</th>
<td>$900K</td>
<td>$900K</td>
</tr>
<tr>
<th>3-yr incremental cost</th>
<td>$750K</td>
<td>$500K</td>
</tr>
<tr>
<th>3-yr net</th>
<td>$150K</td>
<td>$400K</td>
</tr>
<tr>
<th>ROI</th>
<td>20%</td>
<td>80%</td>
</tr>
<tr>
<th>NPV @ 10%</th>
<td>$3K</td>
<td>$207K</td>
</tr>
<tr>
<th>Payback</th>
<td>35 months (~Nov 2028)</td>
<td>32 months (~Aug 2028)</td>
</tr>
</tbody>
</table>
</div>
```python
fig = go.Figure()
fig.add_trace(bar(X, [ben_by_year[y] for y in YEARS], "Benefits", "#1baf7a"))
fig.add_trace(bar(X, [-inc[y] for y in YEARS],
f"Incremental cost vs {money(BASELINE_ANNUAL)}/yr baseline",
"#e34948"))
cum_net = pd.Series([net_by[y] for y in YEARS]).cumsum()
fig.add_trace(cum_line(X, cum_net, "Cumulative net"))
fig.update_layout(barmode="relative")
# Several amounts in one annotation → html_money, or MathJax eats the text.
fig.add_annotation(
xref="paper", yref="paper", x=0.01, y=0.98, align="left", showarrow=False,
font=dict(size=12, color=INK2), bgcolor=SURFACE, bordercolor=GRID, borderwidth=1,
text=(f"3-yr net <b>{html_money(kpi['net_3yr'])}</b> · "
f"NPV@{DISCOUNT_RATE:.0%} <b>{html_money(kpi['npv'])}</b> · "
f"payback <b>{kpi['payback']}</b>"))
tei_layout(fig, "Business case vs doing nothing",
"Baseline-relative: net = benefits (programme cost baseline)")
fig.show()
```
<a id="section-3"></a>
## 3 · Verification & assertions
Engine pins use **explicit default arguments**, so the gate tests `studylib`, not the
current widget state; live-state checks only run when the inputs sit at their defaults.
This cell must pass under headless `nbconvert --execute` — it is the study's smoke test.
Output renders backstage only.
```python
def _approx(got, want, tol=0.5):
assert abs(got - want) <= tol, f"got {got:,.2f}, want {want:,.2f}"
# Engine pins — explicit defaults, independent of widget state
_approx(anchor("platform_annual"), 500_000) # signed overlay
_approx(ANCHOR_VERBATIM["platform_annual"], 600_000) # vendor record intact
_p = platform_costs_by_year() # verbatim rate, 6-mo ramp
_approx(_p[2026], 300_000)
_approx(_p[2027], 600_000)
_b = benefits_by_year()
_approx(sum(_b.values()), anchor("benefit_3yr"))
_approx(_b[2026], 0) # nothing lands in year 1
# Default-flow pins (contracted frame at engine defaults)
_pc = platform_costs_by_year(annual=anchor("platform_annual"))
_tot = {y: current_costs_by_year()[y] + _pc[y] + services_by_year()[y] for y in YEARS}
_, _net = case_flows(_tot, _b)
_approx(sum(_net.values()), 400_000)
assert payback_label(_net) == "32 months (~Aug 2028)"
# Live state — only checked when the widgets sit at their defaults
_at_default = (PLATFORM_ANNUAL == round(anchor("platform_annual"))
and RAMP_MONTHS == DEFAULT_RAMP_MONTHS)
if _at_default:
_approx(kpi["net_3yr"], 400_000)
for y in YEARS:
_approx(net_by[y], ben_by_year[y] - (total_by_year[y] - BASELINE_ANNUAL))
backstage("All assertions passed.")
backstage(f" net 3-yr {money(kpi['net_3yr'])} · payback {kpi['payback']}")
```
All assertions passed.
net 3-yr $400K · payback 32 months (~Aug 2028)
<a id="section-4"></a>
## 4 · Data appendix — for the machines
Everything above, dumped as markdown tables plus one JSON block of model state, so the
exported report is complete LLM input without re-running anything. The dump renders
**backstage** (JupyterLab and the exports) and stays hidden in the Mercury app.
```python
# ── Data appendix — LLM-readable dump of every model output ──────────
# Renders backstage only (JupyterLab / nbconvert exports) — hidden on
# the Mercury stage, where the narrative and figures carry the story.
import json as _json
def _section(title, df, **kw):
backstage(f"\n#### {title}\n")
backstage(df.to_markdown(floatfmt=",.0f", **kw))
_section("Cost stack ($)", study_costs)
_section("Business-case flows vs do-nothing baseline ($)",
pd.DataFrame({"programme cost": total_by_year,
"incremental cost": inc, "net": net_by}).T[YEARS])
_section("KPIs — vendor frame vs contracted", kpis_fmt)
backstage("\n#### Model state (JSON)\n")
backstage("```json")
backstage(_json.dumps({
"scenario": "TEMPLATE — replace with the study's one-line scenario",
"benefit_by_year": {str(y): round(ben_by_year[y]) for y in YEARS},
"cost_by_year": {str(y): round(total_by_year[y]) for y in YEARS},
"net_by_year": {str(y): round(net_by[y]) for y in YEARS},
"kpis": {k: (round(v, 4) if isinstance(v, float) else v)
for k, v in kpi.items()},
"kpis_vendor_frame": {k: (round(v, 4) if isinstance(v, float) else v)
for k, v in kpi_v.items()},
"assumptions": {
"platform_annual": round(PLATFORM_ANNUAL),
"vendor_platform_annual": ANCHOR_VERBATIM["platform_annual"],
"ramp_months": RAMP_MONTHS,
"discount_rate": DISCOUNT_RATE,
"baseline_annual": round(BASELINE_ANNUAL),
},
}, indent=2))
backstage("```")
```
#### Cost stack ($)
| | 2026 | 2027 | 2028 | 3-yr |
|:------------------------------------------|----------:|----------:|--------:|----------:|
| Platform (contracted, ramp-adjusted) | 250,000 | 500,000 | 500,000 | 1,250,000 |
| Services (year 1) | 250,000 | 0 | 0 | 250,000 |
| Existing platform (term-contract run-off) | 1,000,000 | 1,000,000 | 0 | 2,000,000 |
#### Business-case flows vs do-nothing baseline ($)
| | 2026 | 2027 | 2028 |
|:-----------------|----------:|----------:|----------:|
| programme cost | 1,500,000 | 1,500,000 | 500,000 |
| incremental cost | 500,000 | 500,000 | -500,000 |
| net | -500,000 | -200,000 | 1,100,000 |
#### KPIs — vendor frame vs contracted
| | Vendor frame ($600K/yr) | Contracted ($500K/yr) |
|:----------------------|:--------------------------|:------------------------|
| 3-yr benefits | $900K | $900K |
| 3-yr incremental cost | $750K | $500K |
| 3-yr net | $150K | $400K |
| ROI | 20% | 80% |
| NPV @ 10% | $3K | $207K |
| Payback | 35 months (~Nov 2028) | 32 months (~Aug 2028) |
#### Model state (JSON)
```json
{
"scenario": "TEMPLATE \u2014 replace with the study's one-line scenario",
"benefit_by_year": {
"2026": 0,
"2027": 300000,
"2028": 600000
},
"cost_by_year": {
"2026": 1500000,
"2027": 1500000,
"2028": 500000
},
"net_by_year": {
"2026": -500000,
"2027": -200000,
"2028": 1100000
},
"kpis": {
"benefits_3yr": 900000.0,
"incremental_cost_3yr": 500000.0,
"net_3yr": 400000.0,
"roi": 0.8,
"npv": 206611.5702,
"discount_rate": 0.1,
"payback": "32 months (~Aug 2028)"
},
"kpis_vendor_frame": {
"benefits_3yr": 900000.0,
"incremental_cost_3yr": 750000.0,
"net_3yr": 150000.0,
"roi": 0.2,
"npv": 3380.9166,
"discount_rate": 0.1,
"payback": "35 months (~Nov 2028)"
},
"assumptions": {
"platform_annual": 500000,
"vendor_platform_annual": 600000,
"ramp_months": 6,
"discount_rate": 0.1,
"baseline_annual": 1000000
}
}
```