167 lines
6.2 KiB
Python
167 lines
6.2 KiB
Python
"""
|
|
Study engine — the single source of truth for every number in the notebooks.
|
|
|
|
Replace the toy domain below with the study's real model; the *structure*
|
|
is the pattern:
|
|
|
|
- ``ANCHOR_VERBATIM`` — the client/vendor source record, never edited.
|
|
- ``ANCHOR_CONTRACTED`` — signed values layered over it; ``anchor()`` reads
|
|
through, so the source anchor survives for as-pitched comparisons.
|
|
- ``*_by_year`` schedules keyed by calendar year.
|
|
- A baseline-relative case frame (``case_flows`` / ``case_kpis``).
|
|
|
|
The notebooks hold no math — they call this module and render.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
|
|
# ── Timeline ─────────────────────────────────────────────────────────
|
|
|
|
YEARS = [2026, 2027, 2028] # model window; contract starts Jan of YEARS[0]
|
|
_MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
|
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
|
|
|
|
|
|
def month_label(m: int) -> str:
|
|
"""Calendar label for a 1-indexed month from Jan of YEARS[0]."""
|
|
return f"{_MONTHS[(m - 1) % 12]} {YEARS[0] + (m - 1) // 12}"
|
|
|
|
|
|
# ── Anchors: verbatim source record + contracted overlay ─────────────
|
|
|
|
#: The vendor's pitch / the client's source deck — VERBATIM, do not edit.
|
|
ANCHOR_VERBATIM: dict[str, float] = {
|
|
"baseline_annual": 1_000_000, # do-nothing run-rate
|
|
"platform_annual": 600_000, # pitched platform run-rate
|
|
"services_y1": 250_000, # pitched one-off services, year 1
|
|
"benefit_3yr": 900_000, # claimed 3-yr benefit
|
|
"npv_discount_rate": 0.10,
|
|
}
|
|
|
|
#: Signed values where they differ from the pitch — 🟢 contractual.
|
|
ANCHOR_CONTRACTED: dict[str, float] = {
|
|
"platform_annual": 500_000, # signed run-rate (pitch said $600K)
|
|
}
|
|
|
|
|
|
def anchor(key: str) -> float:
|
|
"""Contracted value where one exists, else the verbatim anchor."""
|
|
return ANCHOR_CONTRACTED.get(key, ANCHOR_VERBATIM[key])
|
|
|
|
|
|
DEFAULT_RAMP_MONTHS = 6 # platform billing starts month 7
|
|
DEFAULT_TERMINATION_YEAR = 2027 # existing platform bills through this year
|
|
REALIZE_MONTH = 18 # benefits realize from month 19
|
|
|
|
|
|
# ── Cost & benefit schedules (calendar-year keyed) ───────────────────
|
|
|
|
|
|
def platform_costs_by_year(
|
|
ramp_months: int = DEFAULT_RAMP_MONTHS, annual: float | None = None
|
|
) -> dict[int, float]:
|
|
"""Ramp programme: billing starts in calendar month ramp_months + 1."""
|
|
rate = ANCHOR_VERBATIM["platform_annual"] if annual is None else annual
|
|
out = {}
|
|
for yi, y in enumerate(YEARS, start=1):
|
|
start, end = 12 * (yi - 1) + 1, 12 * yi
|
|
months = max(0, end - max(start, ramp_months + 1) + 1)
|
|
out[y] = rate * months / 12
|
|
return out
|
|
|
|
|
|
def current_costs_by_year(
|
|
termination_year: int = DEFAULT_TERMINATION_YEAR, annual: float | None = None
|
|
) -> dict[int, float]:
|
|
"""Existing-platform run-off (the double-billing line)."""
|
|
rate = ANCHOR_VERBATIM["baseline_annual"] if annual is None else annual
|
|
return {y: (rate if y <= termination_year else 0.0) for y in YEARS}
|
|
|
|
|
|
def services_by_year() -> dict[int, float]:
|
|
"""One-off services — verbatim, year 1 only."""
|
|
return {y: (ANCHOR_VERBATIM["services_y1"] if y == YEARS[0] else 0.0)
|
|
for y in YEARS}
|
|
|
|
|
|
def benefits_by_year(realize_month: int = REALIZE_MONTH) -> dict[int, float]:
|
|
"""Phase the claimed 3-yr benefit across its live months."""
|
|
live = {y: max(0, 12 * yi - max(12 * (yi - 1), realize_month))
|
|
for yi, y in enumerate(YEARS, start=1)}
|
|
total = sum(live.values())
|
|
return {y: anchor("benefit_3yr") * m / total if total else 0.0
|
|
for y, m in live.items()}
|
|
|
|
|
|
# ── Business case (baseline-relative frame) ──────────────────────────
|
|
|
|
|
|
def case_flows(
|
|
total_cost_by_year: dict[int, float],
|
|
benefit_by_year: dict[int, float],
|
|
baseline_annual: float | None = None,
|
|
) -> tuple[dict[int, float], dict[int, float]]:
|
|
"""(incremental cost, net) vs the do-nothing baseline."""
|
|
base = ANCHOR_VERBATIM["baseline_annual"] if baseline_annual is None \
|
|
else baseline_annual
|
|
inc = {y: total_cost_by_year[y] - base for y in YEARS}
|
|
net = {y: benefit_by_year[y] - inc[y] for y in YEARS}
|
|
return inc, net
|
|
|
|
|
|
def npv(flows: list[float], rate: float) -> float:
|
|
"""NPV with the first flow discounted one full year."""
|
|
return sum(v / (1 + rate) ** i for i, v in enumerate(flows, start=1))
|
|
|
|
|
|
def payback_label(net_by_year: dict[int, float]) -> str:
|
|
cum = 0.0
|
|
for i, y in enumerate(YEARS):
|
|
step = net_by_year[y]
|
|
if cum + step >= 0:
|
|
if i == 0 and step >= 0:
|
|
return "immediate"
|
|
frac = (-cum / step) if step > 0 else 0.0
|
|
m = math.ceil((i + frac) * 12)
|
|
return f"{m} months (~{month_label(m)})"
|
|
cum += step
|
|
return f"beyond {YEARS[-1]}"
|
|
|
|
|
|
def case_kpis(
|
|
inc: dict[int, float],
|
|
net: dict[int, float],
|
|
discount_rate: float | None = None,
|
|
) -> dict:
|
|
"""KPIs for one cost frame. Benefits are recoverable as net + inc."""
|
|
rate = ANCHOR_VERBATIM["npv_discount_rate"] if discount_rate is None \
|
|
else discount_rate
|
|
net_list = [net[y] for y in YEARS]
|
|
inc_total = sum(inc.values())
|
|
net_total = sum(net_list)
|
|
return {
|
|
"benefits_3yr": net_total + inc_total,
|
|
"incremental_cost_3yr": inc_total,
|
|
"net_3yr": net_total,
|
|
"roi": (net_total / inc_total) if inc_total > 0 else None,
|
|
"npv": npv(net_list, rate),
|
|
"discount_rate": rate,
|
|
"payback": payback_label(net),
|
|
}
|
|
|
|
|
|
# ── Display helpers ──────────────────────────────────────────────────
|
|
|
|
|
|
def money(v: float) -> str:
|
|
sign, a = ("-" if v < 0 else ""), abs(v)
|
|
return f"{sign}${a/1e6:,.1f}M" if a >= 1e6 else f"{sign}${a/1e3:,.0f}K"
|
|
|
|
|
|
def html_money(v: float) -> str:
|
|
"""Plotly text with two or more bare ``$`` triggers MathJax math mode —
|
|
annotations holding several amounts must use the HTML entity instead."""
|
|
return money(v).replace("$", "$")
|