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,31 @@
"""Pure-python TEI financial math."""
from core.calculations.npv import (
discount_factor,
npv,
present_value,
present_value_series,
)
from core.calculations.payback import payback_months, payback_years
from core.calculations.roi import roi, roi_percentage
from core.calculations.scenarios import (
SCENARIOS,
apply_scenario,
risk_adjust_benefit,
risk_adjust_cost,
)
__all__ = [
"SCENARIOS",
"apply_scenario",
"discount_factor",
"npv",
"payback_months",
"payback_years",
"present_value",
"present_value_series",
"risk_adjust_benefit",
"risk_adjust_cost",
"roi",
"roi_percentage",
]

78
core/calculations/npv.py Normal file
View File

@@ -0,0 +1,78 @@
"""
Net Present Value and discounting.
Convention (matches the Forrester TEI methodology used in the Amazon Connect
study):
* The *Initial* investment (column "Initial" in TEI tables) is **not**
discounted — it occurs at time zero.
* Year-N cash flows are discounted at the end of the year:
``PV = CF_n / (1 + r) ** n`` for ``n = 1, 2, …``.
That matches the PDF note: *"The initial investment column contains costs
incurred at 'time 0' or at the beginning of Year 1 that are not discounted.
All other cash flows are discounted using the discount rate at the end of
the year."*
"""
from __future__ import annotations
from collections.abc import Iterable
def discount_factor(year: int, discount_rate: float) -> float:
"""Return ``1 / (1 + r) ** year``. Year 0 → 1.0 (no discount)."""
if year < 0:
raise ValueError("year must be >= 0")
return 1.0 / ((1.0 + discount_rate) ** year)
def present_value(amount: float, year: int, discount_rate: float) -> float:
"""Discount ``amount`` from end-of-year ``year`` to present."""
return amount * discount_factor(year, discount_rate)
def present_value_series(
cashflows: Iterable[float],
discount_rate: float,
start_year: int = 1,
) -> float:
"""
Sum the present value of a stream of year-end cashflows.
Args:
cashflows: iterable of year-N amounts (Y1, Y2, …).
discount_rate: e.g. 0.10 for 10%.
start_year: year of the first element. Default 1 (skip year-0).
"""
total = 0.0
for offset, cf in enumerate(cashflows):
total += present_value(cf, start_year + offset, discount_rate)
return total
def npv(
cashflows: Iterable[float],
discount_rate: float,
initial: float = 0.0,
) -> float:
"""
Net Present Value.
Args:
cashflows: year-end cashflows for Year 1, Year 2, …
discount_rate: e.g. 0.10
initial: undiscounted year-0 cashflow (negative for a cost,
positive for a one-off benefit).
Returns:
``initial + Σ CF_n / (1 + r)^n`` for ``n = 1..len(cashflows)``.
Example::
>>> # Amazon Connect total benefits PV ≈ $101.7M
>>> benefits = [27_279_019, 40_333_658, 57_983_794]
>>> round(npv(benefits, 0.10) / 1_000_000, 1)
101.7
"""
return initial + present_value_series(cashflows, discount_rate, start_year=1)

View File

@@ -0,0 +1,63 @@
"""
Payback period.
Linear interpolation within the crossing year: returns the moment when the
running net cashflow first turns non-negative.
Inputs are *risk-adjusted, undiscounted* cashflows by convention (TEI shows
"<6 months" payback for the Amazon Connect composite using the risk-adjusted
nominal cashflows from the Cash Flow Analysis table).
"""
from __future__ import annotations
from collections.abc import Iterable
def payback_years(
initial_cost: float,
yearly_net_benefits: Iterable[float],
) -> float | None:
"""
Years until cumulative net benefits cover the initial cost.
Args:
initial_cost: positive number — undiscounted year-0 outlay.
yearly_net_benefits: sequence of (benefits costs) per year, Y1+.
Returns:
Float number of years, or ``None`` if payback is never reached
within the supplied horizon.
Example::
>>> # Amazon Connect: initial cost $1.196M, Y1 net $19.998M
>>> # → ~0.06 years ≈ 0.7 months — well under 6 months.
>>> round(payback_years(1_196_250, [19_997_953, 31_562_489, 47_443_905]), 3)
0.06
"""
remaining = float(initial_cost)
if remaining <= 0:
return 0.0
cumulative_year = 0
for cf in yearly_net_benefits:
cumulative_year += 1
cf = float(cf)
if cf <= 0:
remaining += -cf # net loss this year increases the gap
continue
if cf >= remaining:
# Crossing happens partway through this year.
fraction = remaining / cf
return (cumulative_year - 1) + fraction
remaining -= cf
return None
def payback_months(
initial_cost: float,
yearly_net_benefits: Iterable[float],
) -> float | None:
"""Same as :func:`payback_years`, expressed in months."""
yrs = payback_years(initial_cost, yearly_net_benefits)
return yrs * 12.0 if yrs is not None else None

27
core/calculations/roi.py Normal file
View File

@@ -0,0 +1,27 @@
"""Return on Investment."""
from __future__ import annotations
def roi(benefits_pv: float, costs_pv: float) -> float:
"""
Return on Investment as a fraction.
``ROI = (Benefits Costs) / Costs``
Costs here are expressed as a positive present-value amount (the absolute
cost). Returns ``0.0`` when costs are zero (rather than dividing by zero).
Example::
>>> round(roi(101_696_791, 22_983_076), 2) # Amazon Connect: 342%
3.42
"""
if costs_pv <= 0:
return 0.0
return (benefits_pv - costs_pv) / costs_pv
def roi_percentage(benefits_pv: float, costs_pv: float) -> float:
"""ROI as a percentage (e.g. 342.0 for 342%)."""
return roi(benefits_pv, costs_pv) * 100.0

View File

@@ -0,0 +1,128 @@
"""
Scenario modelling and risk adjustment.
Forrester TEI applies a *downward* risk adjustment to benefits (subtract
``risk_factor × benefit``) and an *upward* adjustment to costs (add
``risk_factor × cost``). Scenarios scale both adoption (cashflow magnitude)
and uncertainty (risk factor).
Default scenario multipliers are sensible starting points. Override per
study by editing the study's ``config.py`` and passing a custom dict to
:func:`apply_scenario`.
"""
from __future__ import annotations
from collections.abc import Iterable
from copy import deepcopy
from typing import Any
#: Default scenario multipliers used by Palladium notebooks.
#:
#: * ``adoption`` scales nominal annual values up or down.
#: * ``risk_delta`` is *added* to the benefit's risk_adjustment factor and
#: *subtracted* from the cost's risk_adjustment factor (conservative
#: = more uncertainty on benefits, less padding on costs).
SCENARIOS: dict[str, dict[str, float]] = {
"conservative": {"adoption": 0.80, "risk_delta": 0.10},
"moderate": {"adoption": 1.00, "risk_delta": 0.00},
"aggressive": {"adoption": 1.15, "risk_delta": -0.05},
}
def risk_adjust_benefit(amount: float, risk_factor: float) -> float:
"""
Apply a downward risk adjustment to a benefit.
``adjusted = amount × (1 risk_factor)``.
``risk_factor`` is clamped to ``[0, 1]``.
"""
rf = max(0.0, min(1.0, float(risk_factor)))
return amount * (1.0 - rf)
def risk_adjust_cost(amount: float, risk_factor: float) -> float:
"""
Apply an upward risk adjustment to a cost.
``adjusted = amount × (1 + risk_factor)``.
``risk_factor`` is clamped to ``[0, 1]``.
"""
rf = max(0.0, min(1.0, float(risk_factor)))
return amount * (1.0 + rf)
def _scale_yearly(values: Iterable[float], factor: float) -> list[float]:
return [float(v) * factor for v in values]
def apply_scenario(
items: list[dict],
scenario: str = "moderate",
*,
multipliers: dict[str, dict[str, float]] | None = None,
table: str | None = None,
) -> list[dict]:
"""
Return a deep-copied list of value-rows with scenario adjustments applied.
Each item is expected to have:
- ``table`` (``'benefits'`` or ``'costs'``) — required for sign of
risk_delta. If absent, defaults to ``benefits`` unless ``table=``
is passed explicitly.
- ``year_values`` (dict of year-string → float) **or** a scalar
``value``.
- ``risk_adjustment`` (optional float).
- ``initial`` (optional, costs only) — scaled by adoption.
Args:
items: rows shaped like the ``_normalize_value`` output of
:class:`core.tei_client.TEIClient`.
scenario: key into ``multipliers`` (default ``SCENARIOS``).
multipliers: override map. Same shape as ``SCENARIOS``.
table: force a table when items lack one.
Returns:
A new list — original items are not mutated.
"""
cfg = (multipliers or SCENARIOS).get(scenario)
if cfg is None:
raise KeyError(f"Unknown scenario: {scenario!r}")
adoption = float(cfg.get("adoption", 1.0))
risk_delta = float(cfg.get("risk_delta", 0.0))
out: list[dict] = []
for raw in items:
item: dict[str, Any] = deepcopy(raw)
item_table = item.get("table") or table or "benefits"
item["table"] = item_table
# Adoption scaling
if "year_values" in item and isinstance(item["year_values"], dict):
item["year_values"] = {
k: float(v) * adoption for k, v in item["year_values"].items()
}
if "value" in item and item["value"] is not None:
try:
item["value"] = float(item["value"]) * adoption
except (TypeError, ValueError):
pass
if "initial" in item and item["initial"] is not None:
try:
item["initial"] = float(item["initial"]) * adoption
except (TypeError, ValueError):
pass
# Risk adjustment delta
ra = item.get("risk_adjustment")
if ra is None:
ra = 0.0
if item_table == "benefits":
new_ra = float(ra) + risk_delta
else: # costs: adverse scenario should *raise* costs less, so subtract
new_ra = float(ra) - risk_delta
item["risk_adjustment"] = max(0.0, min(1.0, new_ra))
out.append(item)
return out