Migrate Genesys CX Cloud TEI study to the pattern; retire Streamlit app
studies/202512_GenesysCX -> studies/202512_TEI_Genesys_CX_Cloud, rebuilt as pattern Variant 4 (TEI composite reproduction): - teicalc/ self-contained engine: Forrester's tables as the never-edited verbatim anchor (incl. the p.14 typo note and the $0 AI-token line), generic model/scenarios/staging carried over from the Amazon Connect study, ClientDrivers overlay (agents / weekly interactions / revenue, flat composite so no growth re-base) with ai_tokens_annual as a direct input for the token line the published study models at $0 - one deliverable notebook (business_case.ipynb): widget-pair sidebar drivers incl. the AI-token price, published-vs-overlay KPI columns, cash-flow/waterfall/scenario charts, verification gate, backstage JSON data appendix - gate + tests reproduce the published totals within $2: NPV $10.8M / ROI 266% (engine $10,783,466 / 265.79%; payback 3.3 months, not headlined in the PDF); 29 study tests, headless nbconvert green, stage simulation leak-free, exports carry the appendix - old Athena workflow (00_provision..04_export, config.py, seed_data.py, PALLADIUM_GENESYSCX_* keys, ATHENA_EXPECTED reconciliation) deleted; git history preserves it With the last legacy study migrated, the retirement lands too: - app/ (Streamlit UI) and core/notebook_helpers deleted; nothing else imported them - streamlit stripped from pyproject extras, requirements.txt, Makefile; .env.example reduced to the Athena keys; 00_setup.ipynb and core/bootstrap.py repointed at the pattern studies - root README reworked: self-contained studies + slim core/ Athena toolkit (tei_client, calculations, export, cli) All suites green: Genesys 29, Amazon Connect 27, CTM 55, template 7, root 58. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,10 +7,11 @@ From *any* notebook in the repo (root, ``studies/<slug>/notebooks/``, …)::
|
||||
pal = init() # loads .env, builds client, tests it
|
||||
pal.client.list_reports()
|
||||
|
||||
or, for a study notebook::
|
||||
|
||||
pal = init(study="202602_AmazonConnect")
|
||||
pal.config.STUDY_SLUG, pal.seed_data.BENEFITS
|
||||
(Pattern studies under ``studies/`` are self-contained — they carry their
|
||||
own engine and venv and never import ``core``. The legacy ``study=``
|
||||
parameter loaded a study's ``config.py``/``seed_data.py``; those modules
|
||||
were retired with the study migrations, so ``init()`` is now purely the
|
||||
Athena connection bootstrap.)
|
||||
|
||||
If ``core`` itself can't be imported (fresh kernel, notebook cwd deep in the
|
||||
tree), put this two-liner first — it is the only path juggling left anywhere::
|
||||
|
||||
@@ -138,7 +138,7 @@ def build_report_data(
|
||||
include_scenarios: if True, locally compute conservative / moderate /
|
||||
aggressive summaries and attach them under ``scenarios``.
|
||||
study_slug: optional human-friendly study identifier (e.g.
|
||||
``"202602_AmazonConnect"``) — written into ``metadata``.
|
||||
``"202602_TEI_Amazon_Connect"``) — written into ``metadata``.
|
||||
|
||||
Returns:
|
||||
A dict with keys::
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
"""Notebook helpers — pandas tables, plotly charts, IPython display."""
|
||||
|
||||
from core.notebook_helpers import charts, display, tables
|
||||
|
||||
__all__ = ["charts", "display", "tables"]
|
||||
@@ -1,315 +0,0 @@
|
||||
"""
|
||||
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)
|
||||
@@ -1,141 +0,0 @@
|
||||
"""
|
||||
IPython display helpers — KPI cards, formatted summary blocks, alerts.
|
||||
|
||||
Functions are notebook-safe: they fall back to plain ``print`` when running
|
||||
outside Jupyter / when IPython is not available.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
try: # pragma: no cover – IPython is a soft dep
|
||||
from IPython.display import HTML, display
|
||||
|
||||
_IPY = True
|
||||
except Exception: # pragma: no cover
|
||||
_IPY = False
|
||||
|
||||
|
||||
def _money(value: Any, default: str = "—") -> str:
|
||||
try:
|
||||
v = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
if abs(v) >= 1_000_000_000:
|
||||
return f"${v/1_000_000_000:,.1f}B"
|
||||
if abs(v) >= 1_000_000:
|
||||
return f"${v/1_000_000:,.1f}M"
|
||||
if abs(v) >= 1_000:
|
||||
return f"${v/1_000:,.1f}K"
|
||||
return f"${v:,.0f}"
|
||||
|
||||
|
||||
def _pct(value: Any, default: str = "—") -> str:
|
||||
try:
|
||||
v = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
return f"{v:,.0f}%"
|
||||
|
||||
|
||||
def _months(value: Any, default: str = "N/A") -> str:
|
||||
if value is None:
|
||||
return default
|
||||
try:
|
||||
v = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
if v < 6:
|
||||
return f"<6 months ({v:.1f})"
|
||||
return f"{v:.1f} months"
|
||||
|
||||
|
||||
def kpi_cards(summary: dict, *, title: str | None = None) -> Any:
|
||||
"""
|
||||
Render a row of KPI cards (NPV, ROI, Payback, Benefits PV).
|
||||
|
||||
In notebooks, returns/displays inline HTML. Outside IPython, prints a
|
||||
plain text version.
|
||||
"""
|
||||
npv = _money(summary.get("npv"))
|
||||
roi = _pct(summary.get("roi") or summary.get("roi_pct"))
|
||||
payback = _months(summary.get("payback_months"))
|
||||
benefits_pv = _money(summary.get("total_benefits_pv"))
|
||||
costs_pv = _money(summary.get("total_costs_pv"))
|
||||
|
||||
if not _IPY: # pragma: no cover
|
||||
print(title or "TEI Summary")
|
||||
print(f" NPV: {npv} ROI: {roi} Payback: {payback}")
|
||||
print(f" Benefits PV: {benefits_pv} Costs PV: {costs_pv}")
|
||||
return None
|
||||
|
||||
title_html = (
|
||||
f'<div style="font-size:1.1em;font-weight:600;margin-bottom:6px;color:#444;">'
|
||||
f"{title}</div>"
|
||||
if title
|
||||
else ""
|
||||
)
|
||||
card_style = (
|
||||
"flex:1;min-width:140px;padding:14px 18px;margin:4px;border-radius:8px;"
|
||||
"background:#f7f9fc;border:1px solid #e3e8ee;"
|
||||
)
|
||||
label_style = "font-size:0.78em;color:#6b7480;text-transform:uppercase;letter-spacing:0.04em;"
|
||||
value_style = "font-size:1.6em;font-weight:600;color:#1a2540;margin-top:4px;"
|
||||
|
||||
cards = [
|
||||
("NPV", npv),
|
||||
("ROI", roi),
|
||||
("Payback", payback),
|
||||
("Benefits PV", benefits_pv),
|
||||
("Costs PV", costs_pv),
|
||||
]
|
||||
cards_html = "".join(
|
||||
f'<div style="{card_style}">'
|
||||
f'<div style="{label_style}">{label}</div>'
|
||||
f'<div style="{value_style}">{value}</div>'
|
||||
f"</div>"
|
||||
for label, value in cards
|
||||
)
|
||||
html = (
|
||||
f'<div>{title_html}'
|
||||
f'<div style="display:flex;flex-wrap:wrap;align-items:stretch;">{cards_html}</div>'
|
||||
f"</div>"
|
||||
)
|
||||
return display(HTML(html))
|
||||
|
||||
|
||||
def summary_panel(summary: dict, *, title: str = "TEI Financial Summary") -> None:
|
||||
"""Plain-text bordered summary block (mirrors the PDF Cash Flow Analysis)."""
|
||||
width = 60
|
||||
print("═" * width)
|
||||
print(f" {title}")
|
||||
print("═" * width)
|
||||
print(f" Benefits PV : {_money(summary.get('total_benefits_pv')):>20}")
|
||||
print(f" Costs PV : {_money(summary.get('total_costs_pv')):>20}")
|
||||
print("─" * width)
|
||||
print(f" NPV : {_money(summary.get('npv')):>20}")
|
||||
roi_val = summary.get("roi") or summary.get("roi_pct")
|
||||
print(f" ROI : {_pct(roi_val):>20}")
|
||||
print(f" Payback : {_months(summary.get('payback_months')):>20}")
|
||||
print("═" * width)
|
||||
|
||||
|
||||
def alert(text: str, kind: str = "info") -> Any:
|
||||
"""Coloured alert box for notebooks ('info', 'success', 'warning', 'error')."""
|
||||
colors = {
|
||||
"info": ("#0277bd", "#e1f5fe"),
|
||||
"success": ("#2e7d32", "#e8f5e9"),
|
||||
"warning": ("#ef6c00", "#fff3e0"),
|
||||
"error": ("#c62828", "#ffebee"),
|
||||
}
|
||||
fg, bg = colors.get(kind, colors["info"])
|
||||
if not _IPY: # pragma: no cover
|
||||
print(f"[{kind.upper()}] {text}")
|
||||
return None
|
||||
html = (
|
||||
f'<div style="padding:10px 14px;border-left:4px solid {fg};'
|
||||
f'background:{bg};color:#1a1a1a;border-radius:4px;margin:6px 0;">'
|
||||
f"{text}</div>"
|
||||
)
|
||||
return display(HTML(html))
|
||||
@@ -1,127 +0,0 @@
|
||||
"""
|
||||
Pandas dataframe builders for benefit / cost / summary tables.
|
||||
|
||||
Each builder accepts the friendly value-row dicts returned by
|
||||
``core.tei_client.TEIClient.get_values`` and returns a
|
||||
nicely-formatted DataFrame for display in notebooks.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from typing import Any
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from core.calculations import risk_adjust_benefit, risk_adjust_cost
|
||||
|
||||
|
||||
def _years_in_data(items: Iterable[dict]) -> list[int]:
|
||||
years: set[int] = set()
|
||||
for it in items:
|
||||
for k in (it.get("year_values") or {}):
|
||||
try:
|
||||
years.add(int(k))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return sorted(years)
|
||||
|
||||
|
||||
def benefits_table(items: list[dict]) -> pd.DataFrame:
|
||||
"""Tidy benefits dataframe with one row per benefit, year columns, totals."""
|
||||
if not items:
|
||||
return pd.DataFrame(
|
||||
columns=["field_key", "label", "category", "risk_adjustment"]
|
||||
)
|
||||
years = _years_in_data(items)
|
||||
rows: list[dict[str, Any]] = []
|
||||
for it in items:
|
||||
rf = float(it.get("risk_adjustment") or 0.0)
|
||||
yv = it.get("year_values") or {}
|
||||
row = {
|
||||
"field_key": it.get("field_key", ""),
|
||||
"label": it.get("label", "") or it.get("field_key", ""),
|
||||
"category": it.get("category", ""),
|
||||
"risk_adjustment": rf,
|
||||
}
|
||||
nominal_total = 0.0
|
||||
ra_total = 0.0
|
||||
for y in years:
|
||||
v = float(yv.get(str(y)) or 0.0)
|
||||
ra = risk_adjust_benefit(v, rf)
|
||||
row[f"Year {y}"] = v
|
||||
row[f"Year {y} (RA)"] = ra
|
||||
nominal_total += v
|
||||
ra_total += ra
|
||||
row["Total"] = nominal_total
|
||||
row["Total (RA)"] = ra_total
|
||||
rows.append(row)
|
||||
return pd.DataFrame(rows)
|
||||
|
||||
|
||||
def costs_table(items: list[dict]) -> pd.DataFrame:
|
||||
"""Tidy costs dataframe — adds an Initial column when present."""
|
||||
if not items:
|
||||
return pd.DataFrame(
|
||||
columns=["field_key", "label", "category", "risk_adjustment", "Initial"]
|
||||
)
|
||||
years = _years_in_data(items)
|
||||
rows: list[dict[str, Any]] = []
|
||||
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)
|
||||
row = {
|
||||
"field_key": it.get("field_key", ""),
|
||||
"label": it.get("label", "") or it.get("field_key", ""),
|
||||
"category": it.get("category", ""),
|
||||
"risk_adjustment": rf,
|
||||
"Initial": initial,
|
||||
"Initial (RA)": risk_adjust_cost(initial, rf),
|
||||
}
|
||||
nominal_total = initial
|
||||
ra_total = risk_adjust_cost(initial, rf)
|
||||
for y in years:
|
||||
v = float(yv.get(str(y)) or 0.0)
|
||||
ra = risk_adjust_cost(v, rf)
|
||||
row[f"Year {y}"] = v
|
||||
row[f"Year {y} (RA)"] = ra
|
||||
nominal_total += v
|
||||
ra_total += ra
|
||||
row["Total"] = nominal_total
|
||||
row["Total (RA)"] = ra_total
|
||||
rows.append(row)
|
||||
return pd.DataFrame(rows)
|
||||
|
||||
|
||||
def summary_table(summary: dict) -> pd.DataFrame:
|
||||
"""Single-row summary dataframe of headline KPIs."""
|
||||
pb = summary.get("payback_months")
|
||||
pb_str = f"{float(pb):.1f} months" if pb not in (None, "") else "N/A"
|
||||
data = {
|
||||
"NPV": [float(summary.get("npv") or 0)],
|
||||
"ROI %": [float(summary.get("roi") or summary.get("roi_pct") or 0)],
|
||||
"Payback": [pb_str],
|
||||
"Benefits PV": [float(summary.get("total_benefits_pv") or 0)],
|
||||
"Costs PV": [float(summary.get("total_costs_pv") or 0)],
|
||||
"Discount rate": [float(summary.get("discount_rate") or 0)],
|
||||
"Analysis years": [int(summary.get("analysis_years") or 0)],
|
||||
}
|
||||
return pd.DataFrame(data)
|
||||
|
||||
|
||||
def cashflow_table(summary: dict) -> pd.DataFrame:
|
||||
"""Per-year cashflow dataframe from a summary's ``yearly_breakdown``."""
|
||||
yb = summary.get("yearly_breakdown") or []
|
||||
if not yb:
|
||||
return pd.DataFrame(columns=["Year", "Benefits", "Costs", "Net", "Cumulative"])
|
||||
df = pd.DataFrame(yb)
|
||||
rename = {
|
||||
"year": "Year",
|
||||
"benefits": "Benefits",
|
||||
"costs": "Costs",
|
||||
"net": "Net",
|
||||
"cumulative_net": "Cumulative",
|
||||
}
|
||||
df = df.rename(columns=rename)
|
||||
return df
|
||||
Reference in New Issue
Block a user