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:
@@ -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
|
||||
),
|
||||
}
|
||||
Reference in New Issue
Block a user