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 shared by the notebook
|
||
(``notebooks/ctm_business_case_corrected.ipynb``) and the Streamlit
|
||
"Corrected Business Case" view — presentation layers hold 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": (1_000, 1_800), # governance, PM, test env, integration
|
||
}
|
||
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": 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("$", "$")
|