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

206
tests/test_calculations.py Normal file
View File

@@ -0,0 +1,206 @@
"""
Tests for core.calculations — reproduces the Forrester Amazon Connect TEI
totals from the published study within rounding.
The PDF reports (Cash Flow Analysis, p.25):
Benefits PV (RA) = $101,696,791
Costs PV (RA) = $ 22,983,076
NPV = $ 78,713,715
ROI = 342%
Payback = <6 months
"""
from __future__ import annotations
import pytest
from core.calculations import (
SCENARIOS,
apply_scenario,
discount_factor,
npv,
payback_months,
payback_years,
present_value,
present_value_series,
risk_adjust_benefit,
risk_adjust_cost,
roi,
roi_percentage,
)
# ─────────────────────────────────────────────
# Building blocks
# ─────────────────────────────────────────────
class TestDiscounting:
def test_discount_factor_year_zero(self):
assert discount_factor(0, 0.10) == pytest.approx(1.0)
def test_discount_factor_known_value(self):
# 1/(1.10)^3
assert discount_factor(3, 0.10) == pytest.approx(0.7513148, rel=1e-5)
def test_negative_year_raises(self):
with pytest.raises(ValueError):
discount_factor(-1, 0.10)
def test_present_value_year_one(self):
assert present_value(110, 1, 0.10) == pytest.approx(100.0)
def test_present_value_series_three_years(self):
# 100 each year for 3 years at 10% → ≈ 248.685
assert present_value_series([100, 100, 100], 0.10) == pytest.approx(
248.685, rel=1e-3
)
class TestNPV:
def test_zero_initial(self):
assert npv([100, 100], 0.0) == pytest.approx(200.0)
def test_with_initial(self):
# 1000 invested up-front, 600 returned each of 2 years at 10%
result = npv([600, 600], 0.10, initial=-1000)
# PV of returns ≈ 545.45 + 495.87 = 1041.32, NPV ≈ 41.32
assert result == pytest.approx(41.32, abs=0.5)
class TestRiskAdjustment:
def test_benefit_zero_risk(self):
assert risk_adjust_benefit(100, 0.0) == 100
def test_benefit_15pct(self):
assert risk_adjust_benefit(100, 0.15) == pytest.approx(85.0)
def test_cost_5pct_upward(self):
assert risk_adjust_cost(100, 0.05) == pytest.approx(105.0)
def test_clamping(self):
assert risk_adjust_benefit(100, 1.5) == 0 # clamped to 1.0
assert risk_adjust_benefit(100, -0.5) == 100 # clamped to 0
class TestROI:
def test_zero_costs_returns_zero(self):
assert roi(100, 0) == 0.0
def test_known(self):
assert roi_percentage(101_696_791, 22_983_076) == pytest.approx(342, abs=1)
class TestPayback:
def test_immediate(self):
assert payback_years(0, [100]) == 0.0
def test_amazon_connect_under_six_months(self):
# Initial $1.196M, Y1 net ~$20M → quick crossing
years = payback_years(1_196_250, [19_997_953, 31_562_489, 47_443_905])
assert years is not None
assert payback_months(1_196_250, [19_997_953, 31_562_489, 47_443_905]) < 6
def test_never_recovered(self):
assert payback_years(1000, [100, 100, 100]) is None
class TestScenarios:
def test_default_scenarios_present(self):
assert set(SCENARIOS) == {"conservative", "moderate", "aggressive"}
def test_moderate_is_passthrough(self):
items = [
{
"table": "benefits",
"field_key": "x",
"year_values": {"1": 1000, "2": 2000},
"risk_adjustment": 0.15,
}
]
out = apply_scenario(items, "moderate")
assert out[0]["year_values"] == {"1": 1000.0, "2": 2000.0}
assert out[0]["risk_adjustment"] == pytest.approx(0.15)
def test_conservative_lowers_benefits(self):
items = [
{
"table": "benefits",
"field_key": "x",
"year_values": {"1": 1000},
"risk_adjustment": 0.15,
}
]
out = apply_scenario(items, "conservative")
assert out[0]["year_values"]["1"] == pytest.approx(800.0)
# 0.15 + 0.10 = 0.25
assert out[0]["risk_adjustment"] == pytest.approx(0.25)
def test_aggressive_increases_benefits(self):
items = [
{
"table": "benefits",
"field_key": "x",
"year_values": {"1": 1000},
"risk_adjustment": 0.15,
}
]
out = apply_scenario(items, "aggressive")
assert out[0]["year_values"]["1"] == pytest.approx(1150.0)
assert out[0]["risk_adjustment"] == pytest.approx(0.10)
def test_unknown_scenario_raises(self):
with pytest.raises(KeyError):
apply_scenario([], "purple")
# ─────────────────────────────────────────────
# End-to-end: reproduce the PDF totals
# ─────────────────────────────────────────────
class TestAmazonConnectComposite:
"""Reproduce the Forrester Amazon Connect TEI numbers within rounding."""
DISCOUNT_RATE = 0.10
EXPECTED_BENEFITS_PV = 101_696_791
EXPECTED_COSTS_PV = 22_983_076
EXPECTED_NPV = 78_713_715
EXPECTED_ROI = 342 # percent
TOLERANCE = 1_500 # dollars; PDF rounding at thousands
def _benefits_pv(self, seed) -> float:
total = 0.0
for b in seed.BENEFITS:
rf = b["risk_adjustment"]
yr = [b["year_values"][str(y)] for y in (1, 2, 3)]
yr_ra = [risk_adjust_benefit(v, rf) for v in yr]
total += npv(yr_ra, self.DISCOUNT_RATE)
return total
def _costs_pv(self, seed) -> float:
total = 0.0
for c in seed.COSTS:
rf = c["risk_adjustment"]
init = risk_adjust_cost(c.get("initial") or 0, rf)
yr = [c["year_values"][str(y)] for y in (1, 2, 3)]
yr_ra = [risk_adjust_cost(v, rf) for v in yr]
total += npv(yr_ra, self.DISCOUNT_RATE, initial=init)
return total
def test_benefits_pv(self, amazon_connect_seed):
result = self._benefits_pv(amazon_connect_seed)
assert result == pytest.approx(self.EXPECTED_BENEFITS_PV, abs=self.TOLERANCE)
def test_costs_pv(self, amazon_connect_seed):
result = self._costs_pv(amazon_connect_seed)
assert result == pytest.approx(self.EXPECTED_COSTS_PV, abs=self.TOLERANCE)
def test_npv(self, amazon_connect_seed):
b = self._benefits_pv(amazon_connect_seed)
c = self._costs_pv(amazon_connect_seed)
assert (b - c) == pytest.approx(self.EXPECTED_NPV, abs=self.TOLERANCE)
def test_roi(self, amazon_connect_seed):
b = self._benefits_pv(amazon_connect_seed)
c = self._costs_pv(amazon_connect_seed)
assert roi_percentage(b, c) == pytest.approx(self.EXPECTED_ROI, abs=1)