- 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
118 lines
3.6 KiB
Python
118 lines
3.6 KiB
Python
"""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
|