- 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
316 lines
10 KiB
Python
316 lines
10 KiB
Python
"""
|
||
Plotly charts for TEI analyses.
|
||
|
||
Each function returns a ``plotly.graph_objects.Figure`` so callers can
|
||
``.show()`` (notebook), pass to ``st.plotly_chart`` (Streamlit), or write to
|
||
HTML / image. No styling is hard-coded beyond a neutral default palette.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from collections.abc import Iterable
|
||
|
||
import plotly.graph_objects as go
|
||
|
||
PALETTE = {
|
||
"benefits": "#2E7D32", # green
|
||
"costs": "#C62828", # red
|
||
"net_positive": "#1565C0", # blue
|
||
"net_negative": "#C62828",
|
||
"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 a–j, 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],
|
||
*,
|
||
title: str = "Cash Flow Analysis (Risk-Adjusted)",
|
||
initial_cost: float = 0.0,
|
||
) -> go.Figure:
|
||
"""
|
||
Stacked bars of benefits & costs by year + cumulative net line.
|
||
|
||
Mirrors the chart on page 25 of the Forrester Amazon Connect TEI study.
|
||
"""
|
||
if not yearly_breakdown:
|
||
return go.Figure(layout={"title": title})
|
||
|
||
years = ["Initial"] + [f"Year {row['year']}" for row in yearly_breakdown]
|
||
benefits = [0.0] + [float(row.get("benefits", 0)) for row in yearly_breakdown]
|
||
costs = [-float(initial_cost)] + [
|
||
-float(row.get("costs", 0)) for row in yearly_breakdown
|
||
]
|
||
# cumulative_net assumes initial cost has already been deducted
|
||
cumulative = [-float(initial_cost)] + [
|
||
float(row.get("cumulative_net", 0)) for row in yearly_breakdown
|
||
]
|
||
|
||
fig = go.Figure()
|
||
fig.add_bar(
|
||
name="Total benefits",
|
||
x=years,
|
||
y=benefits,
|
||
marker_color=THEME["bar_green"],
|
||
)
|
||
fig.add_bar(
|
||
name="Total costs",
|
||
x=years,
|
||
y=costs,
|
||
marker_color=THEME["bar_red"],
|
||
)
|
||
fig.add_scatter(
|
||
name="Cumulative net benefits",
|
||
x=years,
|
||
y=cumulative,
|
||
mode="lines+markers",
|
||
line={"color": PALETTE["cumulative"], "width": 3},
|
||
)
|
||
fig.update_layout(
|
||
title=title,
|
||
barmode="relative",
|
||
yaxis_tickformat="$,.0f",
|
||
legend={"orientation": "h", "y": -0.15},
|
||
margin={"l": 40, "r": 20, "t": 60, "b": 40},
|
||
)
|
||
return _themed(fig)
|
||
|
||
|
||
def benefits_bar(items: list[dict], *, title: str = "Benefits (Three-Year)") -> go.Figure:
|
||
"""Horizontal bars of risk-adjusted three-year totals per benefit."""
|
||
labels: list[str] = []
|
||
totals: list[float] = []
|
||
for it in items:
|
||
rf = float(it.get("risk_adjustment") or 0.0)
|
||
yv = it.get("year_values") or {}
|
||
ra_total = sum(float(v or 0) * (1.0 - rf) for v in yv.values())
|
||
labels.append(it.get("label", "") or it.get("field_key", ""))
|
||
totals.append(ra_total)
|
||
|
||
fig = go.Figure(
|
||
go.Bar(
|
||
x=totals,
|
||
y=labels,
|
||
orientation="h",
|
||
marker_color=THEME["bar_green"],
|
||
text=[f"${t/1_000_000:,.1f}M" for t in totals],
|
||
textposition="auto",
|
||
)
|
||
)
|
||
fig.update_layout(
|
||
title=title,
|
||
xaxis_tickformat="$,.0f",
|
||
yaxis={"autorange": "reversed"},
|
||
margin={"l": 40, "r": 20, "t": 60, "b": 40},
|
||
)
|
||
return _themed(fig)
|
||
|
||
|
||
def cost_breakdown_pie(
|
||
items: list[dict], *, title: str = "Cost Breakdown (Three-Year, Risk-Adjusted)"
|
||
) -> go.Figure:
|
||
"""Pie chart of risk-adjusted costs by category/label."""
|
||
labels: list[str] = []
|
||
values: list[float] = []
|
||
for it in items:
|
||
rf = float(it.get("risk_adjustment") or 0.0)
|
||
yv = it.get("year_values") or {}
|
||
initial = float(it.get("initial") or 0.0)
|
||
ra_total = (
|
||
initial * (1.0 + rf)
|
||
+ sum(float(v or 0) * (1.0 + rf) for v in yv.values())
|
||
)
|
||
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,
|
||
marker={"colors": THEME["pie_colors"]}))
|
||
fig.update_layout(title=title, margin={"l": 40, "r": 20, "t": 60, "b": 40})
|
||
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:
|
||
"""Grouped bars comparing NPV and Costs PV across scenarios."""
|
||
keys: list[str] = list(scenarios.keys())
|
||
if not keys:
|
||
return go.Figure()
|
||
benefits = [float(scenarios[k].get("total_benefits_pv") or 0) for k in keys]
|
||
costs = [float(scenarios[k].get("total_costs_pv") or 0) for k in keys]
|
||
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=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",
|
||
barmode="group",
|
||
yaxis_tickformat="$,.0f",
|
||
legend={"orientation": "h", "y": -0.15},
|
||
)
|
||
return _themed(fig)
|
||
|
||
|
||
def cumulative_benefits_chart(
|
||
yearly_breakdown: list[dict],
|
||
*,
|
||
title: str = "Cumulative Net Benefits",
|
||
) -> go.Figure:
|
||
"""Single-line cumulative net benefits trajectory."""
|
||
if not yearly_breakdown:
|
||
return go.Figure(layout={"title": title})
|
||
years = [f"Year {row['year']}" for row in yearly_breakdown]
|
||
cumulative = [float(row.get("cumulative_net", 0)) for row in yearly_breakdown]
|
||
fig = go.Figure(
|
||
go.Scatter(
|
||
x=years,
|
||
y=cumulative,
|
||
mode="lines+markers",
|
||
fill="tozeroy",
|
||
line={"color": PALETTE["net_positive"], "width": 3},
|
||
)
|
||
)
|
||
fig.update_layout(title=title, yaxis_tickformat="$,.0f")
|
||
return _themed(fig)
|
||
|
||
|
||
def waterfall(values: Iterable[tuple[str, float]], *, title: str = "TEI Waterfall") -> go.Figure:
|
||
"""
|
||
Generic waterfall (pass tuples of (label, value)).
|
||
|
||
Used by 03_business_case to show: Benefits PV → Costs PV → NPV.
|
||
"""
|
||
labels, amounts = zip(*values, strict=True) if values else ([], [])
|
||
measures = ["relative"] * (len(labels) - 1) + ["total"] if labels else []
|
||
fig = go.Figure(
|
||
go.Waterfall(
|
||
x=list(labels),
|
||
y=list(amounts),
|
||
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 _themed(fig)
|