docs: introduce Mercury Notebook Deliverable Pattern
This commit is contained in:
1
template/MercuryNotebook/studylib/__init__.py
Normal file
1
template/MercuryNotebook/studylib/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Study engine package — rename ``studylib`` to match your study."""
|
||||
166
template/MercuryNotebook/studylib/model.py
Normal file
166
template/MercuryNotebook/studylib/model.py
Normal file
@@ -0,0 +1,166 @@
|
||||
"""
|
||||
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("$", "$")
|
||||
29
template/MercuryNotebook/studylib/staging.py
Normal file
29
template/MercuryNotebook/studylib/staging.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""
|
||||
Stage vs backstage — is this notebook render stakeholder-facing?
|
||||
|
||||
The Mercury CLI (``mercury --working-dir …``) exports ``MERCURY_CONFIG_DIR``
|
||||
into the server process so the widget library can locate ``config.toml``
|
||||
(see ``mercury/config.py``); every kernel that server spawns inherits it.
|
||||
JupyterLab and nbconvert kernels don't have it. That makes the variable a
|
||||
reliable signal for "the audience is looking" (the stage) versus an
|
||||
analyst session or a headless export run (backstage).
|
||||
|
||||
Diagnostics routed through :func:`backstage` stay visible in JupyterLab
|
||||
and land in the nbconvert exports (where the machine-readable appendix
|
||||
must appear for LLM consumption) but never render in the Mercury app.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
|
||||
def on_stage() -> bool:
|
||||
"""True when running under the Mercury app (stakeholder-facing)."""
|
||||
return os.getenv("MERCURY_CONFIG_DIR") is not None
|
||||
|
||||
|
||||
def backstage(*args, **kwargs) -> None:
|
||||
"""``print`` that renders only backstage (JupyterLab, nbconvert)."""
|
||||
if not on_stage():
|
||||
print(*args, **kwargs)
|
||||
Reference in New Issue
Block a user