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,576 @@
|
||||
"""
|
||||
NTT DATA — CTM Token Calculator (Streamlit).
|
||||
|
||||
Run from the ctm-token-calculator root::
|
||||
|
||||
streamlit run app/streamlit_app.py
|
||||
|
||||
Thin presentation layer over ``tokencalc`` — all math lives in the
|
||||
library, shared with the JupyterLab notebook.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Import tokencalc from the project root without install
|
||||
_ROOT = Path(__file__).resolve().parent.parent
|
||||
if str(_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_ROOT))
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import plotly.express as px
|
||||
import plotly.graph_objects as go
|
||||
import streamlit as st
|
||||
|
||||
import tokencalc.scenarios as tc_scenarios
|
||||
from tokencalc import (
|
||||
CONTRACTED_NAMED_USERS,
|
||||
CTM_DEFAULT_FEATURE_SCOPES,
|
||||
CTM_DEFAULT_SITES,
|
||||
CTM_DEFAULT_TAKEOUTS,
|
||||
DEFAULT_METERS,
|
||||
DEFAULT_PRICING,
|
||||
Confidence,
|
||||
CostTakeout,
|
||||
FeatureScope,
|
||||
SiteInput,
|
||||
build_business_case,
|
||||
calculate_total_benefit,
|
||||
calculate_total_cost,
|
||||
export_excel,
|
||||
get_scenario,
|
||||
meters_dataframe,
|
||||
scenario_state_from_json,
|
||||
scenario_state_to_json,
|
||||
sites_dataframe,
|
||||
)
|
||||
|
||||
st.set_page_config(page_title="NTT DATA — CTM Token Calculator",
|
||||
page_icon="🧮", layout="wide")
|
||||
|
||||
YEARS = (1, 2, 3)
|
||||
FEATURES = list(DEFAULT_METERS)
|
||||
_DEFAULT_REALISTIC = {
|
||||
k: v["realistic"] for k, v in tc_scenarios.BENEFIT_PARAMS.items()
|
||||
}
|
||||
|
||||
|
||||
# ── State ────────────────────────────────────────────────────────────
|
||||
|
||||
def _init_state(force: bool = False) -> None:
|
||||
if force or "sites" not in st.session_state:
|
||||
st.session_state.sites = list(CTM_DEFAULT_SITES)
|
||||
st.session_state.takeouts = list(CTM_DEFAULT_TAKEOUTS)
|
||||
st.session_state.scopes = [
|
||||
dataclasses.replace(s) for s in CTM_DEFAULT_FEATURE_SCOPES
|
||||
]
|
||||
st.session_state.meters = dict(DEFAULT_METERS)
|
||||
st.session_state.pricing = dict(DEFAULT_PRICING)
|
||||
st.session_state.use_contracted = False
|
||||
st.session_state.implementation_cost = 0.0
|
||||
for k, v in _DEFAULT_REALISTIC.items(): # reset benefit sliders
|
||||
tc_scenarios.BENEFIT_PARAMS[k]["realistic"] = v
|
||||
|
||||
|
||||
_init_state()
|
||||
|
||||
|
||||
def _state_key() -> str:
|
||||
"""Stable serialization of inputs for st.cache_data keys."""
|
||||
return scenario_state_to_json(
|
||||
st.session_state.sites, st.session_state.takeouts, st.session_state.scopes
|
||||
) + json.dumps(
|
||||
{
|
||||
"params": {k: v["realistic"] for k, v in tc_scenarios.BENEFIT_PARAMS.items()},
|
||||
"contracted": st.session_state.use_contracted,
|
||||
"impl": st.session_state.implementation_cost,
|
||||
"meters": {f: m.tokens_per_unit for f, m in st.session_state.meters.items()},
|
||||
"pricing": {
|
||||
r: (p.list_rate_per_token, p.contracted_rate_per_token)
|
||||
for r, p in st.session_state.pricing.items()
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@st.cache_data(show_spinner=False)
|
||||
def _cached_case(state_key: str, scenario: str) -> dict:
|
||||
return build_business_case(
|
||||
st.session_state.sites, st.session_state.scopes,
|
||||
st.session_state.meters, st.session_state.pricing,
|
||||
st.session_state.takeouts, scenario,
|
||||
implementation_cost=st.session_state.implementation_cost,
|
||||
use_contracted=st.session_state.use_contracted,
|
||||
)
|
||||
|
||||
|
||||
def _case(scenario: str) -> dict:
|
||||
return _cached_case(_state_key(), scenario)
|
||||
|
||||
|
||||
# ── Sidebar ──────────────────────────────────────────────────────────
|
||||
|
||||
st.sidebar.title("NTT DATA — CTM Token Calculator")
|
||||
page = st.sidebar.radio("Page", [
|
||||
"1. Inputs", "2. Token Meters", "3. Cost Model", "4. Benefit Model",
|
||||
"5. Business Case", "6. Sensitivity Analysis", "7. Export",
|
||||
])
|
||||
st.sidebar.divider()
|
||||
scenario_name = st.sidebar.radio(
|
||||
"Scenario", ["floor", "realistic", "stretch"], index=1, horizontal=True
|
||||
)
|
||||
year = st.sidebar.radio("Year", YEARS, horizontal=True)
|
||||
if st.sidebar.button("Reset to CTM defaults"):
|
||||
_init_state(force=True)
|
||||
st.cache_data.clear()
|
||||
st.rerun()
|
||||
st.sidebar.caption(
|
||||
"⚠️ Planning tool — published list rates unless overridden; "
|
||||
"not contractual pricing."
|
||||
)
|
||||
|
||||
sites: list[SiteInput] = st.session_state.sites
|
||||
scopes: list[FeatureScope] = st.session_state.scopes
|
||||
meters = st.session_state.meters
|
||||
pricing = st.session_state.pricing
|
||||
scenario = get_scenario(scenario_name)
|
||||
|
||||
|
||||
def _users_warning() -> None:
|
||||
total = sum(s.named_users for s in sites)
|
||||
if total != CONTRACTED_NAMED_USERS:
|
||||
st.warning(
|
||||
f"Named users across sites = {total:,} ≠ contracted licence "
|
||||
f"count {CONTRACTED_NAMED_USERS:,}."
|
||||
)
|
||||
|
||||
|
||||
# ── Page 1: Inputs ───────────────────────────────────────────────────
|
||||
|
||||
if page == "1. Inputs":
|
||||
st.header("Inputs")
|
||||
st.caption("Site data outside NAM is **estimated — confirm with CTM data**.")
|
||||
_users_warning()
|
||||
|
||||
df = sites_dataframe(sites)
|
||||
edited = st.data_editor(df, num_rows="dynamic", key="sites_editor")
|
||||
if st.button("Apply site changes"):
|
||||
try:
|
||||
st.session_state.sites = [
|
||||
SiteInput(
|
||||
**{
|
||||
**row,
|
||||
"languages": [
|
||||
x.strip() for x in str(row["languages"]).split(",") if x.strip()
|
||||
],
|
||||
}
|
||||
)
|
||||
for row in edited.to_dict("records")
|
||||
]
|
||||
st.cache_data.clear()
|
||||
st.success("Sites updated.")
|
||||
st.rerun()
|
||||
except (ValueError, TypeError) as e:
|
||||
st.error(f"Validation failed: {e}")
|
||||
|
||||
st.subheader("Cost takeouts")
|
||||
tdf = pd.DataFrame(
|
||||
[
|
||||
{"name": t.name, "annual_cost": t.annual_cost,
|
||||
"start_year": t.start_year, "confidence": t.confidence.value,
|
||||
"notes": t.notes}
|
||||
for t in st.session_state.takeouts
|
||||
]
|
||||
)
|
||||
tedit = st.data_editor(
|
||||
tdf, num_rows="dynamic", key="takeouts_editor",
|
||||
column_config={
|
||||
"confidence": st.column_config.SelectboxColumn(
|
||||
options=[c.value for c in Confidence]
|
||||
)
|
||||
},
|
||||
)
|
||||
if st.button("Apply takeout changes"):
|
||||
try:
|
||||
st.session_state.takeouts = [
|
||||
CostTakeout(
|
||||
name=r["name"], annual_cost=float(r["annual_cost"] or 0),
|
||||
start_year=int(r["start_year"] or 1),
|
||||
confidence=Confidence(r["confidence"]), notes=r["notes"] or "",
|
||||
)
|
||||
for r in tedit.to_dict("records")
|
||||
]
|
||||
st.cache_data.clear()
|
||||
st.success("Takeouts updated.")
|
||||
st.rerun()
|
||||
except (ValueError, TypeError) as e:
|
||||
st.error(f"Validation failed: {e}")
|
||||
|
||||
st.subheader("Save / load scenario")
|
||||
col1, col2 = st.columns(2)
|
||||
with col1:
|
||||
st.download_button(
|
||||
"Download scenario JSON",
|
||||
scenario_state_to_json(sites, st.session_state.takeouts, scopes),
|
||||
file_name="ctm_scenario.json", mime="application/json",
|
||||
)
|
||||
with col2:
|
||||
up = st.file_uploader("Load scenario JSON", type="json")
|
||||
if up is not None and st.button("Load"):
|
||||
s, t, sc = scenario_state_from_json(up.read().decode())
|
||||
st.session_state.sites, st.session_state.takeouts = s, t
|
||||
st.session_state.scopes = sc
|
||||
st.cache_data.clear()
|
||||
st.success("Scenario loaded.")
|
||||
st.rerun()
|
||||
|
||||
# ── Page 2: Token Meters ─────────────────────────────────────────────
|
||||
|
||||
elif page == "2. Token Meters":
|
||||
st.header("Token Meters")
|
||||
st.dataframe(meters_dataframe(meters), width="stretch", hide_index=True)
|
||||
|
||||
st.subheader("Override a meter rate")
|
||||
feature = st.selectbox("Feature", FEATURES)
|
||||
m = meters[feature]
|
||||
override = st.toggle("Override default", key=f"ovr_{feature}")
|
||||
if override:
|
||||
new_rate = st.number_input(
|
||||
"tokens per unit (per user/month for per-user meters)",
|
||||
value=float(m.tokens_per_unit), min_value=0.0, step=0.005,
|
||||
format="%.4f",
|
||||
)
|
||||
if st.button("Apply override"):
|
||||
meters[feature] = dataclasses.replace(
|
||||
m,
|
||||
tokens_per_unit=new_rate,
|
||||
units_per_token=(1 / new_rate if new_rate and m.units_per_token else 0.0),
|
||||
confidence=Confidence.ESTIMATED,
|
||||
notes=m.notes + " [rate overridden by user]",
|
||||
)
|
||||
st.cache_data.clear()
|
||||
st.success(f"{feature} now {new_rate} tokens/unit (flagged estimated).")
|
||||
|
||||
st.subheader("Token pricing per region")
|
||||
st.session_state.use_contracted = st.toggle(
|
||||
"Apply contracted rate (if known) instead of list rate",
|
||||
value=st.session_state.use_contracted,
|
||||
)
|
||||
for region, p in pricing.items():
|
||||
c1, c2 = st.columns(2)
|
||||
with c1:
|
||||
lr = st.number_input(
|
||||
f"{region} — list $/token", value=float(p.list_rate_per_token),
|
||||
min_value=0.0, key=f"list_{region}",
|
||||
)
|
||||
with c2:
|
||||
cr = st.number_input(
|
||||
f"{region} — contracted $/token (0 = unknown)",
|
||||
value=float(p.contracted_rate_per_token or 0.0),
|
||||
min_value=0.0, key=f"con_{region}",
|
||||
)
|
||||
pricing[region] = dataclasses.replace(
|
||||
p, list_rate_per_token=lr,
|
||||
contracted_rate_per_token=cr or None,
|
||||
)
|
||||
|
||||
# ── Page 3: Cost Model ───────────────────────────────────────────────
|
||||
|
||||
elif page == "3. Cost Model":
|
||||
st.header("Cost Model")
|
||||
_users_warning()
|
||||
|
||||
st.subheader("Feature enablement & phasing")
|
||||
st.caption("Phase = model year the feature switches on at that site; 0 = off.")
|
||||
site_names = [s.site_name for s in sites]
|
||||
matrix = pd.DataFrame(0, index=site_names, columns=FEATURES, dtype=int)
|
||||
for sc in scopes:
|
||||
for sn in sc.enabled_sites:
|
||||
if sn in matrix.index:
|
||||
matrix.loc[sn, sc.feature] = sc.phase
|
||||
edited_matrix = st.data_editor(matrix, key="phasing_matrix")
|
||||
if st.button("Apply phasing"):
|
||||
new_scopes: list[FeatureScope] = []
|
||||
for feature in FEATURES:
|
||||
for phase in (1, 2, 3):
|
||||
enabled = [sn for sn in site_names
|
||||
if int(edited_matrix.loc[sn, feature]) == phase]
|
||||
if enabled:
|
||||
template = next(
|
||||
(s for s in scopes if s.feature == feature), None
|
||||
)
|
||||
new_scopes.append(
|
||||
FeatureScope(
|
||||
feature, enabled, phase=phase,
|
||||
adoption_curve=(
|
||||
template.adoption_curve if template else {}
|
||||
),
|
||||
deflection_target=(
|
||||
template.deflection_target if template else None
|
||||
),
|
||||
eligibility_pct=(
|
||||
template.eligibility_pct if template else None
|
||||
),
|
||||
)
|
||||
)
|
||||
st.session_state.scopes = new_scopes
|
||||
st.cache_data.clear()
|
||||
st.success("Phasing updated.")
|
||||
st.rerun()
|
||||
|
||||
frames = []
|
||||
for y in YEARS:
|
||||
d = calculate_total_cost(
|
||||
sites, scopes, meters, pricing, scenario, y,
|
||||
use_contracted=st.session_state.use_contracted,
|
||||
)
|
||||
d["year"] = f"Y{y}"
|
||||
frames.append(d)
|
||||
cost_3y = pd.concat(frames, ignore_index=True)
|
||||
|
||||
this_year = frames[year - 1]
|
||||
total = this_year["annual_cost"].sum()
|
||||
unknown = this_year[this_year["confidence"] == "unknown"]["annual_cost"].sum()
|
||||
c1, c2 = st.columns(2)
|
||||
c1.metric(f"Year {year} total cost ({scenario_name})", f"${total:,.0f}")
|
||||
c2.metric("of which 🔴 unknown-rate features", f"${unknown:,.0f}",
|
||||
help="Range driven by unsourced meter rates — total could move "
|
||||
"materially once these are confirmed.")
|
||||
|
||||
st.plotly_chart(
|
||||
px.bar(cost_3y, x="year", y="annual_cost", color="cost_line",
|
||||
title=f"Cost breakdown by feature — {scenario_name}",
|
||||
labels={"annual_cost": "$/yr"}),
|
||||
width="stretch", key="cost_stack",
|
||||
)
|
||||
icon_map = {c.value: c.icon for c in Confidence}
|
||||
show = this_year.copy()
|
||||
show["confidence"] = show["confidence"].map(
|
||||
lambda v: f"{icon_map.get(v, '')} {v}"
|
||||
)
|
||||
st.dataframe(show.sort_values("annual_cost", ascending=False),
|
||||
width="stretch", hide_index=True)
|
||||
|
||||
# ── Page 4: Benefit Model ────────────────────────────────────────────
|
||||
|
||||
elif page == "4. Benefit Model":
|
||||
st.header("Benefit Model")
|
||||
st.caption("Sliders adjust the pressure-tested (realistic) parameters; "
|
||||
"the Genesys-claim figures stay fixed for comparison.")
|
||||
|
||||
cols = st.columns(3)
|
||||
for i, (key, vals) in enumerate(tc_scenarios.BENEFIT_PARAMS.items()):
|
||||
with cols[i % 3]:
|
||||
tc_scenarios.BENEFIT_PARAMS[key]["realistic"] = st.slider(
|
||||
key.replace("_", " "),
|
||||
0.0, max(1.0, vals["claim"]),
|
||||
value=float(vals["realistic"]), step=0.005, format="%.3f",
|
||||
key=f"bp_{key}",
|
||||
)
|
||||
|
||||
frames = []
|
||||
for y in YEARS:
|
||||
d = calculate_total_benefit(sites, scopes, scenario, y, params="realistic")
|
||||
d["year"] = f"Y{y}"
|
||||
frames.append(d)
|
||||
ben_3y = pd.concat(frames, ignore_index=True)
|
||||
|
||||
st.metric(f"Year {year} total benefit ({scenario_name})",
|
||||
f"${frames[year - 1]['annual_value'].sum():,.0f}")
|
||||
st.plotly_chart(
|
||||
px.bar(ben_3y, x="year", y="annual_value", color="benefit_line",
|
||||
title=f"Benefit breakdown by source — {scenario_name}",
|
||||
labels={"annual_value": "$/yr"}),
|
||||
width="stretch", key="benefit_stack",
|
||||
)
|
||||
|
||||
claim = calculate_total_benefit(sites, scopes, scenario, year, params="claim")
|
||||
realistic = frames[year - 1]
|
||||
comp = pd.merge(
|
||||
claim[["benefit_line", "annual_value"]].rename(
|
||||
columns={"annual_value": "Genesys claim"}),
|
||||
realistic[["benefit_line", "annual_value"]].rename(
|
||||
columns={"annual_value": "Pressure-tested"}),
|
||||
on="benefit_line", how="outer",
|
||||
).fillna(0)
|
||||
fig = go.Figure([
|
||||
go.Bar(name="Genesys claim", x=comp.benefit_line, y=comp["Genesys claim"]),
|
||||
go.Bar(name="Pressure-tested realistic", x=comp.benefit_line,
|
||||
y=comp["Pressure-tested"]),
|
||||
])
|
||||
fig.update_layout(barmode="group", yaxis_tickformat="$,.0f",
|
||||
title=f"Genesys claim vs pressure-tested — Year {year}")
|
||||
st.plotly_chart(fig, width="stretch", key="claim_vs_real")
|
||||
|
||||
# ── Page 5: Business Case ────────────────────────────────────────────
|
||||
|
||||
elif page == "5. Business Case":
|
||||
st.header("Business Case")
|
||||
st.session_state.implementation_cost = st.number_input(
|
||||
"One-off implementation cost (amortized over 3 years)",
|
||||
value=float(st.session_state.implementation_cost), min_value=0.0,
|
||||
step=50_000.0,
|
||||
)
|
||||
case = _case(scenario_name)
|
||||
|
||||
pb = case["payback_period_years"]
|
||||
c1, c2, c3 = st.columns(3)
|
||||
c1.metric("NPV @ 8%", f"${case['npv']:,.0f}")
|
||||
c2.metric("Payback", f"{pb:.2f} yrs" if pb is not None else "never")
|
||||
c3.metric("3-Year ROI", f"{case['roi_3yr']:.0%}" if case["roi_3yr"] else "n/a")
|
||||
|
||||
pnl = pd.concat(
|
||||
[
|
||||
case["cost_by_year"].drop(columns="confidence"),
|
||||
case["takeouts_by_year"].drop(columns="confidence"),
|
||||
case["benefit_by_year"].drop(columns="confidence"),
|
||||
case["net_by_year"],
|
||||
],
|
||||
ignore_index=True,
|
||||
)
|
||||
pnl["3-yr Total"] = pnl[["Y1", "Y2", "Y3"]].sum(axis=1)
|
||||
st.dataframe(
|
||||
pnl, width="stretch", hide_index=True,
|
||||
column_config={
|
||||
c: st.column_config.NumberColumn(c, format="$%,.0f")
|
||||
for c in ("Y1", "Y2", "Y3", "3-yr Total")
|
||||
},
|
||||
)
|
||||
|
||||
fig = go.Figure()
|
||||
for name in ("floor", "realistic", "stretch"):
|
||||
c = _case(name)
|
||||
fig.add_scatter(
|
||||
x=c["cumulative_net"].year, y=c["cumulative_net"].cumulative_net,
|
||||
mode="lines+markers", name=name.capitalize(),
|
||||
)
|
||||
fig.update_layout(title="Cumulative net cash flow by scenario",
|
||||
xaxis_title="Year", yaxis_tickformat="$,.0f")
|
||||
st.plotly_chart(fig, width="stretch", key="cum_net")
|
||||
|
||||
# ── Page 6: Sensitivity ──────────────────────────────────────────────
|
||||
|
||||
elif page == "6. Sensitivity Analysis":
|
||||
st.header("Sensitivity Analysis")
|
||||
base_npv = _case(scenario_name)["npv"]
|
||||
st.caption(f"Base 3-yr NPV ({scenario_name}): ${base_npv:,.0f}")
|
||||
|
||||
def _npv_with(**overrides) -> float:
|
||||
sc = dataclasses.replace(scenario, **overrides)
|
||||
return build_business_case(
|
||||
sites, scopes, meters, pricing, st.session_state.takeouts, sc,
|
||||
implementation_cost=st.session_state.implementation_cost,
|
||||
use_contracted=st.session_state.use_contracted,
|
||||
)["npv"]
|
||||
|
||||
drivers = [
|
||||
"voice_bot_deflection", "voice_bot_avg_minutes", "agentic_va_deflection",
|
||||
"voice_summarization_eligibility", "voice_knowledge_eligibility",
|
||||
"email_auto_respond_rate", "email_auto_suggest_acceptance",
|
||||
]
|
||||
rows = []
|
||||
for d in drivers:
|
||||
base_v = getattr(scenario, d)
|
||||
lo = base_v * 0.75 if d == "voice_bot_avg_minutes" else min(base_v * 0.75, 1.0)
|
||||
hi = base_v * 1.25 if d == "voice_bot_avg_minutes" else min(base_v * 1.25, 1.0)
|
||||
rows.append({"driver": d,
|
||||
"low": _npv_with(**{d: lo}) - base_npv,
|
||||
"high": _npv_with(**{d: hi}) - base_npv})
|
||||
torn = pd.DataFrame(rows)
|
||||
torn["swing"] = (torn.high - torn.low).abs()
|
||||
torn = torn.sort_values("swing")
|
||||
fig = go.Figure([
|
||||
go.Bar(y=torn.driver, x=torn.low, orientation="h", name="-25%"),
|
||||
go.Bar(y=torn.driver, x=torn.high, orientation="h", name="+25%"),
|
||||
])
|
||||
fig.update_layout(barmode="overlay", title="Tornado — NPV impact of ±25%",
|
||||
xaxis_tickformat="$,.0f")
|
||||
st.plotly_chart(fig, width="stretch", key="tornado")
|
||||
|
||||
st.subheader("Two-variable heatmap")
|
||||
xs = np.linspace(0.0, 0.50, 6) # Email Auto-Respond rate
|
||||
ys = np.linspace(0.0, 0.25, 6) # Agentic VA deflection
|
||||
z = [[_npv_with(email_auto_respond_rate=float(x),
|
||||
agentic_va_deflection=float(yv)) for x in xs] for yv in ys]
|
||||
fig = go.Figure(go.Heatmap(
|
||||
x=[f"{x:.0%}" for x in xs], y=[f"{yv:.0%}" for yv in ys], z=z,
|
||||
colorbar={"title": "3-yr NPV"},
|
||||
))
|
||||
fig.update_layout(title="NPV: Email Auto-Respond rate × Agentic VA deflection",
|
||||
xaxis_title="Email Auto-Respond rate",
|
||||
yaxis_title="Agentic VA deflection")
|
||||
st.plotly_chart(fig, width="stretch", key="heatmap")
|
||||
|
||||
st.subheader("Break-even finder")
|
||||
rates = np.linspace(0.0, 0.50, 26)
|
||||
npvs = [_npv_with(email_auto_respond_rate=float(r)) for r in rates]
|
||||
breakeven = next((r for r, v in zip(rates, npvs) if v >= 0), None)
|
||||
if npvs[0] >= 0:
|
||||
st.success(f"Case is NPV-positive even at 0% Auto-Respond "
|
||||
f"(${npvs[0]:,.0f}).")
|
||||
elif breakeven is not None:
|
||||
st.info(f"Break-even at ~{breakeven:.0%} email Auto-Respond rate.")
|
||||
else:
|
||||
st.error("No break-even within 0–50% Auto-Respond.")
|
||||
st.plotly_chart(
|
||||
px.line(x=rates, y=npvs,
|
||||
labels={"x": "Email Auto-Respond rate", "y": "3-yr NPV ($)"}),
|
||||
width="stretch", key="breakeven",
|
||||
)
|
||||
|
||||
# ── Page 7: Export ───────────────────────────────────────────────────
|
||||
|
||||
elif page == "7. Export":
|
||||
st.header("Export")
|
||||
case = _case(scenario_name)
|
||||
cost_frames, ben_frames = [], []
|
||||
for y in YEARS:
|
||||
d = calculate_total_cost(sites, scopes, meters, pricing, scenario, y,
|
||||
use_contracted=st.session_state.use_contracted)
|
||||
d["year"] = f"Y{y}"
|
||||
cost_frames.append(d)
|
||||
b = calculate_total_benefit(sites, scopes, scenario, y)
|
||||
b["year"] = f"Y{y}"
|
||||
ben_frames.append(b)
|
||||
|
||||
comparison = pd.DataFrame([
|
||||
{"scenario": n, "NPV": _case(n)["npv"],
|
||||
"payback_years": _case(n)["payback_period_years"],
|
||||
"roi_3yr": _case(n)["roi_3yr"]}
|
||||
for n in ("floor", "realistic", "stretch")
|
||||
])
|
||||
|
||||
pnl = pd.concat(
|
||||
[case["cost_by_year"].drop(columns="confidence"),
|
||||
case["takeouts_by_year"].drop(columns="confidence"),
|
||||
case["benefit_by_year"].drop(columns="confidence"),
|
||||
case["net_by_year"]],
|
||||
ignore_index=True,
|
||||
)
|
||||
|
||||
buf = io.BytesIO()
|
||||
with pd.ExcelWriter(buf, engine="openpyxl") as writer:
|
||||
sites_dataframe(sites).to_excel(writer, sheet_name="Inputs", index=False)
|
||||
meters_dataframe(meters).to_excel(writer, sheet_name="Meters", index=False)
|
||||
pd.concat(cost_frames).to_excel(writer, sheet_name="Cost detail", index=False)
|
||||
pd.concat(ben_frames).to_excel(writer, sheet_name="Benefit detail", index=False)
|
||||
pnl.to_excel(writer, sheet_name="Business case", index=False)
|
||||
comparison.to_excel(writer, sheet_name="Scenario comparison", index=False)
|
||||
st.download_button(
|
||||
"⬇️ Download Excel workbook",
|
||||
buf.getvalue(),
|
||||
file_name=f"ctm_token_calculator_{scenario_name}.xlsx",
|
||||
mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
)
|
||||
st.download_button(
|
||||
"⬇️ Download scenario JSON",
|
||||
scenario_state_to_json(sites, st.session_state.takeouts, scopes),
|
||||
file_name="ctm_scenario.json", mime="application/json",
|
||||
)
|
||||
st.dataframe(comparison, width="stretch", hide_index=True)
|
||||
Reference in New Issue
Block a user