- 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
189 lines
6.8 KiB
Python
189 lines
6.8 KiB
Python
"""
|
|
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
|
|
),
|
|
}
|