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:
@@ -1,4 +1,9 @@
|
||||
"""Streamlit-friendly chart wrappers (delegate to core.notebook_helpers.charts)."""
|
||||
"""Streamlit-friendly chart wrappers (delegate to core.notebook_helpers.charts).
|
||||
|
||||
Every wrapper takes a ``key`` — the same figure type renders on multiple
|
||||
tabs (Summary, Benefits, Costs) within one script run, so Streamlit needs
|
||||
explicit element IDs to avoid StreamlitDuplicateElementId errors.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -7,26 +12,31 @@ import streamlit as st
|
||||
from core.notebook_helpers import charts as core_charts
|
||||
|
||||
|
||||
def cashflow(yearly_breakdown, *, initial_cost: float = 0.0) -> None:
|
||||
def cashflow(yearly_breakdown, *, initial_cost: float = 0.0, key: str = "cashflow") -> None:
|
||||
fig = core_charts.cashflow_chart(yearly_breakdown, initial_cost=initial_cost)
|
||||
st.plotly_chart(fig, width="stretch")
|
||||
st.plotly_chart(fig, width="stretch", key=key)
|
||||
|
||||
|
||||
def benefits_bar(items) -> None:
|
||||
def benefits_bar(items, *, key: str = "benefits_bar") -> None:
|
||||
fig = core_charts.benefits_bar(items)
|
||||
st.plotly_chart(fig, width="stretch")
|
||||
st.plotly_chart(fig, width="stretch", key=key)
|
||||
|
||||
|
||||
def cost_pie(items) -> None:
|
||||
def cost_pie(items, *, key: str = "cost_pie") -> None:
|
||||
fig = core_charts.cost_breakdown_pie(items)
|
||||
st.plotly_chart(fig, width="stretch")
|
||||
st.plotly_chart(fig, width="stretch", key=key)
|
||||
|
||||
|
||||
def scenario_bars(scenarios) -> None:
|
||||
def benefits_vs_costs_by_year(benefit_items, cost_items, *, key: str = "by_year") -> None:
|
||||
fig = core_charts.benefits_vs_costs_by_year(benefit_items, cost_items)
|
||||
st.plotly_chart(fig, width="stretch", key=key)
|
||||
|
||||
|
||||
def scenario_bars(scenarios, *, key: str = "scenario_bars") -> None:
|
||||
fig = core_charts.scenario_comparison(scenarios)
|
||||
st.plotly_chart(fig, width="stretch")
|
||||
st.plotly_chart(fig, width="stretch", key=key)
|
||||
|
||||
|
||||
def waterfall(values) -> None:
|
||||
def waterfall(values, *, key: str = "waterfall") -> None:
|
||||
fig = core_charts.waterfall(values)
|
||||
st.plotly_chart(fig, width="stretch")
|
||||
st.plotly_chart(fig, width="stretch", key=key)
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import streamlit as st
|
||||
|
||||
from app.components import charts
|
||||
from app.components.tables import df_to_values, value_editor
|
||||
from app.utils import icon
|
||||
|
||||
@@ -50,3 +51,7 @@ def render(client: TEIClient, tool: dict) -> None:
|
||||
"applied at calculate time. Use the Recalculate button in the "
|
||||
"sidebar after saving to refresh the summary."
|
||||
)
|
||||
|
||||
if values:
|
||||
st.divider()
|
||||
charts.benefits_bar(values, key=f"benefits_tab_bar_{public_id}")
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import streamlit as st
|
||||
|
||||
from app.components import charts
|
||||
from app.components.tables import df_to_values, value_editor
|
||||
from app.utils import icon
|
||||
|
||||
@@ -50,3 +51,18 @@ def render(client: TEIClient, tool: dict) -> None:
|
||||
"are end-of-year cashflows. Costs are risk-adjusted upward "
|
||||
"(higher risk → higher cost)."
|
||||
)
|
||||
|
||||
if values:
|
||||
st.divider()
|
||||
col_pie, col_year = st.columns(2)
|
||||
with col_pie:
|
||||
charts.cost_pie(values, key=f"costs_tab_pie_{public_id}")
|
||||
with col_year:
|
||||
benefit_values = [
|
||||
v
|
||||
for v in safe(client.get_values, public_id) or []
|
||||
if v.get("table") == "benefits"
|
||||
]
|
||||
charts.benefits_vs_costs_by_year(
|
||||
benefit_values, values, key=f"costs_tab_by_year_{public_id}"
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user