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

@@ -56,19 +56,61 @@ def render(client: TEIClient, tool: dict) -> None:
st.divider()
# Build the yearly breakdown from the documented per-year summary keys
# (benefits_year_N / costs_year_N) when no pre-built breakdown exists.
yb = summary.get("yearly_breakdown") or []
# ── Financial visualizations ────────────────────────────────────
# Built from the live value rows so Year-0 "Initial" amounts stay
# separate (Athena's per-year summary folds them into Year 1).
values = safe(client.get_values, public_id) or []
benefit_rows = [v for v in values if v.get("table") == "benefits"]
cost_rows = [v for v in values if v.get("table") == "costs"]
if benefit_rows or cost_rows:
col_pie, col_bar = st.columns(2)
with col_pie:
charts.cost_pie(cost_rows, key=f"summary_pie_{public_id}")
with col_bar:
charts.benefits_bar(benefit_rows, key=f"summary_bar_{public_id}")
charts.benefits_vs_costs_by_year(
benefit_rows, cost_rows, key=f"summary_by_year_{public_id}"
)
# Cash flow + cumulative net — the Forrester-style exhibit.
def _yearly_breakdown_from_values():
initial = sum(float(c.get("initial") or 0) for c in cost_rows)
years: set[int] = set()
for v in [*benefit_rows, *cost_rows]:
years.update(int(y) for y in (v.get("year_values") or {}))
rows, cumulative = [], -initial
for y in sorted(years):
b = sum(
float((v.get("year_values") or {}).get(str(y), 0) or 0)
* (1 - float(v.get("risk_adjustment") or 0))
for v in benefit_rows
)
c = sum(
float((v.get("year_values") or {}).get(str(y), 0) or 0)
for v in cost_rows
)
cumulative += b - c
rows.append(
{"year": y, "benefits": b, "costs": c, "net": b - c,
"cumulative_net": cumulative}
)
return rows, initial
yb, initial = ([], 0.0)
if benefit_rows or cost_rows:
yb, initial = _yearly_breakdown_from_values()
if not yb:
# Fallback: documented per-year summary keys (initial folded in Y1).
n = 1
while f"benefits_year_{n}" in summary or f"costs_year_{n}" in summary:
b = float(summary.get(f"benefits_year_{n}") or 0)
c = float(summary.get(f"costs_year_{n}") or 0)
yb.append({"year": n, "benefits": b, "costs": c, "net": b - c})
n += 1
initial = float(summary.get("initial_costs") or 0)
initial = float(summary.get("initial_costs") or 0)
if yb:
charts.cashflow(yb, initial_cost=initial)
charts.cashflow(yb, initial_cost=initial, key=f"summary_cashflow_{public_id}")
with st.expander("Cash flow table"):
_cur = currency_fmt()
st.dataframe(
@@ -85,6 +127,14 @@ def render(client: TEIClient, tool: dict) -> None:
else:
st.caption("No yearly breakdown in this summary.")
# Waterfall — Benefits PV down to NPV.
if bpv or cpv:
charts.waterfall([
("Benefits PV", bpv),
("Costs PV", -cpv),
("NPV", npv),
], key=f"summary_waterfall_{public_id}")
# Scenario comparison — computed locally from current values
with st.expander("Scenario analysis (conservative / moderate / aggressive)"):
envelope = safe(
@@ -95,7 +145,9 @@ def render(client: TEIClient, tool: dict) -> None:
study_slug=report.get("name", ""),
)
if envelope and envelope.get("scenarios"):
charts.scenario_bars(envelope["scenarios"])
charts.scenario_bars(
envelope["scenarios"], key=f"summary_scenarios_{public_id}"
)
rows = [
{
"Scenario": k,