docs: introduce Mercury Notebook Deliverable Pattern
This commit is contained in:
577
studies/202607_CTM_GenesysCX/tokencalc/appendix4.py
Normal file
577
studies/202607_CTM_GenesysCX/tokencalc/appendix4.py
Normal file
@@ -0,0 +1,577 @@
|
||||
"""
|
||||
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
|
||||
}
|
||||
|
||||
#: Actual contracted values where they differ from the deck — 🟢
|
||||
#: contractual. Layered over TCO_VERBATIM, which stays the untouched
|
||||
#: record of what Genesys pitched (the anchor CTM can follow);
|
||||
#: presentation reads through :func:`tco`.
|
||||
TCO_CONTRACTED: dict[str, float] = {
|
||||
"ccaas_annual": 3_200_000, # signed licence run-rate (deck pitched $4.3M/yr)
|
||||
}
|
||||
|
||||
#: NTT professional services — SOW billing milestones (🟢 contractual).
|
||||
#: The deck's verbatim anchor books $2.4M PS in year 1; the signed SOW
|
||||
#: bills $2,025,446.48 in four milestones split 50/50 across 2026-27.
|
||||
PS_MILESTONES: list[dict] = [
|
||||
{"name": "SOW Effective Date", "date": dt.date(2026, 3, 15),
|
||||
"share": 0.30, "amount": 607_633.94},
|
||||
{"name": "Start of client UAT (first region)", "date": dt.date(2026, 9, 30),
|
||||
"share": 0.20, "amount": 405_089.30},
|
||||
{"name": "Start of client UAT (last region)", "date": dt.date(2027, 6, 30),
|
||||
"share": 0.30, "amount": 607_633.94},
|
||||
{"name": "Completion of last go-live migration", "date": dt.date(2027, 9, 30),
|
||||
"share": 0.20, "amount": 405_089.30},
|
||||
]
|
||||
PS_CONTRACTED_TOTAL = sum(m["amount"] for m in PS_MILESTONES)
|
||||
|
||||
#: NTT managed services — commences billing at MCX go-live (🟢 contractual).
|
||||
#: Not in the deck's TCO at all; an ongoing run-rate cost thereafter.
|
||||
MANAGED_SERVICES_ANNUAL = 410_918.40
|
||||
MCX_GO_LIVE = dt.date(2026, 9, 30)
|
||||
|
||||
|
||||
def tco(key: str) -> float:
|
||||
"""Contracted value where one exists, else the deck's verbatim anchor."""
|
||||
return TCO_CONTRACTED.get(key, TCO_VERBATIM[key])
|
||||
|
||||
#: 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 = 6 # Genesys ramp programme (🟢 order form)
|
||||
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(contracted: bool = False) -> dict[int, float]:
|
||||
"""Base professional services + training.
|
||||
|
||||
Verbatim: the deck's $2.4M PS lump plus training, all in year 1.
|
||||
Contracted: PS phased on the SOW billing milestones (50/50 across
|
||||
2026-27, $2.03M total); training stays the verbatim year-1 line —
|
||||
the SOW milestones don't itemize it separately.
|
||||
"""
|
||||
training = TCO_VERBATIM["training_y1"]
|
||||
if contracted:
|
||||
ps = {y: 0.0 for y in YEARS}
|
||||
for m in PS_MILESTONES:
|
||||
ps[m["date"].year] += m["amount"]
|
||||
return {y: ps[y] + (training if y == 2026 else 0.0) for y in YEARS}
|
||||
return {2026: TCO_VERBATIM["prof_services_y1"] + training,
|
||||
2027: 0.0, 2028: 0.0}
|
||||
|
||||
|
||||
def ps_milestones_dataframe() -> pd.DataFrame:
|
||||
"""The SOW billing milestones as a display table."""
|
||||
return pd.DataFrame(PS_MILESTONES)
|
||||
|
||||
|
||||
def managed_services_by_year(
|
||||
annual: float = MANAGED_SERVICES_ANNUAL, start: dt.date = MCX_GO_LIVE
|
||||
) -> dict[int, float]:
|
||||
"""Managed services bill from the month after go-live (Sep 30 → Oct),
|
||||
then run at the full annual rate — an ongoing cost with no end date
|
||||
inside the model window."""
|
||||
def _months(y: int) -> int:
|
||||
if y < start.year:
|
||||
return 0
|
||||
return 12 if y > start.year else 12 - start.month
|
||||
return {y: annual * _months(y) / 12 for y in YEARS}
|
||||
|
||||
|
||||
# ── 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,
|
||||
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("$", "$")
|
||||
Reference in New Issue
Block a user