Replace Streamlit and JupyterLab commands with Mercury for serving interactive notebooks as web apps. Update README to reflect new architecture where notebooks are the primary deliverables, utilizing Mercury input widgets for live client tuning. Add export_report.py script to generate LLM-readable HTML/Markdown reports from the notebooks. Update corrected business case notebook to include Mercury dependency and usage instructions.
516 lines
21 KiB
Python
516 lines
21 KiB
Python
"""
|
||
Appendix-4 corrected business case — the Genesys/Broadreach benefits
|
||
kept verbatim, with the costs the deck omitted: AI Experience token
|
||
consumption, AI implementation effort (V2 LoE), and double-billing of
|
||
the existing platforms until their term contracts end.
|
||
|
||
Single source of truth behind the deliverable notebook
|
||
(``notebooks/ctm_business_case_corrected.ipynb``, served with
|
||
Mercury) — the presentation layer holds no math.
|
||
|
||
Sources: ``docs/Appendix 4 - CCaaS Platform Benefit Calculations
|
||
(Consolidated).pptx`` (verbatim figures, deployment schedule) and
|
||
``docs/ctm_ai_labour_estimate_V2.md`` (implementation hours).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import dataclasses
|
||
import datetime as dt
|
||
import math
|
||
|
||
import pandas as pd
|
||
|
||
from .business_case import npv, payback_years
|
||
from .cost_model import calculate_total_cost
|
||
from .defaults import DEFAULT_METERS
|
||
from .inputs import FeatureScope, SiteInput
|
||
from .meters import Confidence, TokenMeter, TokenPricing
|
||
from .rollout import RolloutPlan
|
||
from .scenarios import Scenario
|
||
|
||
# ── Timeline ─────────────────────────────────────────────────────────
|
||
|
||
YEARS = [2026, 2027, 2028] # model years 1..3, contract start Jan 2026
|
||
YEAR_INDEX = {2026: 1, 2027: 2, 2028: 3}
|
||
_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 2026 (m=21 → 'Sep 2027')."""
|
||
return f"{_MONTHS[(m - 1) % 12]} {2026 + (m - 1) // 12}"
|
||
|
||
|
||
# ── Verbatim Appendix 4 figures ──────────────────────────────────────
|
||
|
||
REGIONS = ["NA", "ANZ", "EMEA", "ASIA"]
|
||
CAPABILITIES = ["Agent Copilot", "WFM", "Email", "STA",
|
||
"Predictive Routing", "Supervisor Copilot"]
|
||
|
||
#: (annual_value, three_yr_value) — VERBATIM slides 12-15, do not edit.
|
||
VERBATIM_BENEFITS: dict[tuple[str, str], tuple[float, float]] = {
|
||
("NA", "Agent Copilot"): (2_400_000, 3_400_000),
|
||
("NA", "Email"): (1_900_000, 2_500_000),
|
||
("NA", "STA"): (294_000, 506_000),
|
||
("NA", "Supervisor Copilot"): (218_000, 291_000),
|
||
("NA", "Predictive Routing"): (97_000, 167_000),
|
||
("NA", "WFM"): (0, 0), # NA excluded — has similar feature
|
||
("ANZ", "Agent Copilot"): (3_600_000, 3_900_000),
|
||
("ANZ", "WFM"): (1_300_000, 1_400_000),
|
||
("ANZ", "Predictive Routing"): (279_000, 302_000),
|
||
("ANZ", "Email"): (132_000, 143_000),
|
||
("ANZ", "STA"): (97_000, 105_000),
|
||
("ANZ", "Supervisor Copilot"): (25_000, 27_000),
|
||
("ASIA", "WFM"): (1_600_000, 914_000),
|
||
("ASIA", "Email"): (160_000, 93_000),
|
||
("ASIA", "STA"): (124_000, 72_000),
|
||
("ASIA", "Predictive Routing"): (87_000, 51_000),
|
||
("ASIA", "Agent Copilot"): (0, 0),
|
||
("ASIA", "Supervisor Copilot"): (0, 0),
|
||
("EMEA", "WFM"): (824_000, 687_000),
|
||
("EMEA", "Email"): (282_000, 235_000),
|
||
("EMEA", "STA"): (157_000, 131_000),
|
||
("EMEA", "Agent Copilot"): (77_000, 64_000),
|
||
("EMEA", "Supervisor Copilot"): (59_000, 49_000),
|
||
("EMEA", "Predictive Routing"): (7_000, 6_000),
|
||
}
|
||
|
||
#: The deck's own (rounded) summary rows — slides 8-9.
|
||
SLIDE_TOTALS: dict = {
|
||
"regional_3yr": {"NA": 6_900_000, "ANZ": 5_900_000,
|
||
"ASIA": 1_100_000, "EMEA": 1_200_000},
|
||
"capability_3yr": {"Agent Copilot": 7_400_000, "WFM": 3_000_000,
|
||
"Email": 2_900_000, "STA": 814_000,
|
||
"Predictive Routing": 526_000,
|
||
"Supervisor Copilot": 367_000},
|
||
"total_3yr": 15_000_000,
|
||
"total_annual": 13_600_000,
|
||
}
|
||
|
||
#: Verbatim TCO anchors — slides 5-6.
|
||
TCO_VERBATIM: dict[str, float] = {
|
||
"current_annual": 7_300_000, # current global spend / yr
|
||
"current_3yr": 22_000_000,
|
||
"ccaas_annual": 4_300_000, # licence run-rate / yr
|
||
"ccaas_3yr": 15_400_000, # deck's 3-yr CCaaS investment (no ramp, no AI costs)
|
||
"prof_services_y1": 2_400_000,
|
||
"training_y1": 167_000,
|
||
"npv_discount_rate": 0.135, # deck's benefit-NPV rate
|
||
}
|
||
|
||
#: Genesys/Broadreach deployment schedule (slides 17-21), months from
|
||
#: Jan 2026 inclusive. Benefits realize IMPL + 3 months.
|
||
IMPL_MONTH = {"NA": 18, "ANZ": 21, "EMEA": 24, "ASIA": 27}
|
||
BENEFIT_LAG_MONTHS = 3
|
||
REALIZE_MONTH = {r: m + BENEFIT_LAG_MONTHS for r, m in IMPL_MONTH.items()}
|
||
#: NA Gantt exception: Email implemented Jan 2027, realizes Apr 2027.
|
||
NA_EMAIL_IMPL_MONTH = 13
|
||
|
||
DEFAULT_RAMP_MONTHS = 12 # Genesys ramp programme
|
||
DEFAULT_TERMINATION = dt.date(2027, 12, 31) # current-platform term contracts
|
||
|
||
# ── Region ⇄ site mapping ────────────────────────────────────────────
|
||
|
||
|
||
def site_region(site_name: str) -> str:
|
||
"""Map a tokencalc site to its Appendix-4 region (APAC * → ASIA)."""
|
||
return {"NAM": "NA", "AUZ": "ANZ", "EMEA": "EMEA"}.get(site_name, "ASIA")
|
||
|
||
|
||
def region_site_names(sites: list[SiteInput]) -> dict[str, list[str]]:
|
||
return {r: [s.site_name for s in sites if site_region(s.site_name) == r]
|
||
for r in REGIONS}
|
||
|
||
|
||
def region_agents(sites: list[SiteInput]) -> dict[str, int]:
|
||
return {r: sum(s.agents for s in sites if site_region(s.site_name) == r)
|
||
for r in REGIONS}
|
||
|
||
|
||
# ── Verbatim benefit helpers ─────────────────────────────────────────
|
||
|
||
|
||
def verbatim_dataframe() -> pd.DataFrame:
|
||
"""Long DataFrame of the verbatim benefits: region, capability, annual, three_yr."""
|
||
return pd.DataFrame(
|
||
[{"region": r, "capability": c, "annual": a, "three_yr": t}
|
||
for (r, c), (a, t) in VERBATIM_BENEFITS.items()]
|
||
)
|
||
|
||
|
||
def crossfoot_tolerance(value: float) -> float:
|
||
"""The deck rounds to $0.1M and its own tables cross-foot ±$50-120K."""
|
||
return max(100_000, 0.015 * value)
|
||
|
||
|
||
# ── Schedules & rollouts ─────────────────────────────────────────────
|
||
|
||
|
||
def build_rollouts(
|
||
sites: list[SiteInput],
|
||
na_email_early: bool = True,
|
||
ramp_months: int = DEFAULT_RAMP_MONTHS,
|
||
) -> tuple[RolloutPlan, RolloutPlan, RolloutPlan]:
|
||
"""(token, email_token, benefit) rollout plans on the deck's schedule.
|
||
|
||
``RolloutPlan.go_live_month = m`` means active from month m+1; the
|
||
deck's labels are inclusive (NA "realizes Sep 2027" ⇒ September
|
||
counts), so keys are set to label − 1. The benefit plan is keyed by
|
||
region (plus ``NA_EMAIL`` for the NA Gantt exception); the token
|
||
plans are keyed by site.
|
||
"""
|
||
token = RolloutPlan(
|
||
contract_start="2026-01", build_months=max(IMPL_MONTH.values()),
|
||
ramp_months=ramp_months,
|
||
first_year_platform_discount=0.0, # licences are handled verbatim, not by this plan
|
||
go_live_month={s.site_name: IMPL_MONTH[site_region(s.site_name)] - 1
|
||
for s in sites},
|
||
)
|
||
email = dataclasses.replace(
|
||
token,
|
||
go_live_month={**token.go_live_month,
|
||
"NAM": (NA_EMAIL_IMPL_MONTH - 1) if na_email_early
|
||
else IMPL_MONTH["NA"] - 1},
|
||
)
|
||
benefit = RolloutPlan(
|
||
first_year_platform_discount=0.0,
|
||
go_live_month={**{r: REALIZE_MONTH[r] - 1 for r in REGIONS},
|
||
"NA_EMAIL": (NA_EMAIL_IMPL_MONTH + BENEFIT_LAG_MONTHS - 1)
|
||
if na_email_early else REALIZE_MONTH["NA"] - 1},
|
||
)
|
||
return token, email, benefit
|
||
|
||
|
||
def benefits_by_year(
|
||
benefit_rollout: RolloutPlan, na_email_early: bool = True
|
||
) -> pd.DataFrame:
|
||
"""Phase each verbatim 3-yr value by its region's realization window.
|
||
|
||
Scaling is at the finest grain (region × capability), so every
|
||
verbatim per-region, per-capability, and grand total is reproduced
|
||
exactly. Long DataFrame: region, capability, year, benefit.
|
||
"""
|
||
rows = []
|
||
for (region, cap), (_annual, three_yr) in VERBATIM_BENEFITS.items():
|
||
key = ("NA_EMAIL" if (region == "NA" and cap == "Email" and na_email_early)
|
||
else region)
|
||
live = [benefit_rollout.live_months_in_year(key, YEAR_INDEX[y]) for y in YEARS]
|
||
total_live = sum(live)
|
||
for y, m in zip(YEARS, live):
|
||
rows.append({"region": region, "capability": cap, "year": y,
|
||
"benefit": three_yr * m / total_live if total_live else 0.0})
|
||
return pd.DataFrame(rows)
|
||
|
||
|
||
# ── Base cost lines (verbatim + contract mechanics) ──────────────────
|
||
|
||
|
||
def current_months_in_year(termination: dt.date, cal_year: int) -> int:
|
||
"""Months a term contract bills in ``cal_year`` (through its termination month)."""
|
||
if cal_year < termination.year:
|
||
return 12
|
||
if cal_year > termination.year:
|
||
return 0
|
||
return termination.month
|
||
|
||
|
||
def current_state_inputs(
|
||
sites: list[SiteInput],
|
||
total_annual: float | None = None,
|
||
termination: dt.date = DEFAULT_TERMINATION,
|
||
) -> pd.DataFrame:
|
||
"""Per-region current-platform inputs, seeded by agent share of the
|
||
verbatim global spend. Region-indexed; annual_cost and
|
||
contract_termination are the editable columns."""
|
||
total = TCO_VERBATIM["current_annual"] if total_annual is None else total_annual
|
||
agents = region_agents(sites)
|
||
total_agents = sum(agents.values())
|
||
return pd.DataFrame([
|
||
{"region": r,
|
||
"agents": agents[r],
|
||
"share": agents[r] / total_agents,
|
||
"annual_cost": total * agents[r] / total_agents,
|
||
"contract_termination": termination,
|
||
"confidence": "🟡 agent-share allocation of the verbatim total"}
|
||
for r in REGIONS
|
||
]).set_index("region")
|
||
|
||
|
||
def current_costs_by_year(current_state: pd.DataFrame) -> dict[int, float]:
|
||
"""Existing-platform run-off per calendar year (the double-billing line)."""
|
||
return {
|
||
y: float(sum(
|
||
row["annual_cost"]
|
||
* current_months_in_year(row["contract_termination"], y) / 12
|
||
for _, row in current_state.iterrows()))
|
||
for y in YEARS
|
||
}
|
||
|
||
|
||
def licence_months_in_year(year_index: int, ramp_months: int) -> int:
|
||
"""Ramp programme: licence billing starts in calendar month ramp_months + 1."""
|
||
start, end = 12 * (year_index - 1) + 1, 12 * year_index
|
||
return max(0, end - max(start, ramp_months + 1) + 1)
|
||
|
||
|
||
def licence_costs_by_year(
|
||
ramp_months: int = DEFAULT_RAMP_MONTHS, annual: float | None = None
|
||
) -> dict[int, float]:
|
||
rate = TCO_VERBATIM["ccaas_annual"] if annual is None else annual
|
||
return {y: rate * licence_months_in_year(YEAR_INDEX[y], ramp_months) / 12
|
||
for y in YEARS}
|
||
|
||
|
||
def ps_costs_by_year() -> dict[int, float]:
|
||
"""Base professional services + training — verbatim, year 1 only."""
|
||
return {2026: TCO_VERBATIM["prof_services_y1"] + TCO_VERBATIM["training_y1"],
|
||
2027: 0.0, 2028: 0.0}
|
||
|
||
|
||
# ── Token consumption (missing cost #1) ──────────────────────────────
|
||
|
||
|
||
def claim_scenario(email_auto_respond_rate: float = 0.255) -> Scenario:
|
||
"""Claim-level scenario: deck parameters, no consumption maturity ramp."""
|
||
return Scenario(
|
||
name="genesys-claim",
|
||
voice_bot_deflection=0.0, voice_bot_avg_minutes=0.0,
|
||
agentic_va_deflection=0.0,
|
||
voice_summarization_eligibility=0.0,
|
||
voice_knowledge_eligibility=0.0, # unused by the Appendix-4 scope set
|
||
email_auto_respond_rate=email_auto_respond_rate,
|
||
email_auto_suggest_acceptance=0.0, # Auto-Suggest is inside Copilot (V2 #1)
|
||
consumption_cost_realization={1: 1.0, 2: 1.0, 3: 1.0},
|
||
)
|
||
|
||
|
||
def autorespond_meter(tokens_per_msg: float = 0.05) -> TokenMeter:
|
||
"""Email Auto-Respond working meter — rate unpublished (🔴→🟡).
|
||
|
||
Anchor: ≈1 AI action per generated response; Genesys Cloud Copilot
|
||
meters 20 AI actions per token.
|
||
"""
|
||
return dataclasses.replace(
|
||
DEFAULT_METERS["Email AI (Auto-Respond)"],
|
||
units_per_token=1.0 / tokens_per_msg,
|
||
tokens_per_unit=tokens_per_msg,
|
||
confidence=Confidence.ESTIMATED,
|
||
notes="WORKING ASSUMPTION — rate unpublished; ≈1 AI action per generated "
|
||
"response (Genesys Cloud Copilot meters 20 AI actions/token).",
|
||
)
|
||
|
||
|
||
def build_scopes(
|
||
sites: list[SiteInput],
|
||
copilot_includes_asia: bool = False,
|
||
pr_eligibility: float = 1.0,
|
||
ai_translate_eligibility: float = 0.01,
|
||
) -> tuple[list[FeatureScope], list[FeatureScope]]:
|
||
"""(core, email) feature scopes mirroring the six deck capabilities.
|
||
|
||
No ``adoption_curve`` on any scope — a curve would silently override
|
||
the claim scenario's flat consumption realization. Email scopes are
|
||
separate because NA Email implements early (own rollout plan).
|
||
"""
|
||
all_names = [s.site_name for s in sites]
|
||
asia = [n for n in all_names if site_region(n) == "ASIA"]
|
||
non_asia = [n for n in all_names if site_region(n) != "ASIA"]
|
||
copilot_sites = non_asia + (asia if copilot_includes_asia else [])
|
||
core = [
|
||
FeatureScope("Agent Copilot [named]", copilot_sites, phase=1),
|
||
FeatureScope("Speech & Text Analytics [named]", all_names, phase=1),
|
||
FeatureScope("Predictive Routing", all_names, phase=1,
|
||
eligibility_pct=pr_eligibility),
|
||
# $0 by Rule 1 (Copilot covers summarization) — kept visible.
|
||
FeatureScope("AI Summary & Insights", copilot_sites, phase=1),
|
||
# Supervisor Copilot small-volume proxy.
|
||
FeatureScope("AI Translate", asia + ["EMEA"], phase=1,
|
||
eligibility_pct=ai_translate_eligibility),
|
||
]
|
||
email = [FeatureScope("Email AI (Auto-Respond)", all_names, phase=1)]
|
||
return core, email
|
||
|
||
|
||
def token_costs_by_year(
|
||
sites: list[SiteInput],
|
||
meters: dict[str, TokenMeter],
|
||
pricing: dict[str, TokenPricing],
|
||
scenario: Scenario,
|
||
core_scopes: list[FeatureScope],
|
||
email_scopes: list[FeatureScope],
|
||
token_rollout: RolloutPlan,
|
||
email_rollout: RolloutPlan,
|
||
use_contracted: bool = False,
|
||
) -> pd.DataFrame:
|
||
"""Engine-computed token costs, rollout-gated, per calendar year.
|
||
|
||
Long DataFrame: cost_line, scope, annual_cost, confidence, year.
|
||
"""
|
||
frames = []
|
||
for y in YEARS:
|
||
for scopes, rollout in ((core_scopes, token_rollout),
|
||
(email_scopes, email_rollout)):
|
||
part = calculate_total_cost(
|
||
sites, scopes, meters, pricing, scenario, YEAR_INDEX[y],
|
||
include_platform=False, use_contracted=use_contracted,
|
||
rollout=rollout,
|
||
)
|
||
part["year"] = y
|
||
frames.append(part)
|
||
return pd.concat(frames, ignore_index=True)
|
||
|
||
|
||
# ── AI implementation effort (missing cost #2, V2 LoE) ───────────────
|
||
|
||
#: (low, high) Y1 hours — docs/ctm_ai_labour_estimate_V2.md.
|
||
AI_IMPL_HOURS: dict[str, tuple[float, float]] = {
|
||
"Agent Copilot": (1_200, 1_800), # voice + digital incl. email Auto-Suggest
|
||
"Email Auto-Respond": (800, 1_400), # separate flow; needs SoR integration
|
||
"STA": (800, 1_200), # topics, programs, tuning × 7 languages
|
||
"Supervisor Copilot": (200, 400),
|
||
"Predictive Routing": (400, 700),
|
||
"Cross-cutting (PM, governance, testing, integration)": (1_000, 1_800),
|
||
}
|
||
KB_READINESS_HOURS = (500, 1_500) # prerequisite project — flagged separately
|
||
STEADY_STATE_HOURS = (500, 900) # absolute h/yr, 2027-2028
|
||
DEFAULT_BLENDED_RATE = 225.0
|
||
SMELL_TEST_FLOOR = 0.15 # impl ≥ 15% of benefit claim, or flag
|
||
|
||
|
||
def impl_feature_regions(copilot_includes_asia: bool = False) -> dict[str, list[str]]:
|
||
"""Which regions each implementation workstream serves."""
|
||
return {
|
||
"Agent Copilot": (["NA", "ANZ", "EMEA"]
|
||
+ (["ASIA"] if copilot_includes_asia else [])),
|
||
"Email Auto-Respond": list(REGIONS),
|
||
"STA": list(REGIONS),
|
||
"Supervisor Copilot": ["NA", "ANZ", "EMEA"], # deck: $0 SupCopilot in ASIA
|
||
"Predictive Routing": list(REGIONS),
|
||
"Cross-cutting (PM, governance, testing, integration)": list(REGIONS),
|
||
}
|
||
|
||
|
||
def hours_pick(rng: tuple[float, float], mode: str) -> float:
|
||
low, high = rng
|
||
return {"low": low, "mid": (low + high) / 2, "high": high}[mode]
|
||
|
||
|
||
def impl_year_fractions(impl_month: int) -> list[float]:
|
||
"""Spend spreads uniformly from contract start (month 0) to the impl month."""
|
||
prev, fracs = 0, []
|
||
for yi in (1, 2, 3):
|
||
cur = min(12 * yi, impl_month)
|
||
fracs.append((cur - prev) / impl_month)
|
||
prev = cur
|
||
return fracs
|
||
|
||
|
||
def region_impl_month(feature: str, region: str, na_email_early: bool = True) -> int:
|
||
if feature == "Email Auto-Respond" and region == "NA" and na_email_early:
|
||
return NA_EMAIL_IMPL_MONTH
|
||
return IMPL_MONTH[region]
|
||
|
||
|
||
def build_impl_costs(
|
||
sites: list[SiteInput],
|
||
mode: str = "mid",
|
||
rate: float = DEFAULT_BLENDED_RATE,
|
||
include_kb: bool = True,
|
||
copilot_includes_asia: bool = False,
|
||
na_email_early: bool = True,
|
||
) -> tuple[pd.DataFrame, dict[int, float], dict[int, float], dict[int, float]]:
|
||
"""V2 hours-range × rate model (swap point for the future LoE engine).
|
||
|
||
Returns (detail_df, impl_by_year, kb_by_year, steady_by_year).
|
||
Hours allocate to each workstream's scoped regions by agent share;
|
||
steady-state is booked program-level in 2027-2028.
|
||
"""
|
||
agents = region_agents(sites)
|
||
feature_regions = impl_feature_regions(copilot_includes_asia)
|
||
workstreams = dict(AI_IMPL_HOURS)
|
||
if include_kb:
|
||
workstreams["KB readiness (prerequisite)"] = KB_READINESS_HOURS
|
||
rows = []
|
||
for feature, rng in workstreams.items():
|
||
regions = feature_regions.get(feature, list(REGIONS))
|
||
scope_agents = sum(agents[r] for r in regions)
|
||
for r in regions:
|
||
hours = hours_pick(rng, mode) * agents[r] / scope_agents
|
||
fracs = impl_year_fractions(
|
||
region_impl_month(feature, r, na_email_early))
|
||
rows.append({"workstream": feature, "region": r, "hours": hours,
|
||
"cost": hours * rate,
|
||
**{y: hours * rate * f for y, f in zip(YEARS, fracs)}})
|
||
df = pd.DataFrame(rows)
|
||
is_kb = df["workstream"].str.startswith("KB")
|
||
impl_y = {y: float(df.loc[~is_kb, y].sum()) for y in YEARS}
|
||
kb_y = {y: float(df.loc[is_kb, y].sum()) for y in YEARS}
|
||
steady = hours_pick(STEADY_STATE_HOURS, mode) * rate
|
||
steady_y = {2026: 0.0, 2027: steady, 2028: steady}
|
||
return df, impl_y, kb_y, steady_y
|
||
|
||
|
||
# ── Business case (baseline-relative frame) ──────────────────────────
|
||
|
||
|
||
def case_flows(
|
||
total_cost_by_year: dict[int, float],
|
||
benefit_total_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.
|
||
|
||
One frame captures both the 2026-27 double-billing penalty and the
|
||
post-termination cost-avoidance credit.
|
||
"""
|
||
base = TCO_VERBATIM["current_annual"] if baseline_annual is None else baseline_annual
|
||
inc = {y: total_cost_by_year[y] - base for y in YEARS}
|
||
net = {y: benefit_total_by_year[y] - inc[y] for y in YEARS}
|
||
return inc, net
|
||
|
||
|
||
def payback_label(net_by_year: dict[int, float]) -> str:
|
||
pb = payback_years([net_by_year[y] for y in YEARS])
|
||
if pb is None:
|
||
return f"beyond {YEARS[-1]}"
|
||
if pb == 0:
|
||
return "immediate"
|
||
m = math.ceil(pb * 12)
|
||
return f"{m} months (~{month_label(m)})"
|
||
|
||
|
||
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 = TCO_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("$", "$")
|