refactor: restructure repo into core/app modules with per-study folders

Reorganize Palladium codebase into a modular architecture with `core/`
shared logic and `app/` Streamlit UI, separating per-study assets into
`studies/YYYYMM_<Vendor>/` folders containing notebooks, seed data, and
configuration. Update README to reflect new structure, add `.gitignore`
entries for `.env` and study exports, and refresh component documentation.
This commit is contained in:
2026-05-20 22:28:12 -04:00
parent a6f3ee3676
commit a2420ed692
52 changed files with 35300 additions and 105 deletions

View File

@@ -0,0 +1,5 @@
"""Notebook helpers — pandas tables, plotly charts, IPython display."""
from core.notebook_helpers import charts, display, tables
__all__ = ["charts", "display", "tables"]

View File

@@ -0,0 +1,193 @@
"""
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
}
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=PALETTE["benefits"],
)
fig.add_bar(
name="Total costs",
x=years,
y=costs,
marker_color=PALETTE["costs"],
)
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 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=PALETTE["benefits"],
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 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))
fig.update_layout(title=title, margin={"l": 40, "r": 20, "t": 60, "b": 40})
return 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=PALETTE["benefits"])
fig.add_bar(name="Costs PV", x=keys, y=costs, marker_color=PALETTE["costs"])
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 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 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",
)
)
fig.update_layout(title=title, yaxis_tickformat="$,.0f")
return fig

View File

@@ -0,0 +1,141 @@
"""
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))

View File

@@ -0,0 +1,127 @@
"""
Pandas dataframe builders for benefit / cost / summary tables.
Each builder accepts the value-row dicts produced by
``core.tei_client.TEIClient._normalize_value`` 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