feat: add GenesysCX study and fix Streamlit chart key collisions

- Add 202512_GenesysCX TEI study (config, seed data, notebooks, README)
  with NPV $10.8M / ROI 266% including AI-token cost line
- Add explicit `key` parameter to all chart wrappers in app/components
  to prevent StreamlitDuplicateElementId errors when the same figure
  type renders across Summary/Benefits/Costs tabs
- Render benefits bar and cost pie charts on their respective tabs
- Add benefits_vs_costs_by_year chart wrapper
This commit is contained in:
2026-06-10 14:26:49 -04:00
parent ecd164ee6d
commit 64fb83257d
34 changed files with 12902 additions and 39 deletions

View File

@@ -0,0 +1,188 @@
"""
Business case — combines costs, benefits, and cost takeouts into a
3-year net view with NPV, payback, and ROI.
Convention: all cashflows are year-end and discounted at
``discount_rate`` (default 8%); there is no undiscounted year-0 column
— implementation is amortized straight-line across the analysis years
(spec §5.6 "Implementation amort." line).
"""
from __future__ import annotations
import pandas as pd
from .benefit_model import calculate_total_benefit
from .cost_model import calculate_total_cost
from .defaults import (
DEFAULT_DISCOUNT_RATE,
DEFAULT_IMPLEMENTATION_COST,
PLATFORM_RATE_PER_USER_MONTHLY,
)
from .inputs import CostTakeout, FeatureScope, SiteInput
from .meters import Confidence, TokenMeter, TokenPricing
from .rollout import RolloutPlan
from .scenarios import Scenario, get_scenario
def npv(cashflows_by_year: list[float], discount_rate: float) -> float:
"""Year-end-discounted NPV of year-1..N cashflows."""
return sum(
cf / (1 + discount_rate) ** year
for year, cf in enumerate(cashflows_by_year, start=1)
)
def payback_years(cashflows_by_year: list[float]) -> float | None:
"""First (fractional) year cumulative net turns >= 0; None if never.
Cashflows are assumed evenly spread within each year.
"""
cumulative = 0.0
for year, cf in enumerate(cashflows_by_year, start=1):
if cumulative + cf >= 0 and cf != 0:
if cumulative >= 0:
return float(year - 1)
return (year - 1) + (-cumulative / cf)
cumulative += cf
return None
def build_business_case(
sites: list[SiteInput],
feature_scopes: list[FeatureScope],
meters: dict[str, TokenMeter],
pricing: dict[str, TokenPricing],
takeouts: list[CostTakeout],
scenario: str | Scenario,
years: int = 3,
discount_rate: float = DEFAULT_DISCOUNT_RATE,
platform_rate: float = PLATFORM_RATE_PER_USER_MONTHLY,
implementation_cost: float = DEFAULT_IMPLEMENTATION_COST,
use_contracted: bool = False,
benefit_params: str = "realistic",
rollout: RolloutPlan | None = None,
) -> dict:
"""Returns the dict described in spec §4.3 (DataFrames + headline
metrics). Every number traces to a cost line, benefit line, or
takeout row in the per-year detail frames.
"""
sc = get_scenario(scenario) if isinstance(scenario, str) else scenario
year_cols = [f"Y{y}" for y in range(1, years + 1)]
cost_frames, benefit_frames = {}, {}
for y in range(1, years + 1):
cost_frames[y] = calculate_total_cost(
sites, feature_scopes, meters, pricing, sc, y,
platform_rate=platform_rate, use_contracted=use_contracted,
rollout=rollout,
)
benefit_frames[y] = calculate_total_benefit(
sites, feature_scopes, sc, y, params=benefit_params,
rollout=rollout,
)
# ── cost_by_year: one row per cost line, one column per year ────
cost_lines = list(cost_frames[1]["cost_line"])
cost_by_year = pd.DataFrame({"line": cost_lines})
for y in range(1, years + 1):
cost_by_year[f"Y{y}"] = list(cost_frames[y]["annual_cost"])
cost_by_year["confidence"] = list(cost_frames[1]["confidence"])
if implementation_cost:
amort = implementation_cost / years
cost_by_year = pd.concat(
[
cost_by_year,
pd.DataFrame(
[
{
"line": "Implementation (amortized)",
**{c: amort for c in year_cols},
"confidence": Confidence.ESTIMATED.value,
}
]
),
],
ignore_index=True,
)
# ── benefit_by_year ──────────────────────────────────────────────
benefit_lines: list[str] = []
for y in range(1, years + 1):
for line in benefit_frames[y]["benefit_line"]:
if line not in benefit_lines:
benefit_lines.append(line)
benefit_by_year = pd.DataFrame({"line": benefit_lines})
for y in range(1, years + 1):
lookup = dict(
zip(benefit_frames[y]["benefit_line"], benefit_frames[y]["annual_value"])
)
benefit_by_year[f"Y{y}"] = [lookup.get(line, 0.0) for line in benefit_lines]
conf_lookup: dict[str, str] = {}
for y in range(1, years + 1):
conf_lookup.update(
dict(zip(benefit_frames[y]["benefit_line"], benefit_frames[y]["confidence"]))
)
benefit_by_year["confidence"] = [
conf_lookup.get(line, Confidence.ESTIMATED.value) for line in benefit_lines
]
# ── takeouts_by_year ─────────────────────────────────────────────
takeouts_by_year = pd.DataFrame(
[
{
"line": t.name,
**{f"Y{y}": t.value_in_year(y) for y in range(1, years + 1)},
"confidence": t.confidence.value,
}
for t in takeouts
]
)
# ── net + cumulative ─────────────────────────────────────────────
total_costs = [float(cost_by_year[c].sum()) for c in year_cols]
total_benefits = [float(benefit_by_year[c].sum()) for c in year_cols]
total_takeouts = [
float(takeouts_by_year[c].sum()) if not takeouts_by_year.empty else 0.0
for c in year_cols
]
net = [
b + t - c for b, t, c in zip(total_benefits, total_takeouts, total_costs)
]
cumulative = pd.Series(net).cumsum().tolist()
net_by_year = pd.DataFrame(
{
"line": [
"TOTAL COSTS", "TOTAL TAKEOUTS", "TOTAL BENEFITS",
"NET", "Cumulative net",
],
**{
f"Y{y}": [
total_costs[y - 1], total_takeouts[y - 1],
total_benefits[y - 1], net[y - 1], cumulative[y - 1],
]
for y in range(1, years + 1)
},
}
)
cumulative_net = pd.DataFrame(
{"year": list(range(1, years + 1)), "cumulative_net": cumulative}
)
total_cost_sum = sum(total_costs)
total_value_sum = sum(total_benefits) + sum(total_takeouts)
return {
"cost_by_year": cost_by_year,
"benefit_by_year": benefit_by_year,
"takeouts_by_year": takeouts_by_year,
"net_by_year": net_by_year,
"cumulative_net": cumulative_net,
"npv": npv(net, discount_rate),
"payback_period_years": payback_years(net),
"roi_3yr": (
(total_value_sum - total_cost_sum) / total_cost_sum
if total_cost_sum
else None
),
}