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

0
tests/__init__.py Normal file
View File

32
tests/conftest.py Normal file
View File

@@ -0,0 +1,32 @@
"""Pytest fixtures for Palladium tests."""
from __future__ import annotations
import os
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parent.parent
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
@pytest.fixture(autouse=True)
def _env(monkeypatch):
"""Default test env vars so TEIClient() doesn't need a real .env."""
monkeypatch.setenv("ATHENA_BASE_URL", "https://athena.test")
monkeypatch.setenv("ATHENA_API_KEY", "test-key")
@pytest.fixture
def amazon_connect_seed():
"""Load the Amazon Connect study's seed data."""
sys.path.insert(0, str(ROOT / "studies" / "202602_AmazonConnect"))
try:
import seed_data # type: ignore[import-not-found]
return seed_data
finally:
# Leave the path alone — many tests will use the seed
pass

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)

174
tests/test_client.py Normal file
View File

@@ -0,0 +1,174 @@
"""
TEI client tests with mocked HTTP.
We mock ``requests.Session.request`` so tests do not require network access
or a live Athena instance.
"""
from __future__ import annotations
import json
from unittest.mock import MagicMock
import pytest
from core.tei_client import AthenaAPIError, TEIClient
def _mock_response(status: int, body=None) -> MagicMock:
resp = MagicMock()
resp.status_code = status
resp.content = b"{}" if body is None else json.dumps(body).encode()
resp.json.return_value = body if body is not None else {}
resp.text = json.dumps(body or {})
return resp
@pytest.fixture
def client(monkeypatch) -> TEIClient:
c = TEIClient()
c.session = MagicMock()
return c
class TestConfig:
def test_requires_base_url(self, monkeypatch):
monkeypatch.delenv("ATHENA_BASE_URL", raising=False)
with pytest.raises(ValueError, match="ATHENA_BASE_URL"):
TEIClient(api_key="x")
def test_requires_api_key(self, monkeypatch):
monkeypatch.delenv("ATHENA_API_KEY", raising=False)
with pytest.raises(ValueError, match="ATHENA_API_KEY"):
TEIClient(base_url="https://example.com")
def test_authorization_header(self):
c = TEIClient(base_url="https://example.com", api_key="abc123")
assert c.session.headers["Authorization"] == "Api-Key abc123"
class TestPaths:
"""Verify each endpoint targets the documented URL."""
def _last_call_url(self, client: TEIClient) -> str:
return client.session.request.call_args.kwargs["url"]
def test_list_reports_path(self, client):
client.session.request.return_value = _mock_response(
200, {"results": [], "next": None}
)
client.list_reports()
assert self._last_call_url(client) == "https://athena.test/api/v1/tei/reports/"
def test_get_tool_path(self, client):
client.session.request.return_value = _mock_response(200, {"id": "abc"})
client.get_tool("abc123")
assert self._last_call_url(client).endswith("/api/v1/tei/tools/abc123/")
def test_calculate_path(self, client):
client.session.request.return_value = _mock_response(200, {})
client.calculate("abc")
assert self._last_call_url(client).endswith("/api/v1/tei/tools/abc/calculate/")
assert client.session.request.call_args.kwargs["method"] == "POST"
def test_export_path(self, client):
client.session.request.return_value = _mock_response(200, {})
client.export("abc")
assert self._last_call_url(client).endswith("/api/v1/tei/tools/abc/export/")
def test_aggregate_summary_path(self, client):
client.session.request.return_value = _mock_response(200, {})
client.aggregate_summary()
assert self._last_call_url(client).endswith("/api/v1/tei/summary/")
def test_save_version_path(self, client):
client.session.request.return_value = _mock_response(201, {"version_number": 1})
client.save_version("abc", note="initial")
url = self._last_call_url(client)
assert url.endswith("/api/v1/tei/tools/abc/versions/")
body = client.session.request.call_args.kwargs["json"]
assert body == {"note": "initial"}
class TestErrorHandling:
def test_404_raises_athena_error(self, client):
client.session.request.return_value = _mock_response(
404, {"detail": "Not found"}
)
with pytest.raises(AthenaAPIError) as ei:
client.get_tool("missing")
assert ei.value.status_code == 404
assert "Not found" in ei.value.detail
def test_test_connection_returns_error_dict(self, client):
client.session.request.return_value = _mock_response(
401, {"detail": "Invalid token"}
)
result = client.test_connection()
assert result["status"] == "error"
assert result["authenticated"] is False
assert result["error_code"] == 401
class TestPagination:
def test_walks_next_links(self, client):
# First page returns one item with a `next` URL; second page returns
# one more item and no next.
page1 = _mock_response(
200,
{
"results": [{"id": 1}],
"next": "https://athena.test/api/v1/tei/reports/?page=2",
},
)
page2 = _mock_response(200, {"results": [{"id": 2}], "next": None})
client.session.request.return_value = page1
client.session.get.return_value = page2 # follow next via session.get
out = client.list_reports()
assert [r["id"] for r in out] == [1, 2]
class TestNormalizeValue:
def test_year_underscore_keys(self):
out = TEIClient._normalize_value(
{"field_key": "x", "year_1": 100, "year_2": 200, "risk_adjustment": 0.1}
)
assert out["year_values"] == {"1": 100.0, "2": 200.0}
assert out["risk_adjustment"] == 0.1
def test_year_values_dict_passthrough(self):
out = TEIClient._normalize_value(
{
"field_key": "x",
"year_values": {"1": 50, "3": 75},
"notes": " hi ",
}
)
assert out["year_values"] == {"1": 50.0, "3": 75.0}
assert out["notes"] == " hi "
def test_initial_carried(self):
out = TEIClient._normalize_value(
{"field_key": "x", "initial": 1000, "year_1": 5}
)
assert out["initial"] == 1000.0
def test_scalar_value(self):
out = TEIClient._normalize_value({"field_key": "rate", "value": 0.10})
assert out["value"] == 0.10
assert "year_values" not in out
class TestUpdateValuesPayload:
def test_wraps_in_envelope(self, client):
client.session.request.return_value = _mock_response(200, {})
client.update_values(
"abc",
[{"field_key": "x", "year_1": 100}, {"field_key": "y", "year_1": 200}],
)
body = client.session.request.call_args.kwargs["json"]
assert "values" in body
assert len(body["values"]) == 2
assert body["values"][0]["field_key"] == "x"
assert body["values"][0]["year_values"] == {"1": 100.0}

98
tests/test_export.py Normal file
View File

@@ -0,0 +1,98 @@
"""Tests for core.export.report_data — envelope shape and computed totals."""
from __future__ import annotations
from unittest.mock import MagicMock
import pytest
from core.export import build_report_data
from core.export.report_data import _compute_summary, _yearly_totals
class TestComputeSummary:
def test_amazon_connect_totals(self, amazon_connect_seed):
s = _compute_summary(
amazon_connect_seed.BENEFITS,
amazon_connect_seed.COSTS,
0.10,
3,
)
assert s["total_benefits_pv"] == pytest.approx(101_696_791, abs=1500)
assert s["total_costs_pv"] == pytest.approx(22_983_076, abs=1500)
assert s["npv"] == pytest.approx(78_713_715, abs=2000)
assert s["roi_pct"] == pytest.approx(342, abs=1)
assert s["payback_months"] is not None and s["payback_months"] < 6
def test_yearly_breakdown_three_rows(self, amazon_connect_seed):
s = _compute_summary(
amazon_connect_seed.BENEFITS, amazon_connect_seed.COSTS, 0.10, 3
)
assert len(s["yearly_breakdown"]) == 3
assert [r["year"] for r in s["yearly_breakdown"]] == [1, 2, 3]
class TestYearlyTotals:
def test_only_within_horizon(self):
items = [
{"year_values": {"1": 100, "2": 200, "3": 300, "4": 999}},
]
assert _yearly_totals(items, 3) == [100.0, 200.0, 300.0]
def test_skips_invalid_keys(self):
items = [{"year_values": {"1": 50, "abc": 999}}]
assert _yearly_totals(items, 2) == [50.0, 0.0]
class TestBuildReportData:
def _stub_client(self, seed):
c = MagicMock()
c.get_tool_with_data.return_value = {
"tool": {"id": "pid", "name": "T", "report": "rid", "proposal": 7},
"fields": [],
"values": seed.BENEFITS + seed.COSTS,
}
c.get_report.return_value = {
"id": "rid",
"name": "Amazon Connect",
"vendor": "AWS",
"version": "1.0",
"discount_rate": "0.10",
"analysis_period_years": 3,
}
c.export.return_value = {"echoed": True}
return c
def test_envelope_shape(self, amazon_connect_seed):
client = self._stub_client(amazon_connect_seed)
env = build_report_data(client, "pid", study_slug="202602_AmazonConnect")
assert set(env) >= {
"metadata",
"report",
"fields",
"values",
"summary",
"athena_export",
"scenarios",
}
assert env["metadata"]["study_slug"] == "202602_AmazonConnect"
assert env["metadata"]["proposal"] == 7
assert env["values"]["benefits"]
assert env["values"]["costs"]
def test_scenarios_have_three_keys(self, amazon_connect_seed):
client = self._stub_client(amazon_connect_seed)
env = build_report_data(client, "pid")
assert set(env["scenarios"]) == {"conservative", "moderate", "aggressive"}
def test_no_scenarios_flag(self, amazon_connect_seed):
client = self._stub_client(amazon_connect_seed)
env = build_report_data(client, "pid", include_scenarios=False)
assert "scenarios" not in env
def test_local_summary_matches_seed(self, amazon_connect_seed):
client = self._stub_client(amazon_connect_seed)
env = build_report_data(client, "pid", include_scenarios=False)
assert env["summary"]["total_benefits_pv"] == pytest.approx(
101_696_791, abs=1500
)