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

@@ -20,6 +20,70 @@ PALETTE = {
"cumulative": "#616161", # grey
}
#: Visual theme — override per study/client with :func:`apply_theme`.
#: Hex colours; fonts are CSS font-family strings.
THEME = {
"heading_font": "Helvetica Neue, Arial, sans-serif",
"body_font": "Helvetica, Arial, sans-serif",
"font_color": "#1F2937",
# Circle-chart slice colours aj, used in order.
"pie_colors": [
"#1565C0", # a
"#2E7D32", # b
"#C62828", # c
"#F9A825", # d
"#6A1B9A", # e
"#00838F", # f
"#EF6C00", # g
"#5D4037", # h
"#37474F", # i
"#AD1457", # j
],
"bar_green": "#2E7D32",
"bar_red": "#C62828",
}
def apply_theme(**overrides) -> dict:
"""
Override theme values for all charts in this session.
Accepts any THEME key. ``pie_colors`` may be a list (used in order) or
a dict keyed ``"a"````"j"`` (sorted alphabetically). Returns the
active theme. Example::
from core.notebook_helpers import charts
charts.apply_theme(
heading_font="Georgia, serif",
font_color="#102A43",
pie_colors={"a": "#1565C0", "b": "#2E7D32"},
bar_green="#1B5E20",
bar_red="#B71C1C",
)
"""
for key, value in overrides.items():
if key not in THEME:
raise KeyError(
f"Unknown theme key {key!r}. Valid keys: {sorted(THEME)}"
)
if key == "pie_colors" and isinstance(value, dict):
value = [value[k] for k in sorted(value)]
THEME[key] = value
return THEME
def _themed(fig: go.Figure) -> go.Figure:
"""Apply theme fonts/colours to a figure's layout."""
fig.update_layout(
font={"family": THEME["body_font"], "color": THEME["font_color"]},
title_font={
"family": THEME["heading_font"],
"color": THEME["font_color"],
},
legend_font={"family": THEME["body_font"], "color": THEME["font_color"]},
)
return fig
def cashflow_chart(
yearly_breakdown: list[dict],
@@ -50,13 +114,13 @@ def cashflow_chart(
name="Total benefits",
x=years,
y=benefits,
marker_color=PALETTE["benefits"],
marker_color=THEME["bar_green"],
)
fig.add_bar(
name="Total costs",
x=years,
y=costs,
marker_color=PALETTE["costs"],
marker_color=THEME["bar_red"],
)
fig.add_scatter(
name="Cumulative net benefits",
@@ -72,7 +136,7 @@ def cashflow_chart(
legend={"orientation": "h", "y": -0.15},
margin={"l": 40, "r": 20, "t": 60, "b": 40},
)
return fig
return _themed(fig)
def benefits_bar(items: list[dict], *, title: str = "Benefits (Three-Year)") -> go.Figure:
@@ -91,7 +155,7 @@ def benefits_bar(items: list[dict], *, title: str = "Benefits (Three-Year)") ->
x=totals,
y=labels,
orientation="h",
marker_color=PALETTE["benefits"],
marker_color=THEME["bar_green"],
text=[f"${t/1_000_000:,.1f}M" for t in totals],
textposition="auto",
)
@@ -102,7 +166,7 @@ def benefits_bar(items: list[dict], *, title: str = "Benefits (Three-Year)") ->
yaxis={"autorange": "reversed"},
margin={"l": 40, "r": 20, "t": 60, "b": 40},
)
return fig
return _themed(fig)
def cost_breakdown_pie(
@@ -122,9 +186,64 @@ def cost_breakdown_pie(
labels.append(it.get("label", "") or it.get("field_key", ""))
values.append(ra_total)
fig = go.Figure(go.Pie(labels=labels, values=values, hole=0.35))
fig = go.Figure(go.Pie(labels=labels, values=values, hole=0.35,
marker={"colors": THEME["pie_colors"]}))
fig.update_layout(title=title, margin={"l": 40, "r": 20, "t": 60, "b": 40})
return fig
return _themed(fig)
def benefits_vs_costs_by_year(
benefit_items: list[dict],
cost_items: list[dict],
*,
title: str = "Benefits vs Costs by Year (Risk-Adjusted)",
) -> go.Figure:
"""
Grouped bars of risk-adjusted benefits and costs per year, with an
Initial (Year 0) column for one-time costs.
Accepts the friendly value rows from ``TEIClient.get_values``:
benefit values are nominal (field-level risk adjustment applied here);
cost values are stored already risk-adjusted (Palladium convention),
with ``initial`` carrying the Year-0 amount.
"""
years: set[int] = set()
for it in [*benefit_items, *cost_items]:
years.update(int(y) for y in (it.get("year_values") or {}))
year_list = sorted(years) or [1, 2, 3]
benefits_by_year: dict[int, float] = dict.fromkeys(year_list, 0.0)
costs_by_year: dict[int, float] = dict.fromkeys(year_list, 0.0)
initial_total = 0.0
for it in benefit_items:
rf = float(it.get("risk_adjustment") or 0.0)
for y, v in (it.get("year_values") or {}).items():
benefits_by_year[int(y)] += float(v or 0) * (1.0 - rf)
for it in cost_items:
initial_total += float(it.get("initial") or 0.0)
for y, v in (it.get("year_values") or {}).items():
costs_by_year[int(y)] += float(v or 0)
x = ["Initial"] + [f"Year {y}" for y in year_list]
benefits = [0.0] + [benefits_by_year[y] for y in year_list]
costs = [initial_total] + [costs_by_year[y] for y in year_list]
fig = go.Figure()
fig.add_bar(name="Benefits", x=x, y=benefits, marker_color=THEME["bar_green"],
text=[f"${v/1_000_000:,.1f}M" if v else "" for v in benefits],
textposition="outside")
fig.add_bar(name="Costs", x=x, y=costs, marker_color=THEME["bar_red"],
text=[f"${v/1_000_000:,.1f}M" if v else "" for v in costs],
textposition="outside")
fig.update_layout(
title=title,
barmode="group",
yaxis_tickformat="$,.0f",
legend={"orientation": "h", "y": -0.15},
margin={"l": 40, "r": 20, "t": 60, "b": 40},
)
return _themed(fig)
def scenario_comparison(scenarios: dict) -> go.Figure:
@@ -137,8 +256,8 @@ def scenario_comparison(scenarios: dict) -> go.Figure:
npvs = [float(scenarios[k].get("npv") or 0) for k in keys]
fig = go.Figure()
fig.add_bar(name="Benefits PV", x=keys, y=benefits, marker_color=PALETTE["benefits"])
fig.add_bar(name="Costs PV", x=keys, y=costs, marker_color=PALETTE["costs"])
fig.add_bar(name="Benefits PV", x=keys, y=benefits, marker_color=THEME["bar_green"])
fig.add_bar(name="Costs PV", x=keys, y=costs, marker_color=THEME["bar_red"])
fig.add_bar(name="NPV", x=keys, y=npvs, marker_color=PALETTE["net_positive"])
fig.update_layout(
title="Scenario Comparison",
@@ -146,7 +265,7 @@ def scenario_comparison(scenarios: dict) -> go.Figure:
yaxis_tickformat="$,.0f",
legend={"orientation": "h", "y": -0.15},
)
return fig
return _themed(fig)
def cumulative_benefits_chart(
@@ -169,7 +288,7 @@ def cumulative_benefits_chart(
)
)
fig.update_layout(title=title, yaxis_tickformat="$,.0f")
return fig
return _themed(fig)
def waterfall(values: Iterable[tuple[str, float]], *, title: str = "TEI Waterfall") -> go.Figure:
@@ -187,7 +306,10 @@ def waterfall(values: Iterable[tuple[str, float]], *, title: str = "TEI Waterfal
measure=measures,
text=[f"${v/1_000_000:,.1f}M" for v in amounts],
textposition="outside",
increasing={"marker": {"color": THEME["bar_green"]}},
decreasing={"marker": {"color": THEME["bar_red"]}},
totals={"marker": {"color": PALETTE["net_positive"]}},
)
)
fig.update_layout(title=title, yaxis_tickformat="$,.0f")
return fig
return _themed(fig)