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,117 @@
"""Business case maths + exports."""
from __future__ import annotations
import pytest
from tokencalc.business_case import build_business_case, npv, payback_years
from tokencalc.defaults import (
CTM_DEFAULT_FEATURE_SCOPES,
CTM_DEFAULT_SITES,
CTM_DEFAULT_TAKEOUTS,
DEFAULT_METERS,
DEFAULT_PRICING,
)
from tokencalc.exports import (
export_excel,
scenario_state_from_json,
scenario_state_to_json,
)
def test_npv_hand_check():
"""100/yr for 3 years @ 8%: 92.593 + 85.734 + 79.383 = 257.710."""
assert npv([100, 100, 100], 0.08) == pytest.approx(257.710, abs=0.001)
def test_payback_interpolation():
# -100 in Y1, +200 in Y2 → breakeven halfway through Y2 = 1.5 years
assert payback_years([-100, 200, 0]) == pytest.approx(1.5)
assert payback_years([-100, -100, -100]) is None
assert payback_years([50, 50, 50]) == pytest.approx(0.0)
def _case(scenario="realistic", **kw):
return build_business_case(
CTM_DEFAULT_SITES, CTM_DEFAULT_FEATURE_SCOPES, DEFAULT_METERS,
DEFAULT_PRICING, CTM_DEFAULT_TAKEOUTS, scenario, **kw,
)
def test_business_case_shape():
case = _case()
assert set(case) == {
"cost_by_year", "benefit_by_year", "takeouts_by_year",
"net_by_year", "cumulative_net", "npv",
"payback_period_years", "roi_3yr",
}
for key in ("cost_by_year", "benefit_by_year", "net_by_year"):
assert {"Y1", "Y2", "Y3"} <= set(case[key].columns)
def test_net_consistency():
"""NET row must equal benefits + takeouts costs, per year."""
case = _case()
nb = case["net_by_year"].set_index("line")
for y in ("Y1", "Y2", "Y3"):
assert nb.loc["NET", y] == pytest.approx(
nb.loc["TOTAL BENEFITS", y]
+ nb.loc["TOTAL TAKEOUTS", y]
- nb.loc["TOTAL COSTS", y]
)
# cumulative is a running sum of NET
assert nb.loc["Cumulative net", "Y3"] == pytest.approx(
sum(nb.loc["NET", y] for y in ("Y1", "Y2", "Y3"))
)
def test_npv_matches_net_rows():
case = _case()
nb = case["net_by_year"].set_index("line")
net = [nb.loc["NET", y] for y in ("Y1", "Y2", "Y3")]
assert case["npv"] == pytest.approx(npv(net, 0.08))
def test_three_scenarios_distinct():
npvs = {s: _case(s)["npv"] for s in ("floor", "realistic", "stretch")}
assert len({round(v) for v in npvs.values()}) == 3
assert npvs["floor"] < npvs["realistic"] < npvs["stretch"]
def test_implementation_amortization():
base = _case()
with_impl = _case(implementation_cost=900_000)
nb, nb2 = (
c["net_by_year"].set_index("line") for c in (base, with_impl)
)
for y in ("Y1", "Y2", "Y3"):
assert nb2.loc["TOTAL COSTS", y] == pytest.approx(
nb.loc["TOTAL COSTS", y] + 300_000
)
def test_excel_export_readable(tmp_path):
case = _case()
path = export_excel(
{
"Business Case": case["net_by_year"],
"Costs": case["cost_by_year"],
"Benefits": case["benefit_by_year"],
},
tmp_path / "ctm.xlsx",
)
import openpyxl
wb = openpyxl.load_workbook(path)
assert set(wb.sheetnames) == {"Business Case", "Costs", "Benefits"}
def test_scenario_json_roundtrip(tmp_path):
p = tmp_path / "state.json"
scenario_state_to_json(
CTM_DEFAULT_SITES, CTM_DEFAULT_TAKEOUTS, CTM_DEFAULT_FEATURE_SCOPES, p
)
sites, takeouts, scopes = scenario_state_from_json(p)
assert [s.site_name for s in sites] == [s.site_name for s in CTM_DEFAULT_SITES]
assert takeouts[0].annual_cost == CTM_DEFAULT_TAKEOUTS[0].annual_cost
assert scopes[0].adoption_curve == CTM_DEFAULT_FEATURE_SCOPES[0].adoption_curve