feat: add locale formatting config and update notebook outputs

Add configurable locale/display formatting environment variables
(`PALLADIUM_CURRENCY_SYMBOL`, `PALLADIUM_THOUSANDS_SEP`,
`PALLADIUM_DECIMAL_SEP`) to support regional number formatting in the
Streamlit app. Update `.env.example` with documentation for these new
variables.

Also refresh `00_setup.ipynb` with current execution outputs reflecting
a live Athena connection with report templates, a selected client
(Global Guardian Insurance, ID=2), and resolved NameError in assumption
override cells.
This commit is contained in:
2026-06-10 11:54:28 -04:00
parent 253ff38118
commit ecd164ee6d
13 changed files with 839 additions and 111 deletions

View File

@@ -9,24 +9,24 @@ from core.notebook_helpers import charts as core_charts
def cashflow(yearly_breakdown, *, initial_cost: float = 0.0) -> None:
fig = core_charts.cashflow_chart(yearly_breakdown, initial_cost=initial_cost)
st.plotly_chart(fig, use_container_width=True)
st.plotly_chart(fig, width="stretch")
def benefits_bar(items) -> None:
fig = core_charts.benefits_bar(items)
st.plotly_chart(fig, use_container_width=True)
st.plotly_chart(fig, width="stretch")
def cost_pie(items) -> None:
fig = core_charts.cost_breakdown_pie(items)
st.plotly_chart(fig, use_container_width=True)
st.plotly_chart(fig, width="stretch")
def scenario_bars(scenarios) -> None:
fig = core_charts.scenario_comparison(scenarios)
st.plotly_chart(fig, use_container_width=True)
st.plotly_chart(fig, width="stretch")
def waterfall(values) -> None:
fig = core_charts.waterfall(values)
st.plotly_chart(fig, use_container_width=True)
st.plotly_chart(fig, width="stretch")

View File

@@ -5,9 +5,11 @@ from __future__ import annotations
import pandas as pd
import streamlit as st
from app.locale import currency_fmt, fmt_currency, fmt_pct, pct_fmt, _STANDARD_LOCALE
def _years_for_table(fields: list[dict], analysis_years: int) -> list[int]:
"""Years 1..N taken from analysis_period_years on the report."""
"""Years 1..N -- taken from analysis_period_years on the report."""
return list(range(1, max(int(analysis_years or 3), 1) + 1))
@@ -26,6 +28,11 @@ def value_editor(
columns, an ``initial`` column for costs, a risk_adjustment column, and
a notes column. Returns the edited DataFrame; the caller is responsible
for converting it back to value-row dicts and PUTting to Athena.
Currency columns use the locale configured via PALLADIUM_CURRENCY_SYMBOL /
PALLADIUM_THOUSANDS_SEP / PALLADIUM_DECIMAL_SEP in .env.
The risk_adj column is stored as a 0-1 fraction and displayed as a
percentage (e.g. 0.20 -> "20.00%").
"""
fields = [
f
@@ -44,43 +51,76 @@ def value_editor(
for f in fields:
v = by_key.get(f["field_key"], {}) or {}
yv = v.get("year_values") or {}
risk_raw = float(v.get("risk_adjustment") or 0.0)
row = {
"field_key": f["field_key"],
"label": f.get("label", f["field_key"]),
"category": f.get("category", "") or "",
}
if table == "costs":
row["Initial"] = float(v.get("initial") or 0.0)
if _STANDARD_LOCALE:
row["Initial"] = float(v.get("initial") or 0.0)
else:
row["Initial"] = fmt_currency(float(v.get("initial") or 0.0))
for y in years:
row[f"Year {y}"] = float(yv.get(str(y)) or 0.0)
row["risk_adj"] = float(v.get("risk_adjustment") or 0.0)
raw = float(yv.get(str(y)) or 0.0)
if _STANDARD_LOCALE:
row[f"Year {y}"] = raw
else:
row[f"Year {y}"] = fmt_currency(raw)
# Risk adj: store as fraction for standard locales (NumberColumn handles
# display), or pre-format as "20.00%" string for non-standard locales.
if _STANDARD_LOCALE:
row["risk_adj"] = risk_raw
else:
row["risk_adj"] = fmt_pct(risk_raw)
row["notes"] = v.get("notes", "") or ""
rows.append(row)
df = pd.DataFrame(rows)
_cur_fmt = currency_fmt()
_pct_fmt_str = pct_fmt()
column_config: dict = {
"field_key": st.column_config.TextColumn("Key", disabled=True, width="small"),
"label": st.column_config.TextColumn("Field", disabled=True),
"category": st.column_config.TextColumn("Category", disabled=True, width="small"),
"risk_adj": st.column_config.NumberColumn(
"Risk Adj.", min_value=0.0, max_value=1.0, step=0.05, format="%.2f"
),
"notes": st.column_config.TextColumn("Notes", width="medium"),
}
if table == "costs":
column_config["Initial"] = st.column_config.NumberColumn(
"Initial", format="$%.0f"
if _STANDARD_LOCALE:
column_config["risk_adj"] = st.column_config.NumberColumn(
"Risk Adj.",
min_value=0.0,
max_value=1.0,
step=0.05,
format=_pct_fmt_str,
help="Enter as a decimal fraction (e.g. 0.20 = 20%)",
)
for y in years:
column_config[f"Year {y}"] = st.column_config.NumberColumn(
f"Year {y}", format="$%.0f"
if table == "costs":
column_config["Initial"] = st.column_config.NumberColumn(
"Initial", format=_cur_fmt
)
for y in years:
column_config[f"Year {y}"] = st.column_config.NumberColumn(
f"Year {y}", format=_cur_fmt
)
else:
# Non-standard locale: display as pre-formatted strings (read-only display;
# user edits the raw number and we re-format on save).
column_config["risk_adj"] = st.column_config.TextColumn(
"Risk Adj.", help="Displayed as percentage; stored as 0-1 fraction"
)
if table == "costs":
column_config["Initial"] = st.column_config.TextColumn("Initial")
for y in years:
column_config[f"Year {y}"] = st.column_config.TextColumn(f"Year {y}")
edited = st.data_editor(
df,
column_config=column_config,
use_container_width=True,
width="stretch",
num_rows="fixed",
hide_index=True,
key=key,

87
app/locale.py Normal file
View File

@@ -0,0 +1,87 @@
"""
Locale / formatting settings for the Palladium Streamlit app.
All settings are read from environment variables (via .env) so the same
codebase can be deployed for different regions without code changes.
Environment variables
---------------------
PALLADIUM_CURRENCY_SYMBOL Default: "$"
Prefix shown before monetary values (e.g. "$", "", "£", "CAD ").
PALLADIUM_THOUSANDS_SEP Default: ","
Thousands separator used in number display (e.g. "," for Americas,
"." for continental Europe, " " for some locales).
PALLADIUM_DECIMAL_SEP Default: "."
Decimal separator (e.g. "." for Americas/UK, "," for continental Europe).
Note: Streamlit's NumberColumn ``format`` uses printf-style strings.
The ``%,`` flag (thousands separator) is supported in Streamlit ≥ 1.31.
For non-standard separators (e.g. European "." thousands / "," decimal)
the values are pre-formatted as strings and displayed in TextColumns.
"""
from __future__ import annotations
import os
def _env(key: str, default: str) -> str:
return os.environ.get(key, default).strip()
# ---------------------------------------------------------------------------
# Resolved settings (read once at import time; restart app to pick up changes)
# ---------------------------------------------------------------------------
CURRENCY_SYMBOL: str = _env("PALLADIUM_CURRENCY_SYMBOL", "$")
THOUSANDS_SEP: str = _env("PALLADIUM_THOUSANDS_SEP", ",")
DECIMAL_SEP: str = _env("PALLADIUM_DECIMAL_SEP", ".")
# True when the locale uses standard printf-compatible separators
# (i.e. "," thousands + "." decimal — the C/POSIX default).
# When False, we pre-format values as strings instead of relying on printf.
_STANDARD_LOCALE: bool = THOUSANDS_SEP == "," and DECIMAL_SEP == "."
def currency_fmt() -> str:
"""Return a Streamlit NumberColumn ``format`` string for currency.
For standard locales returns e.g. ``"$%,.0f"`` (thousands-separated,
no decimal places). For non-standard locales returns ``"%s"`` and
callers should use :func:`fmt_currency` to pre-format the value.
"""
if _STANDARD_LOCALE:
return f"{CURRENCY_SYMBOL}%,.0f"
return "%s"
def pct_fmt() -> str:
"""Return a Streamlit NumberColumn ``format`` string for percentages.
Stores the value as a fraction (01) and displays as e.g. ``"20.00%"``.
Streamlit's ``%%`` in format strings renders a literal ``%``.
"""
if _STANDARD_LOCALE:
return "%.2f%%"
return "%s"
def fmt_currency(value: float) -> str:
"""Format *value* as a currency string using the configured locale."""
if _STANDARD_LOCALE:
return f"{CURRENCY_SYMBOL}{value:,.0f}"
# Non-standard: build manually
integer_part = f"{int(abs(value)):,}".replace(",", THOUSANDS_SEP)
sign = "-" if value < 0 else ""
return f"{sign}{CURRENCY_SYMBOL}{integer_part}"
def fmt_pct(value: float) -> str:
"""Format *value* (01 fraction) as a percentage string."""
pct = value * 100
if _STANDARD_LOCALE:
return f"{pct:.2f}%"
integer_part = f"{int(pct)}"
decimal_part = f"{abs(pct) % 1:.2f}"[1:] # ".xx"
return f"{integer_part}{DECIMAL_SEP}{decimal_part[1:]}%"

View File

@@ -24,6 +24,7 @@ if str(_ROOT) not in sys.path:
import streamlit as st
from core.tei_client import AthenaAPIError, TEIClient
from app.utils import icon, inject_icons
st.set_page_config(
page_title="Palladium — TEI Calculator",
@@ -32,6 +33,7 @@ st.set_page_config(
)
@st.cache_resource(show_spinner=False)
def get_client() -> TEIClient:
return TEIClient()
@@ -75,7 +77,9 @@ def _crm_engagements(_client: TEIClient, client_name: str) -> list[dict]:
def sidebar_tool_picker(client: TEIClient) -> dict | None:
"""Sidebar: pick an existing TEI tool or create one from a report template."""
st.sidebar.title("🛡️ Palladium")
st.sidebar.markdown(
f"{icon('shield-fill')} **Palladium**", unsafe_allow_html=True
)
st.sidebar.caption("TEI Calculator")
tools = _safe_call(client.list_tools) or []
@@ -163,15 +167,20 @@ def sidebar_tool_picker(client: TEIClient) -> dict | None:
st.sidebar.markdown(f"**Public ID**: `{tool.get('id')}`")
st.sidebar.markdown(f"**Status**: {tool.get('status', '?')}")
st.sidebar.markdown(f"**Version**: {tool.get('current_version', 0)}")
if st.sidebar.button("🔄 Recalculate"):
if st.sidebar.button("Recalculate"):
_safe_call(client.calculate, tool["id"])
st.toast("Recalculated.", icon="")
st.toast("Recalculated.", icon=None)
st.cache_data.clear()
return tool
def main() -> None:
st.title("Palladium — TEI Calculator")
inject_icons()
st.markdown(
f"<h1 style='margin-bottom:0'>{icon('shield-fill')} Palladium — TEI Calculator</h1>",
unsafe_allow_html=True,
)
try:
client = get_client()
except ValueError as e:
@@ -186,14 +195,19 @@ def main() -> None:
st.info("Pick or create a TEI tool from the sidebar to begin.")
return
# Tab navigation — matches `app/pages/*` modules but kept as tabs so all
# Tab navigation — matches `app/views/*` modules but kept as tabs so all
# views share the chosen tool/state without re-querying.
tabs = st.tabs(["📊 Summary", "💰 Benefits", "💸 Costs", "🕒 Versions"])
#
# NOTE: the directory is `app/views/`, NOT `app/pages/`. Streamlit treats a
# `pages/` directory next to the entrypoint as auto-discovered multipage
# scripts, which would render blank since these modules only define
# `render()` and have no top-level output.
tabs = st.tabs(["Summary", "Benefits", "Costs", "Versions"])
from app.pages import benefits as benefits_page
from app.pages import costs as costs_page
from app.pages import summary as summary_page
from app.pages import versions as versions_page
from app.views import benefits as benefits_page
from app.views import costs as costs_page
from app.views import summary as summary_page
from app.views import versions as versions_page
with tabs[0]:
summary_page.render(client, tool)

45
app/utils.py Normal file
View File

@@ -0,0 +1,45 @@
"""
Shared UI utilities for the Palladium Streamlit app.
Kept in a separate module so that ``app.main`` and ``app.views.*`` can both
import from here without creating a circular dependency.
"""
from __future__ import annotations
import streamlit as st
# ---------------------------------------------------------------------------
# Bootstrap Icons — injected once at the top of every page render.
# Using the CDN stylesheet so no npm/build step is needed.
# ---------------------------------------------------------------------------
_BI_CSS = """
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css"
/>
<style>
/* Tighten up the default Streamlit header spacing */
.block-container { padding-top: 1.5rem; }
/* Make BI icons align nicely with surrounding text */
.bi { vertical-align: -0.125em; }
</style>
"""
def inject_icons() -> None:
"""Inject Bootstrap Icons CSS (idempotent — Streamlit deduplicates identical HTML)."""
st.markdown(_BI_CSS, unsafe_allow_html=True)
def icon(name: str, *, cls: str = "") -> str:
"""Return an inline Bootstrap Icon ``<i>`` tag.
Usage::
st.markdown(icon("bar-chart") + " Financial Summary", unsafe_allow_html=True)
See the full icon catalogue at https://icons.getbootstrap.com/
"""
extra = f" {cls}" if cls else ""
return f'<i class="bi bi-{name}{extra}"></i>'

View File

@@ -5,12 +5,17 @@ from __future__ import annotations
import streamlit as st
from app.components.tables import df_to_values, value_editor
from app.pages._helpers import report_meta, safe
from app.utils import icon
from app.views._helpers import report_meta, safe
from core.tei_client import TEIClient
def render(client: TEIClient, tool: dict) -> None:
st.header("💰 Benefits")
st.markdown(
f"<h2>{icon('graph-up-arrow')} Benefits</h2>",
unsafe_allow_html=True,
)
public_id = tool["id"]
report = report_meta(client, tool)
analysis_years = int(report.get("analysis_period_years") or 3)
@@ -32,7 +37,8 @@ def render(client: TEIClient, tool: dict) -> None:
col1, col2 = st.columns([1, 4])
with col1:
if st.button("💾 Save benefits", use_container_width=True):
if st.button("Save benefits", width="stretch"):
payload = df_to_values(edited, "benefits", analysis_years)
result = safe(client.update_values, public_id, payload)
if result is not None:

View File

@@ -5,12 +5,17 @@ from __future__ import annotations
import streamlit as st
from app.components.tables import df_to_values, value_editor
from app.pages._helpers import report_meta, safe
from app.utils import icon
from app.views._helpers import report_meta, safe
from core.tei_client import TEIClient
def render(client: TEIClient, tool: dict) -> None:
st.header("💸 Costs")
st.markdown(
f"<h2>{icon('receipt')} Costs</h2>",
unsafe_allow_html=True,
)
public_id = tool["id"]
report = report_meta(client, tool)
analysis_years = int(report.get("analysis_period_years") or 3)
@@ -32,7 +37,8 @@ def render(client: TEIClient, tool: dict) -> None:
col1, col2 = st.columns([1, 4])
with col1:
if st.button("💾 Save costs", use_container_width=True):
if st.button("Save costs", width="stretch"):
payload = df_to_values(edited, "costs", analysis_years)
result = safe(client.update_values, public_id, payload)
if result is not None:

View File

@@ -5,13 +5,19 @@ from __future__ import annotations
import streamlit as st
from app.components import charts
from app.pages._helpers import report_meta, safe
from app.locale import CURRENCY_SYMBOL, currency_fmt, fmt_currency
from app.utils import icon
from app.views._helpers import report_meta, safe
from core.export import build_report_data
from core.tei_client import AthenaAPIError, TEIClient
def render(client: TEIClient, tool: dict) -> None:
st.header("📊 Financial Summary")
st.markdown(
f"<h2>{icon('bar-chart-line')} Financial Summary</h2>",
unsafe_allow_html=True,
)
public_id = tool["id"]
report = report_meta(client, tool)
@@ -39,14 +45,14 @@ def render(client: TEIClient, tool: dict) -> None:
cpv = float(summary.get("total_costs_pv") or 0)
cols = st.columns(5)
cols[0].metric("NPV", f"${npv/1_000_000:,.1f}M")
cols[0].metric("NPV", f"{CURRENCY_SYMBOL}{npv/1_000_000:,.1f}M")
cols[1].metric("ROI", f"{roi:,.0f}%")
cols[2].metric(
"Payback",
f"{float(payback):.1f} months" if payback is not None else "N/A",
)
cols[3].metric("Benefits PV", f"${bpv/1_000_000:,.1f}M")
cols[4].metric("Costs PV", f"${cpv/1_000_000:,.1f}M")
cols[3].metric("Benefits PV", f"{CURRENCY_SYMBOL}{bpv/1_000_000:,.1f}M")
cols[4].metric("Costs PV", f"{CURRENCY_SYMBOL}{cpv/1_000_000:,.1f}M")
st.divider()
@@ -64,7 +70,18 @@ def render(client: TEIClient, tool: dict) -> None:
if yb:
charts.cashflow(yb, initial_cost=initial)
with st.expander("Cash flow table"):
st.dataframe(yb, use_container_width=True, hide_index=True)
_cur = currency_fmt()
st.dataframe(
yb,
column_config={
"year": st.column_config.NumberColumn("Year", format="%d"),
"benefits": st.column_config.NumberColumn("Benefits", format=_cur),
"costs": st.column_config.NumberColumn("Costs", format=_cur),
"net": st.column_config.NumberColumn("Net", format=_cur),
},
width="stretch",
hide_index=True,
)
else:
st.caption("No yearly breakdown in this summary.")
@@ -94,11 +111,26 @@ def render(client: TEIClient, tool: dict) -> None:
}
for k, v in envelope["scenarios"].items()
]
st.dataframe(rows, use_container_width=True, hide_index=True)
_cur = currency_fmt()
st.dataframe(
rows,
column_config={
"Scenario": st.column_config.TextColumn("Scenario"),
"Benefits PV": st.column_config.NumberColumn("Benefits PV", format=_cur),
"Costs PV": st.column_config.NumberColumn("Costs PV", format=_cur),
"NPV": st.column_config.NumberColumn("NPV", format=_cur),
"ROI %": st.column_config.NumberColumn("ROI %", format="%.1f%%"),
"Payback (months)": st.column_config.NumberColumn(
"Payback (months)", format="%.1f"
),
},
width="stretch",
hide_index=True,
)
# Export button
st.divider()
if st.button("📦 Build export envelope (JSON)"):
if st.button("Build export envelope (JSON)"):
envelope = safe(
build_report_data,
client,

View File

@@ -4,7 +4,9 @@ from __future__ import annotations
import streamlit as st
from app.pages._helpers import safe
from app.utils import icon
from app.views._helpers import safe
from core.tei_client import TEIClient
@@ -17,6 +19,7 @@ def _diff_rows(a: dict[str, dict], b: dict[str, dict]) -> list[dict]:
"""Return one row per field with side-by-side year values."""
keys = sorted(set(a.keys()) | set(b.keys()))
rows: list[dict] = []
def _years_of(v: dict) -> dict:
"""Accept both friendly (year_values) and wire (nested years) shapes."""
if isinstance(v.get("year_values"), dict):
@@ -54,7 +57,10 @@ def _diff_rows(a: dict[str, dict], b: dict[str, dict]) -> list[dict]:
def render(client: TEIClient, tool: dict) -> None:
st.header("🕒 Versions")
st.markdown(
f"<h2>{icon('clock-history')} Versions</h2>",
unsafe_allow_html=True,
)
public_id = tool["id"]
versions = safe(client.list_versions, public_id) or []
@@ -63,7 +69,7 @@ def render(client: TEIClient, tool: dict) -> None:
)
# Save new version
with st.expander(" Save current state as a new version", expanded=not versions):
with st.expander("Save current state as a new version", expanded=not versions):
note = st.text_area(
"Version note",
placeholder=(
@@ -71,7 +77,7 @@ def render(client: TEIClient, tool: dict) -> None:
"raised legacy license cost from $160 to $180/agent.'"
),
)
if st.button("💾 Save version", disabled=not note.strip()):
if st.button("Save version", disabled=not note.strip()):
result = safe(client.save_version, public_id, note.strip())
if result:
st.success(
@@ -102,7 +108,8 @@ def render(client: TEIClient, tool: dict) -> None:
"Note": v.get("note", ""),
}
)
st.dataframe(rows, use_container_width=True, hide_index=True)
st.dataframe(rows, width="stretch", hide_index=True)
# Compare two versions
st.subheader("Compare")
@@ -132,4 +139,5 @@ def render(client: TEIClient, tool: dict) -> None:
if not diff:
st.success("No value differences between these versions.")
else:
st.dataframe(diff, use_container_width=True, hide_index=True)
st.dataframe(diff, width="stretch", hide_index=True)