""" 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("$", "$")