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:
10
.env.example
Normal file
10
.env.example
Normal file
@@ -0,0 +1,10 @@
|
||||
# Athena API
|
||||
ATHENA_BASE_URL=https://athena.nttdata.com
|
||||
ATHENA_API_KEY=your-api-key-here
|
||||
|
||||
# Optional — pre-set the active study + tool so notebooks/CLI pick them up
|
||||
# without editing config.py.
|
||||
#
|
||||
# PALLADIUM_REPORT_PUBLIC_ID=
|
||||
# PALLADIUM_TOOL_PUBLIC_ID=
|
||||
# PALLADIUM_PROPOSAL_ID=
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -5,6 +5,11 @@
|
||||
.ipynb_checkpoints
|
||||
*/.ipynb_checkpoints/*
|
||||
|
||||
# Palladium-specific
|
||||
.env
|
||||
studies/*/exports/*
|
||||
!studies/*/exports/.gitkeep
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
288
README.md
288
README.md
@@ -2,45 +2,47 @@
|
||||
|
||||
**TEI (Total Economic Impact) Calculator** — The strategic artifact that protects the business case.
|
||||
|
||||
Palladium is a Jupyter notebook-based calculator and Streamlit application for building Total Economic Impact analyses. It connects to [Athena](https://athena.nttdata.com) for data persistence, performs financial calculations (NPV, ROI, payback period), and exports structured data for the report generation pipeline.
|
||||
Palladium is a Jupyter notebook + Streamlit toolkit for building Total Economic Impact analyses. It connects to [Athena](https://athena.nttdata.com) for data persistence, performs financial calculations (NPV, ROI, payback period), and exports structured data for the report generation pipeline.
|
||||
|
||||
> *In Greek mythology, the Palladium was a sacred artifact of Athena that protected Troy. Whoever possessed it held strategic advantage. In our ecosystem, Palladium protects the deal — transforming discovery inputs into a financial case no CFO can ignore.*
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Palladium │
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌───────────┐ │
|
||||
│ │ Notebooks │ │ Streamlit │ │ Export │ │
|
||||
│ │ (Analysis) │ │ (Data Entry)│ │ (Report) │ │
|
||||
│ └──────┬───────┘ └──────┬───────┘ └─────┬─────┘ │
|
||||
│ │ │ │ │
|
||||
│ └───────────┬───────┘ │ │
|
||||
│ ▼ │ │
|
||||
│ ┌──────────────────┐ │ │
|
||||
│ │ TEI Client │ │ │
|
||||
│ │ (API Layer) │──────────────────┘ │
|
||||
│ └────────┬─────────┘ │
|
||||
└────────────────────┼────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────┐ ┌──────────────────┐
|
||||
│ Athena │ │ Report Pipeline │
|
||||
│ (API) │ │ (html2docx) │
|
||||
└─────────────┘ └──────────────────┘
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ Palladium │
|
||||
│ │
|
||||
│ studies/202602_AmazonConnect/ ← one folder per TEI study │
|
||||
│ studies/YYYYMM_<Vendor>/ │
|
||||
│ ├─ notebooks/ ─┐ │
|
||||
│ ├─ seed_data.py │ │
|
||||
│ └─ config.py │ │
|
||||
│ ▼ │
|
||||
│ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ core/ │ ←─ │ app/ │ │
|
||||
│ │ shared logic │ │ Streamlit │ │
|
||||
│ └──────┬───────┘ └──────┬───────┘ │
|
||||
│ │ │ │
|
||||
│ ▼ ▼ │
|
||||
│ tei_client → ───────────────────► Athena API │
|
||||
│ calculations │
|
||||
│ export ──────────────────────────► export.json │
|
||||
│ notebook_helpers │
|
||||
│ cli │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Components
|
||||
|
||||
| Component | Purpose |
|
||||
|-----------|---------|
|
||||
| **TEI Client** | Python API client for Athena's TEI endpoints |
|
||||
| **Calculations** | Financial logic — NPV, ROI, payback, risk adjustment |
|
||||
| **Notebooks** | Interactive analysis — benefits, costs, business case |
|
||||
| **Streamlit App** | Data entry UI with version management |
|
||||
| **Export** | Structured JSON for the LLM report generation pipeline |
|
||||
| **`core/tei_client`** | Python API client for Athena's TEI endpoints |
|
||||
| **`core/calculations`** | Financial logic — NPV, ROI, payback, risk adjustment, scenarios |
|
||||
| **`core/export`** | Builds the structured JSON envelope consumed by the report pipeline |
|
||||
| **`core/notebook_helpers`** | Pandas tables, Plotly charts, IPython display widgets |
|
||||
| **`core/cli`** | `python -m palladium` command-line interface |
|
||||
| **`app/`** | Streamlit data-entry UI with version management — *study-agnostic* |
|
||||
| **`studies/`** | One folder per TEI engagement (notebooks, seed data, config, source PDF) |
|
||||
|
||||
---
|
||||
|
||||
@@ -82,26 +84,26 @@ ATHENA_API_KEY=your-api-key-here
|
||||
python -m palladium test
|
||||
```
|
||||
|
||||
Or in a notebook:
|
||||
Or in Python:
|
||||
|
||||
```python
|
||||
from tei_client import TEIClient
|
||||
from core.tei_client import TEIClient
|
||||
|
||||
client = TEIClient()
|
||||
result = client.test_connection()
|
||||
print(result) # {'status': 'ok', 'authenticated': True, ...}
|
||||
print(client.test_connection()) # {'status': 'ok', 'authenticated': True, ...}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
### Jupyter Notebooks
|
||||
### Run a study end-to-end
|
||||
|
||||
The primary workflow for TEI analysis:
|
||||
Each study lives in `studies/<slug>/`. The reference study is the
|
||||
February 2026 Forrester *Total Economic Impact™ Of Amazon Connect*:
|
||||
|
||||
```bash
|
||||
jupyter lab notebooks/
|
||||
jupyter lab studies/202602_AmazonConnect/notebooks/
|
||||
```
|
||||
|
||||
| Notebook | Purpose |
|
||||
@@ -109,11 +111,15 @@ jupyter lab notebooks/
|
||||
| `01_benefits.ipynb` | Quantify and risk-adjust benefit categories |
|
||||
| `02_costs.ipynb` | Document implementation and ongoing costs |
|
||||
| `03_business_case.ipynb` | Financial summary, scenario analysis, visualizations |
|
||||
| `04_export.ipynb` | Generate report-ready JSON for html2docx pipeline |
|
||||
| `04_export.ipynb` | Generate report-ready JSON for the html2docx pipeline |
|
||||
|
||||
### Streamlit Application
|
||||
The Amazon Connect notebooks reproduce the published study totals within
|
||||
rounding: **NPV $78.7M • ROI 342% • Payback <6 months**.
|
||||
|
||||
Interactive UI for data entry and version management:
|
||||
### Streamlit application (study-agnostic)
|
||||
|
||||
Interactive UI for data entry and version management. Works for any TEI
|
||||
study because field definitions come from Athena at runtime:
|
||||
|
||||
```bash
|
||||
streamlit run app/main.py
|
||||
@@ -125,21 +131,56 @@ streamlit run app/main.py
|
||||
# Test connection
|
||||
python -m palladium test
|
||||
|
||||
# List TEI instances
|
||||
# List TEI tool instances
|
||||
python -m palladium list
|
||||
|
||||
# Show financial summary
|
||||
# List available report templates
|
||||
python -m palladium reports
|
||||
|
||||
# Show financial summary for a tool
|
||||
python -m palladium summary <public_id>
|
||||
|
||||
# Export for report pipeline
|
||||
# Trigger server-side recalculation
|
||||
python -m palladium calculate <public_id>
|
||||
|
||||
# Export for the report pipeline
|
||||
python -m palladium export <public_id> -o export.json
|
||||
```
|
||||
|
||||
### Tests
|
||||
|
||||
```bash
|
||||
pytest tests/ -v
|
||||
```
|
||||
|
||||
50 tests cover the API client (mocked HTTP), the financial math, and the
|
||||
export envelope shape. The Amazon Connect seed data is asserted against
|
||||
the published Forrester totals.
|
||||
|
||||
---
|
||||
|
||||
## Adding a new study
|
||||
|
||||
```bash
|
||||
cp -r studies/202602_AmazonConnect studies/202612_GenesysCloud
|
||||
cd studies/202612_GenesysCloud
|
||||
```
|
||||
|
||||
1. **`README.md`** — update the title, source citation, key numbers.
|
||||
2. **`seed_data.py`** — replace `BENEFITS` and `COSTS` with the new study's rows.
|
||||
3. **`config.py`** — set `STUDY_SLUG`, leave `TOOL_PUBLIC_ID` blank until provisioned.
|
||||
4. **`docs/`** — drop the source PDF here.
|
||||
5. Open the notebooks; the imports (`core.calculations`, `core.notebook_helpers`,
|
||||
`core.tei_client`) are study-agnostic. Update the markdown narrative.
|
||||
|
||||
The shared `core/` package and the `app/` Streamlit UI need no changes —
|
||||
they introspect the TEI Report template via the API.
|
||||
|
||||
---
|
||||
|
||||
## TEI Methodology
|
||||
|
||||
Palladium implements the Forrester TEI™ framework [1]:
|
||||
Palladium implements the Forrester TEI™ framework.
|
||||
|
||||
### Benefit Categories
|
||||
|
||||
@@ -154,25 +195,32 @@ Benefits are quantified across categories, risk-adjusted, and discounted to pres
|
||||
|
||||
### Risk Adjustment
|
||||
|
||||
Each benefit carries a risk adjustment factor (0–50%) reflecting implementation uncertainty. A 20% risk adjustment on a $10M benefit yields a risk-adjusted value of $8M.
|
||||
Each benefit carries a risk-adjustment factor (0–50%) reflecting implementation uncertainty.
|
||||
A 20% risk adjustment on a $10M benefit yields a risk-adjusted value of $8M.
|
||||
**Costs** are risk-adjusted **upward** by the same factor (higher risk → higher modelled cost).
|
||||
|
||||
### Financial Metrics
|
||||
|
||||
| Metric | Description |
|
||||
|--------|-------------|
|
||||
| **NPV** | Net Present Value — total risk-adjusted benefits minus costs, discounted |
|
||||
| **ROI** | Return on Investment — (benefits - costs) / costs × 100 |
|
||||
| **ROI** | Return on Investment — `(benefits − costs) / costs × 100` |
|
||||
| **Payback** | Months until cumulative benefits exceed cumulative costs |
|
||||
|
||||
The initial investment (year 0) is **not** discounted. Year-N cashflows are
|
||||
discounted at the end of the year: `PV = CF_n / (1 + r)^n`. This matches
|
||||
the Forrester methodology used in the published studies.
|
||||
|
||||
### Scenario Analysis
|
||||
|
||||
Three scenarios model uncertainty in adoption and realization:
|
||||
Three scenarios model uncertainty in adoption and realization
|
||||
(see `core.calculations.SCENARIOS`):
|
||||
|
||||
| Scenario | Approach |
|
||||
|----------|----------|
|
||||
| Conservative | Higher risk adjustments, lower adoption rates |
|
||||
| Moderate | Balanced assumptions (base case) |
|
||||
| Aggressive | Lower risk adjustments, faster adoption |
|
||||
| Scenario | Adoption | Risk delta | Effect |
|
||||
|----------|----------|------------|--------|
|
||||
| Conservative | 80% | +10pp on benefits | Lower benefits, higher modelled cost |
|
||||
| Moderate | 100% | 0 | Base case (= published study) |
|
||||
| Aggressive | 115% | –5pp on benefits | Higher benefits, lower padding on cost |
|
||||
|
||||
---
|
||||
|
||||
@@ -180,40 +228,48 @@ Three scenarios model uncertainty in adoption and realization:
|
||||
|
||||
```
|
||||
palladium/
|
||||
├── app/ # Streamlit application
|
||||
│ ├── main.py # App entry point
|
||||
│ ├── pages/
|
||||
│ │ ├── benefits.py # Benefits data entry
|
||||
│ │ ├── costs.py # Costs data entry
|
||||
│ │ ├── summary.py # Financial summary dashboard
|
||||
│ │ └── versions.py # Version history & comparison
|
||||
│ └── components/
|
||||
│ ├── charts.py # Visualization components
|
||||
│ └── tables.py # Data table components
|
||||
├── notebooks/ # Jupyter analysis notebooks
|
||||
│ ├── 01_benefits.ipynb
|
||||
│ ├── 02_costs.ipynb
|
||||
│ ├── 03_business_case.ipynb
|
||||
│ └── 04_export.ipynb
|
||||
├── tei_client/ # Athena API client
|
||||
│ ├── __init__.py
|
||||
│ ├── client.py # HTTP client with auth
|
||||
│ └── models.py # Response data models
|
||||
├── calculations/ # Financial calculation engine
|
||||
│ ├── __init__.py
|
||||
│ ├── npv.py # Net present value
|
||||
│ ├── roi.py # Return on investment
|
||||
│ ├── payback.py # Payback period
|
||||
│ └── scenarios.py # Scenario multipliers
|
||||
├── export/ # Report pipeline export
|
||||
│ ├── __init__.py
|
||||
│ └── report_data.py # JSON export for html2docx
|
||||
├── tests/
|
||||
├── core/ # Shared, study-agnostic Python package
|
||||
│ ├── tei_client/ # Athena API client
|
||||
│ │ ├── client.py # TEIClient with all /api/v1/tei/ methods
|
||||
│ │ └── models.py # Optional dataclasses for typed access
|
||||
│ ├── calculations/ # Pure-python financial math
|
||||
│ │ ├── npv.py
|
||||
│ │ ├── roi.py
|
||||
│ │ ├── payback.py
|
||||
│ │ └── scenarios.py
|
||||
│ ├── export/
|
||||
│ │ └── report_data.py # JSON envelope for the report pipeline
|
||||
│ ├── notebook_helpers/
|
||||
│ │ ├── tables.py # Pandas dataframe builders
|
||||
│ │ ├── charts.py # Plotly figures
|
||||
│ │ └── display.py # IPython KPI cards, alerts
|
||||
│ └── cli/
|
||||
│ └── main.py # `python -m palladium ...`
|
||||
├── palladium/ # CLI shim (just exposes `python -m palladium`)
|
||||
│ └── __main__.py
|
||||
├── app/ # Streamlit UI — works with any TEI study
|
||||
│ ├── main.py # entry point
|
||||
│ ├── pages/ # benefits, costs, summary, versions
|
||||
│ └── components/ # tables, charts
|
||||
├── studies/ # One folder per TEI engagement
|
||||
│ └── 202602_AmazonConnect/
|
||||
│ ├── README.md
|
||||
│ ├── config.py # TOOL_PUBLIC_ID, REPORT_PUBLIC_ID
|
||||
│ ├── seed_data.py # 5 benefits + 3 costs from the PDF
|
||||
│ ├── notebooks/
|
||||
│ │ ├── 01_benefits.ipynb
|
||||
│ │ ├── 02_costs.ipynb
|
||||
│ │ ├── 03_business_case.ipynb
|
||||
│ │ └── 04_export.ipynb
|
||||
│ ├── exports/ # generated; .gitignored
|
||||
│ └── docs/
|
||||
│ └── 202602_TEI Report Amazon Connect.pdf
|
||||
├── tests/ # 50 tests for core/
|
||||
│ ├── test_client.py
|
||||
│ ├── test_calculations.py
|
||||
│ └── test_export.py
|
||||
├── Athena API.yaml # OpenAPI reference
|
||||
├── .env.example
|
||||
├── .gitignore
|
||||
├── requirements.txt
|
||||
├── pyproject.toml
|
||||
└── README.md
|
||||
@@ -227,18 +283,36 @@ Palladium connects to Athena's TEI module for data persistence and cross-tool re
|
||||
|
||||
### API Endpoints Used
|
||||
|
||||
All endpoints are under `/api/v1/tei/` and require `Authorization: Api-Key {key}`.
|
||||
|
||||
| Endpoint | Purpose |
|
||||
|----------|---------|
|
||||
| `GET /forge/api/tei/reports/` | List available TEI model templates |
|
||||
| `GET /forge/api/tei/reports/{id}/fields/` | Get field definitions for a model |
|
||||
| `POST /forge/api/tei/tools/` | Create new TEI instance |
|
||||
| `GET /forge/api/tei/tools/{public_id}/` | Get instance metadata |
|
||||
| `GET /forge/api/tei/tools/{public_id}/values/` | Get current field values |
|
||||
| `PUT /forge/api/tei/tools/{public_id}/values/` | Bulk update values |
|
||||
| `POST /forge/api/tei/tools/{public_id}/calculate/` | Trigger calculation |
|
||||
| `GET /forge/api/tei/tools/{public_id}/summary/` | Get financial summary |
|
||||
| `POST /forge/api/tei/tools/{public_id}/versions/` | Save version snapshot |
|
||||
| `GET /forge/api/tei/tools/{public_id}/export/` | Export for report pipeline |
|
||||
| `GET /api/v1/tei/reports/` | List available TEI report templates |
|
||||
| `GET /api/v1/tei/reports/{public_id}/` | Get a report template |
|
||||
| `GET /api/v1/tei/reports/{public_id}/fields/` | Get field definitions for a template |
|
||||
| `POST /api/v1/tei/tools/` | Create a new TEI tool instance |
|
||||
| `GET /api/v1/tei/tools/{public_id}/` | Get instance metadata |
|
||||
| `PATCH /api/v1/tei/tools/{public_id}/` | Update name/status |
|
||||
| `GET /api/v1/tei/tools/{public_id}/values/` | Get current field values |
|
||||
| `PUT /api/v1/tei/tools/{public_id}/values/` | Bulk-update values |
|
||||
| `PATCH /api/v1/tei/tools/{public_id}/values/{field_key}/` | Patch a single value |
|
||||
| `POST /api/v1/tei/tools/{public_id}/calculate/` | Trigger calculation |
|
||||
| `GET /api/v1/tei/tools/{public_id}/summary/` | Get financial summary |
|
||||
| `GET /api/v1/tei/tools/{public_id}/versions/` | List version snapshots |
|
||||
| `POST /api/v1/tei/tools/{public_id}/versions/` | Save a new version |
|
||||
| `GET /api/v1/tei/tools/{public_id}/versions/{n}/` | Get a specific version |
|
||||
| `GET /api/v1/tei/tools/{public_id}/export/` | Export for the report pipeline |
|
||||
| `GET /api/v1/tei/summary/` | Aggregate NPV across all tools |
|
||||
|
||||
### Object model
|
||||
|
||||
| Athena object | Notes |
|
||||
|---|---|
|
||||
| **Opportunity** | Top-level sales record. Owns one or more **Proposals**. |
|
||||
| **Proposal** | A specific bid/offer to a client. **A TEI tool is linked to a Proposal.** |
|
||||
| **Engagement** | Optional — for active client engagements. A TEI tool may also link here. |
|
||||
| **TEIReport** | Template (e.g. *Amazon Connect 2026*) — defines fields, discount rate, analysis horizon. |
|
||||
| **TEITool** | Instance of a Report bound to a Proposal — holds values, summaries, versions. |
|
||||
|
||||
### Authentication
|
||||
|
||||
@@ -258,7 +332,7 @@ Palladium's export produces structured JSON consumed by the LLM report generatio
|
||||
Palladium Export (JSON)
|
||||
│
|
||||
▼
|
||||
LLM generates HTML (following HTML_DOCUMENT_FORMAT.md)
|
||||
Peitho — LLM generates HTML (following HTML_DOCUMENT_FORMAT.md)
|
||||
│
|
||||
▼
|
||||
html2docx converts to native Word
|
||||
@@ -267,23 +341,23 @@ html2docx converts to native Word
|
||||
Professional TEI Report (.docx)
|
||||
```
|
||||
|
||||
The export JSON includes:
|
||||
The export envelope (`core.export.build_report_data`) includes:
|
||||
- All benefit categories with risk-adjusted values
|
||||
- All cost categories with yearly breakdown
|
||||
- Financial summary (NPV, ROI, payback)
|
||||
- Yearly cash flow data (for waterfall/bar charts)
|
||||
- Scenario analysis results (conservative/moderate/aggressive)
|
||||
- Metadata (client, opportunity, analysis period, discount rate)
|
||||
- All cost categories with yearly breakdown (and Initial column)
|
||||
- Financial summary (NPV, ROI, payback, yearly cashflow)
|
||||
- Conservative / moderate / aggressive scenario analysis
|
||||
- Metadata (study slug, proposal, engagement, generator stamp)
|
||||
- The raw Athena `/export/` payload for reference
|
||||
|
||||
---
|
||||
|
||||
## Version Management
|
||||
|
||||
Palladium manages version history through the Streamlit UI:
|
||||
Palladium manages version history through both the API and the Streamlit UI:
|
||||
|
||||
1. **Save Version** — Snapshots current values + financial summary with a descriptive note
|
||||
2. **View History** — See all versions with headline metrics (NPV, ROI)
|
||||
3. **Compare Versions** — Side-by-side diff showing what changed between any two versions
|
||||
1. **Save Version** — Snapshots current values + summary with a descriptive note
|
||||
2. **View History** — All versions with headline metrics (NPV, ROI)
|
||||
3. **Compare Versions** — Side-by-side diff of value changes between any two versions
|
||||
4. **Restore Version** — Load a previous version's values as the current state
|
||||
|
||||
Version notes should capture:
|
||||
@@ -302,6 +376,10 @@ Version notes should capture:
|
||||
pytest tests/ -v
|
||||
```
|
||||
|
||||
Tests are designed to run without an Athena connection — HTTP is mocked
|
||||
and the calculation suite uses the Amazon Connect seed data to verify the
|
||||
Forrester numbers reproduce within rounding.
|
||||
|
||||
### Code Style
|
||||
|
||||
```bash
|
||||
@@ -311,10 +389,11 @@ ruff format .
|
||||
|
||||
### Adding a New Benefit Category
|
||||
|
||||
1. Define the field in Athena's TEI Model admin (field name, type, category, defaults)
|
||||
2. The field automatically appears in Palladium via the API
|
||||
3. Update notebook analysis if category-specific logic is needed
|
||||
4. Update export mapping if the report template expects specific structure
|
||||
1. Define the field in Athena's TEI Report admin (field name, type, category, defaults)
|
||||
2. The field automatically appears in Palladium via the API — no client changes
|
||||
3. Update notebook prose if category-specific commentary is needed
|
||||
4. If the report template exposes a new structure, extend the envelope in
|
||||
`core/export/report_data.py`
|
||||
|
||||
---
|
||||
|
||||
@@ -341,4 +420,3 @@ ruff format .
|
||||
| **Athena** | Platform API — data persistence, cross-tool reporting |
|
||||
| **Peitho** | Document generation — consumes Palladium's export JSON |
|
||||
| **html2docx** | Converts LLM-generated HTML to native Word documents |
|
||||
|
||||
|
||||
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
0
app/components/__init__.py
Normal file
0
app/components/__init__.py
Normal file
32
app/components/charts.py
Normal file
32
app/components/charts.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""Streamlit-friendly chart wrappers (delegate to core.notebook_helpers.charts)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import streamlit as st
|
||||
|
||||
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)
|
||||
|
||||
|
||||
def benefits_bar(items) -> None:
|
||||
fig = core_charts.benefits_bar(items)
|
||||
st.plotly_chart(fig, use_container_width=True)
|
||||
|
||||
|
||||
def cost_pie(items) -> None:
|
||||
fig = core_charts.cost_breakdown_pie(items)
|
||||
st.plotly_chart(fig, use_container_width=True)
|
||||
|
||||
|
||||
def scenario_bars(scenarios) -> None:
|
||||
fig = core_charts.scenario_comparison(scenarios)
|
||||
st.plotly_chart(fig, use_container_width=True)
|
||||
|
||||
|
||||
def waterfall(values) -> None:
|
||||
fig = core_charts.waterfall(values)
|
||||
st.plotly_chart(fig, use_container_width=True)
|
||||
106
app/components/tables.py
Normal file
106
app/components/tables.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""Streamlit data-editor wrappers for benefit/cost rows."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
import streamlit as st
|
||||
|
||||
|
||||
def _years_for_table(fields: list[dict], analysis_years: int) -> list[int]:
|
||||
"""Years 1..N — taken from analysis_period_years on the report."""
|
||||
return list(range(1, max(int(analysis_years or 3), 1) + 1))
|
||||
|
||||
|
||||
def value_editor(
|
||||
table: str,
|
||||
fields: list[dict],
|
||||
values: list[dict],
|
||||
*,
|
||||
analysis_years: int,
|
||||
key: str,
|
||||
) -> pd.DataFrame:
|
||||
"""
|
||||
Render an ``st.data_editor`` for benefit or cost values.
|
||||
|
||||
The editor shows one row per field (filtered to ``table``), with year
|
||||
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.
|
||||
"""
|
||||
fields = [f for f in fields if f.get("table") == table]
|
||||
fields.sort(key=lambda f: int(f.get("sort_order") or 0))
|
||||
|
||||
by_key = {v.get("field_key"): v for v in values}
|
||||
years = _years_for_table(fields, analysis_years)
|
||||
|
||||
rows: list[dict] = []
|
||||
for f in fields:
|
||||
v = by_key.get(f["field_key"], {}) or {}
|
||||
yv = v.get("year_values") or {}
|
||||
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)
|
||||
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)
|
||||
row["notes"] = v.get("notes", "") or ""
|
||||
rows.append(row)
|
||||
|
||||
df = pd.DataFrame(rows)
|
||||
|
||||
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"
|
||||
)
|
||||
for y in years:
|
||||
column_config[f"Year {y}"] = st.column_config.NumberColumn(
|
||||
f"Year {y}", format="$%.0f"
|
||||
)
|
||||
|
||||
edited = st.data_editor(
|
||||
df,
|
||||
column_config=column_config,
|
||||
use_container_width=True,
|
||||
num_rows="fixed",
|
||||
hide_index=True,
|
||||
key=key,
|
||||
)
|
||||
return edited
|
||||
|
||||
|
||||
def df_to_values(df: pd.DataFrame, table: str, analysis_years: int) -> list[dict]:
|
||||
"""Convert an edited DataFrame back to wire-format value rows."""
|
||||
out: list[dict] = []
|
||||
years = list(range(1, max(int(analysis_years or 3), 1) + 1))
|
||||
for _, row in df.iterrows():
|
||||
item: dict = {"field_key": row["field_key"], "table": table}
|
||||
yv = {}
|
||||
for y in years:
|
||||
col = f"Year {y}"
|
||||
if col in df.columns:
|
||||
yv[str(y)] = float(row[col] or 0)
|
||||
if yv:
|
||||
item["year_values"] = yv
|
||||
if table == "costs" and "Initial" in df.columns:
|
||||
item["initial"] = float(row["Initial"] or 0)
|
||||
ra = row.get("risk_adj")
|
||||
if ra is not None and not pd.isna(ra):
|
||||
item["risk_adjustment"] = float(ra)
|
||||
notes = row.get("notes")
|
||||
if isinstance(notes, str) and notes.strip():
|
||||
item["notes"] = notes.strip()
|
||||
out.append(item)
|
||||
return out
|
||||
138
app/main.py
Normal file
138
app/main.py
Normal file
@@ -0,0 +1,138 @@
|
||||
"""
|
||||
Palladium Streamlit app — TEI data entry, calculation, versioning, export.
|
||||
|
||||
Run from the project root::
|
||||
|
||||
streamlit run app/main.py
|
||||
|
||||
The app picks a TEI tool by ``public_id`` (or creates one from a Report
|
||||
template) and exposes Benefits, Costs, Summary, and Versions pages. It is
|
||||
study-agnostic — the field set is loaded dynamically from Athena based on
|
||||
the linked Report template.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Allow `streamlit run app/main.py` from project root without `pip install -e .`
|
||||
_ROOT = Path(__file__).resolve().parent.parent
|
||||
if str(_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_ROOT))
|
||||
|
||||
import streamlit as st
|
||||
|
||||
from core.tei_client import AthenaAPIError, TEIClient
|
||||
|
||||
st.set_page_config(
|
||||
page_title="Palladium — TEI Calculator",
|
||||
page_icon="🛡️",
|
||||
layout="wide",
|
||||
)
|
||||
|
||||
|
||||
@st.cache_resource(show_spinner=False)
|
||||
def get_client() -> TEIClient:
|
||||
return TEIClient()
|
||||
|
||||
|
||||
def _safe_call(fn, *args, **kwargs):
|
||||
"""Run an API call, surfacing errors as Streamlit messages."""
|
||||
try:
|
||||
return fn(*args, **kwargs)
|
||||
except AthenaAPIError as e:
|
||||
st.error(f"Athena API error {e.status_code}: {e.detail}")
|
||||
except ValueError as e:
|
||||
st.error(str(e))
|
||||
return None
|
||||
|
||||
|
||||
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.caption("TEI Calculator")
|
||||
|
||||
tools = _safe_call(client.list_tools) or []
|
||||
if tools:
|
||||
labels = {
|
||||
f"{t.get('name', '(unnamed)')} — {t.get('id', '')[:8]}…": t for t in tools
|
||||
}
|
||||
choice = st.sidebar.selectbox("TEI Tool", list(labels.keys()))
|
||||
tool = labels[choice]
|
||||
else:
|
||||
st.sidebar.info("No TEI tools yet. Create one below.")
|
||||
tool = None
|
||||
|
||||
with st.sidebar.expander("Create new tool"):
|
||||
reports = _safe_call(client.list_reports) or []
|
||||
if not reports:
|
||||
st.write("No report templates available.")
|
||||
else:
|
||||
report_labels = {f"{r['name']} ({r['vendor']} {r['version']})": r for r in reports}
|
||||
r_choice = st.selectbox("Report template", list(report_labels.keys()))
|
||||
new_name = st.text_input("Tool name (optional)", "")
|
||||
proposal_id = st.number_input(
|
||||
"Proposal ID (optional)", min_value=0, value=0, step=1
|
||||
)
|
||||
if st.button("Create"):
|
||||
report = report_labels[r_choice]
|
||||
created = _safe_call(
|
||||
client.create_tool,
|
||||
report_public_id=report["id"],
|
||||
proposal=int(proposal_id) or None,
|
||||
name=new_name or None,
|
||||
)
|
||||
if created:
|
||||
st.success(f"Created tool {created.get('id')}")
|
||||
st.rerun()
|
||||
|
||||
if tool:
|
||||
st.sidebar.divider()
|
||||
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"):
|
||||
_safe_call(client.calculate, tool["id"])
|
||||
st.toast("Recalculated.", icon="✅")
|
||||
st.cache_data.clear()
|
||||
return tool
|
||||
|
||||
|
||||
def main() -> None:
|
||||
st.title("Palladium — TEI Calculator")
|
||||
try:
|
||||
client = get_client()
|
||||
except ValueError as e:
|
||||
st.error(str(e))
|
||||
st.info("Set ATHENA_BASE_URL and ATHENA_API_KEY in your `.env` file.")
|
||||
st.stop()
|
||||
return
|
||||
|
||||
tool = sidebar_tool_picker(client)
|
||||
|
||||
if tool is 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
|
||||
# views share the chosen tool/state without re-querying.
|
||||
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
|
||||
|
||||
with tabs[0]:
|
||||
summary_page.render(client, tool)
|
||||
with tabs[1]:
|
||||
benefits_page.render(client, tool)
|
||||
with tabs[2]:
|
||||
costs_page.render(client, tool)
|
||||
with tabs[3]:
|
||||
versions_page.render(client, tool)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
0
app/pages/__init__.py
Normal file
0
app/pages/__init__.py
Normal file
28
app/pages/_helpers.py
Normal file
28
app/pages/_helpers.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""Common helpers shared by the page modules."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import streamlit as st
|
||||
|
||||
from core.tei_client import AthenaAPIError, TEIClient
|
||||
|
||||
|
||||
def report_meta(client: TEIClient, tool: dict) -> dict:
|
||||
"""Fetch the linked report (handles both nested-object and id-only forms)."""
|
||||
report_obj = tool.get("report")
|
||||
if isinstance(report_obj, dict):
|
||||
return report_obj
|
||||
if isinstance(report_obj, str):
|
||||
try:
|
||||
return client.get_report(report_obj)
|
||||
except AthenaAPIError as e:
|
||||
st.error(f"Failed to load report template: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
def safe(fn, *args, **kwargs):
|
||||
try:
|
||||
return fn(*args, **kwargs)
|
||||
except AthenaAPIError as e:
|
||||
st.error(f"Athena API error {e.status_code}: {e.detail}")
|
||||
return None
|
||||
46
app/pages/benefits.py
Normal file
46
app/pages/benefits.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""Benefits data-entry tab."""
|
||||
|
||||
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 core.tei_client import TEIClient
|
||||
|
||||
|
||||
def render(client: TEIClient, tool: dict) -> None:
|
||||
st.header("💰 Benefits")
|
||||
public_id = tool["id"]
|
||||
report = report_meta(client, tool)
|
||||
analysis_years = int(report.get("analysis_period_years") or 3)
|
||||
|
||||
fields = safe(client.list_fields, report.get("id"), "benefits") or []
|
||||
values = [v for v in safe(client.get_values, public_id) or [] if v.get("table") == "benefits"]
|
||||
|
||||
if not fields:
|
||||
st.info("This report template has no benefit fields defined.")
|
||||
return
|
||||
|
||||
edited = value_editor(
|
||||
"benefits",
|
||||
fields,
|
||||
values,
|
||||
analysis_years=analysis_years,
|
||||
key=f"benefits_editor_{public_id}",
|
||||
)
|
||||
|
||||
col1, col2 = st.columns([1, 4])
|
||||
with col1:
|
||||
if st.button("💾 Save benefits", use_container_width=True):
|
||||
payload = df_to_values(edited, "benefits", analysis_years)
|
||||
result = safe(client.update_values, public_id, payload)
|
||||
if result is not None:
|
||||
st.success(f"Saved {len(payload)} benefit values.")
|
||||
st.cache_data.clear()
|
||||
with col2:
|
||||
st.caption(
|
||||
"Values are saved as nominal annual amounts. Risk adjustments are "
|
||||
"applied at calculate time. Use the Recalculate button in the "
|
||||
"sidebar after saving to refresh the summary."
|
||||
)
|
||||
46
app/pages/costs.py
Normal file
46
app/pages/costs.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""Costs data-entry tab."""
|
||||
|
||||
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 core.tei_client import TEIClient
|
||||
|
||||
|
||||
def render(client: TEIClient, tool: dict) -> None:
|
||||
st.header("💸 Costs")
|
||||
public_id = tool["id"]
|
||||
report = report_meta(client, tool)
|
||||
analysis_years = int(report.get("analysis_period_years") or 3)
|
||||
|
||||
fields = safe(client.list_fields, report.get("id"), "costs") or []
|
||||
values = [v for v in safe(client.get_values, public_id) or [] if v.get("table") == "costs"]
|
||||
|
||||
if not fields:
|
||||
st.info("This report template has no cost fields defined.")
|
||||
return
|
||||
|
||||
edited = value_editor(
|
||||
"costs",
|
||||
fields,
|
||||
values,
|
||||
analysis_years=analysis_years,
|
||||
key=f"costs_editor_{public_id}",
|
||||
)
|
||||
|
||||
col1, col2 = st.columns([1, 4])
|
||||
with col1:
|
||||
if st.button("💾 Save costs", use_container_width=True):
|
||||
payload = df_to_values(edited, "costs", analysis_years)
|
||||
result = safe(client.update_values, public_id, payload)
|
||||
if result is not None:
|
||||
st.success(f"Saved {len(payload)} cost values.")
|
||||
st.cache_data.clear()
|
||||
with col2:
|
||||
st.caption(
|
||||
"The Initial column is undiscounted year-0 spend. Year columns "
|
||||
"are end-of-year cashflows. Costs are risk-adjusted upward "
|
||||
"(higher risk → higher cost)."
|
||||
)
|
||||
104
app/pages/summary.py
Normal file
104
app/pages/summary.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""Financial summary dashboard tab."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import streamlit as st
|
||||
|
||||
from app.components import charts
|
||||
from app.pages._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")
|
||||
public_id = tool["id"]
|
||||
report = report_meta(client, tool)
|
||||
|
||||
try:
|
||||
summary = client.get_summary(public_id)
|
||||
except AthenaAPIError as e:
|
||||
if e.status_code == 404:
|
||||
st.info(
|
||||
"No summary yet — click **Recalculate** in the sidebar after "
|
||||
"filling in benefits and costs."
|
||||
)
|
||||
return
|
||||
st.error(f"Athena API error: {e.detail}")
|
||||
return
|
||||
|
||||
npv = float(summary.get("npv") or 0)
|
||||
roi = float(summary.get("roi") or summary.get("roi_pct") or 0)
|
||||
payback = summary.get("payback_months")
|
||||
bpv = float(summary.get("total_benefits_pv") or 0)
|
||||
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[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")
|
||||
|
||||
st.divider()
|
||||
|
||||
yb = summary.get("yearly_breakdown") or []
|
||||
initial = float(summary.get("initial_costs") or 0)
|
||||
if yb:
|
||||
charts.cashflow(yb, initial_cost=initial)
|
||||
with st.expander("Cash flow table"):
|
||||
st.dataframe(yb, use_container_width=True, hide_index=True)
|
||||
else:
|
||||
st.caption("No yearly breakdown in this summary.")
|
||||
|
||||
# Scenario comparison — computed locally from current values
|
||||
with st.expander("Scenario analysis (conservative / moderate / aggressive)"):
|
||||
envelope = safe(
|
||||
build_report_data,
|
||||
client,
|
||||
public_id,
|
||||
include_scenarios=True,
|
||||
study_slug=report.get("name", ""),
|
||||
)
|
||||
if envelope and envelope.get("scenarios"):
|
||||
charts.scenario_bars(envelope["scenarios"])
|
||||
rows = [
|
||||
{
|
||||
"Scenario": k,
|
||||
"Benefits PV": float(v.get("total_benefits_pv") or 0),
|
||||
"Costs PV": float(v.get("total_costs_pv") or 0),
|
||||
"NPV": float(v.get("npv") or 0),
|
||||
"ROI %": float(v.get("roi_pct") or 0),
|
||||
"Payback (months)": (
|
||||
round(float(v.get("payback_months") or 0), 1)
|
||||
if v.get("payback_months") is not None
|
||||
else None
|
||||
),
|
||||
}
|
||||
for k, v in envelope["scenarios"].items()
|
||||
]
|
||||
st.dataframe(rows, use_container_width=True, hide_index=True)
|
||||
|
||||
# Export button
|
||||
st.divider()
|
||||
if st.button("📦 Build export envelope (JSON)"):
|
||||
envelope = safe(
|
||||
build_report_data,
|
||||
client,
|
||||
public_id,
|
||||
include_scenarios=True,
|
||||
study_slug=report.get("name", ""),
|
||||
)
|
||||
if envelope:
|
||||
import json
|
||||
|
||||
data = json.dumps(envelope, indent=2, default=str)
|
||||
st.download_button(
|
||||
"Download export.json",
|
||||
data=data,
|
||||
file_name=f"{public_id}_export.json",
|
||||
mime="application/json",
|
||||
)
|
||||
117
app/pages/versions.py
Normal file
117
app/pages/versions.py
Normal file
@@ -0,0 +1,117 @@
|
||||
"""Version history tab — list, diff, save, restore."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import streamlit as st
|
||||
|
||||
from app.pages._helpers import safe
|
||||
from core.tei_client import TEIClient
|
||||
|
||||
|
||||
def _flatten_values(values: list[dict]) -> dict[str, dict]:
|
||||
"""Index a values list by field_key for easy diffing."""
|
||||
return {v.get("field_key", ""): v for v in values}
|
||||
|
||||
|
||||
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] = []
|
||||
for k in keys:
|
||||
av = a.get(k, {}) or {}
|
||||
bv = b.get(k, {}) or {}
|
||||
ay = av.get("year_values") or {}
|
||||
by = bv.get("year_values") or {}
|
||||
years = sorted(set(ay.keys()) | set(by.keys()), key=lambda x: int(x))
|
||||
for y in years:
|
||||
a_val = float(ay.get(y) or 0)
|
||||
b_val = float(by.get(y) or 0)
|
||||
if abs(a_val - b_val) < 1e-9:
|
||||
continue
|
||||
rows.append(
|
||||
{
|
||||
"field_key": k,
|
||||
"year": y,
|
||||
"left": a_val,
|
||||
"right": b_val,
|
||||
"delta": b_val - a_val,
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def render(client: TEIClient, tool: dict) -> None:
|
||||
st.header("🕒 Versions")
|
||||
public_id = tool["id"]
|
||||
|
||||
versions = safe(client.list_versions, public_id) or []
|
||||
versions = sorted(
|
||||
versions, key=lambda v: int(v.get("version_number") or 0), reverse=True
|
||||
)
|
||||
|
||||
# Save new version
|
||||
with st.expander("➕ Save current state as a new version", expanded=not versions):
|
||||
note = st.text_area(
|
||||
"Version note",
|
||||
placeholder=(
|
||||
"What changed? E.g. 'CFO confirmed 1.8M contacts/month; "
|
||||
"raised legacy license cost from $160 to $180/agent.'"
|
||||
),
|
||||
)
|
||||
if st.button("💾 Save version", disabled=not note.strip()):
|
||||
result = safe(client.save_version, public_id, note.strip())
|
||||
if result:
|
||||
st.success(
|
||||
f"Saved version {result.get('version_number', '?')}."
|
||||
)
|
||||
st.rerun()
|
||||
|
||||
if not versions:
|
||||
st.info("No versions saved yet.")
|
||||
return
|
||||
|
||||
# Listing
|
||||
st.subheader("History")
|
||||
rows = []
|
||||
for v in versions:
|
||||
snap = v.get("summary_snapshot") or v.get("summary") or {}
|
||||
rows.append(
|
||||
{
|
||||
"Version": v.get("version_number"),
|
||||
"Date": v.get("created_at") or v.get("date"),
|
||||
"NPV": float(snap.get("npv") or 0),
|
||||
"ROI %": float(snap.get("roi") or snap.get("roi_pct") or 0),
|
||||
"Note": v.get("note", ""),
|
||||
}
|
||||
)
|
||||
st.dataframe(rows, use_container_width=True, hide_index=True)
|
||||
|
||||
# Compare two versions
|
||||
st.subheader("Compare")
|
||||
if len(versions) < 2:
|
||||
st.caption("Save two or more versions to compare.")
|
||||
return
|
||||
labels = {f"v{v['version_number']} — {v.get('note', '')[:40]}": v for v in versions}
|
||||
keys = list(labels.keys())
|
||||
c1, c2 = st.columns(2)
|
||||
with c1:
|
||||
left_label = st.selectbox("Left (older)", keys, index=min(1, len(keys) - 1))
|
||||
with c2:
|
||||
right_label = st.selectbox("Right (newer)", keys, index=0)
|
||||
|
||||
if left_label == right_label:
|
||||
st.caption("Pick two different versions to see a diff.")
|
||||
return
|
||||
|
||||
left = safe(client.get_version, public_id, labels[left_label]["version_number"])
|
||||
right = safe(client.get_version, public_id, labels[right_label]["version_number"])
|
||||
if not (left and right):
|
||||
return
|
||||
|
||||
a_values = left.get("values_snapshot") or left.get("values") or []
|
||||
b_values = right.get("values_snapshot") or right.get("values") or []
|
||||
diff = _diff_rows(_flatten_values(a_values), _flatten_values(b_values))
|
||||
if not diff:
|
||||
st.success("No value differences between these versions.")
|
||||
else:
|
||||
st.dataframe(diff, use_container_width=True, hide_index=True)
|
||||
3
core/__init__.py
Normal file
3
core/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""Palladium core — shared TEI client, calculations, export, helpers."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
31
core/calculations/__init__.py
Normal file
31
core/calculations/__init__.py
Normal 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
78
core/calculations/npv.py
Normal 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)
|
||||
63
core/calculations/payback.py
Normal file
63
core/calculations/payback.py
Normal 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
27
core/calculations/roi.py
Normal 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
|
||||
128
core/calculations/scenarios.py
Normal file
128
core/calculations/scenarios.py
Normal 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
|
||||
1
core/cli/__init__.py
Normal file
1
core/cli/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Palladium CLI package — invoked via ``python -m palladium``."""
|
||||
229
core/cli/main.py
Normal file
229
core/cli/main.py
Normal file
@@ -0,0 +1,229 @@
|
||||
"""
|
||||
Palladium CLI implementation.
|
||||
|
||||
Subcommands::
|
||||
|
||||
palladium test # Verify Athena connectivity
|
||||
palladium list # List TEI tool instances
|
||||
palladium reports # List TEI report templates
|
||||
palladium summary <public_id> # Print a tool's financial summary
|
||||
palladium calculate <public_id> # Trigger /calculate
|
||||
palladium export <public_id> -o file # Save export envelope as JSON
|
||||
|
||||
The CLI is invoked via ``python -m palladium`` (root shim) which calls
|
||||
:func:`main` here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from collections.abc import Sequence
|
||||
|
||||
from core.export import build_report_data, write_report_data
|
||||
from core.tei_client import AthenaAPIError, TEIClient
|
||||
|
||||
|
||||
def _configure_logging(verbosity: int) -> None:
|
||||
level = logging.WARNING
|
||||
if verbosity == 1:
|
||||
level = logging.INFO
|
||||
elif verbosity >= 2:
|
||||
level = logging.DEBUG
|
||||
logging.basicConfig(
|
||||
level=level, format="%(asctime)s %(levelname)s %(name)s: %(message)s"
|
||||
)
|
||||
|
||||
|
||||
def _build_parser() -> argparse.ArgumentParser:
|
||||
p = argparse.ArgumentParser(
|
||||
prog="palladium",
|
||||
description="Palladium — TEI Calculator CLI for Athena.",
|
||||
)
|
||||
p.add_argument("-v", "--verbose", action="count", default=0)
|
||||
sub = p.add_subparsers(dest="command", required=True)
|
||||
|
||||
sub.add_parser("test", help="Verify Athena API connectivity")
|
||||
sub.add_parser("list", help="List TEI tool instances")
|
||||
sub.add_parser("reports", help="List TEI report templates")
|
||||
|
||||
s_summary = sub.add_parser("summary", help="Print a tool's financial summary")
|
||||
s_summary.add_argument("public_id")
|
||||
|
||||
s_calc = sub.add_parser("calculate", help="Trigger calculation for a tool")
|
||||
s_calc.add_argument("public_id")
|
||||
|
||||
s_export = sub.add_parser("export", help="Export a tool's report data as JSON")
|
||||
s_export.add_argument("public_id")
|
||||
s_export.add_argument(
|
||||
"-o",
|
||||
"--output",
|
||||
default="-",
|
||||
help="Output path (default: stdout)",
|
||||
)
|
||||
s_export.add_argument(
|
||||
"--no-scenarios",
|
||||
action="store_true",
|
||||
help="Skip computing conservative/moderate/aggressive scenarios.",
|
||||
)
|
||||
s_export.add_argument(
|
||||
"--study-slug",
|
||||
default=None,
|
||||
help="Optional study identifier to embed in metadata.",
|
||||
)
|
||||
|
||||
return p
|
||||
|
||||
|
||||
def _print_table(rows: list[dict], columns: list[tuple[str, str]]) -> None:
|
||||
"""Tiny pure-stdlib pretty-printer."""
|
||||
widths = [len(label) for _, label in columns]
|
||||
formatted: list[list[str]] = []
|
||||
for r in rows:
|
||||
rec = [str(r.get(key, "") or "") for key, _ in columns]
|
||||
formatted.append(rec)
|
||||
for i, val in enumerate(rec):
|
||||
if len(val) > widths[i]:
|
||||
widths[i] = len(val)
|
||||
fmt = " ".join(f"{{:<{w}}}" for w in widths)
|
||||
print(fmt.format(*[label for _, label in columns]))
|
||||
print(fmt.format(*["-" * w for w in widths]))
|
||||
for rec in formatted:
|
||||
print(fmt.format(*rec))
|
||||
|
||||
|
||||
def cmd_test(client: TEIClient, args) -> int:
|
||||
result = client.test_connection()
|
||||
print(json.dumps(result, indent=2))
|
||||
return 0 if result.get("status") == "ok" else 1
|
||||
|
||||
|
||||
def cmd_list(client: TEIClient, args) -> int:
|
||||
tools = client.list_tools()
|
||||
if not tools:
|
||||
print("(no TEI tools)")
|
||||
return 0
|
||||
rows = []
|
||||
for t in tools:
|
||||
report = t.get("report")
|
||||
if isinstance(report, dict):
|
||||
report_name = report.get("name", "")
|
||||
else:
|
||||
report_name = report or ""
|
||||
rows.append(
|
||||
{
|
||||
"id": t.get("id", ""),
|
||||
"name": t.get("name", ""),
|
||||
"report": report_name,
|
||||
"status": t.get("status", ""),
|
||||
"version": t.get("current_version", ""),
|
||||
"modified": t.get("modified_date", ""),
|
||||
}
|
||||
)
|
||||
_print_table(
|
||||
rows,
|
||||
[
|
||||
("id", "PUBLIC_ID"),
|
||||
("name", "NAME"),
|
||||
("report", "REPORT"),
|
||||
("status", "STATUS"),
|
||||
("version", "VER"),
|
||||
("modified", "MODIFIED"),
|
||||
],
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_reports(client: TEIClient, args) -> int:
|
||||
reports = client.list_reports()
|
||||
if not reports:
|
||||
print("(no reports)")
|
||||
return 0
|
||||
rows = [
|
||||
{
|
||||
"id": r.get("id", ""),
|
||||
"name": r.get("name", ""),
|
||||
"vendor": r.get("vendor", ""),
|
||||
"version": r.get("version", ""),
|
||||
"fields": r.get("field_count", 0),
|
||||
"instances": r.get("instance_count", 0),
|
||||
"status": r.get("status", ""),
|
||||
}
|
||||
for r in reports
|
||||
]
|
||||
_print_table(
|
||||
rows,
|
||||
[
|
||||
("id", "PUBLIC_ID"),
|
||||
("name", "NAME"),
|
||||
("vendor", "VENDOR"),
|
||||
("version", "VER"),
|
||||
("fields", "FIELDS"),
|
||||
("instances", "TOOLS"),
|
||||
("status", "STATUS"),
|
||||
],
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_summary(client: TEIClient, args) -> int:
|
||||
client.print_summary(args.public_id)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_calculate(client: TEIClient, args) -> int:
|
||||
client.calculate(args.public_id)
|
||||
print(f"Recalculated {args.public_id}.")
|
||||
client.print_summary(args.public_id)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_export(client: TEIClient, args) -> int:
|
||||
envelope = build_report_data(
|
||||
client,
|
||||
args.public_id,
|
||||
include_scenarios=not args.no_scenarios,
|
||||
study_slug=args.study_slug,
|
||||
)
|
||||
if args.output in ("-", ""):
|
||||
json.dump(envelope, sys.stdout, indent=2, default=str)
|
||||
sys.stdout.write("\n")
|
||||
else:
|
||||
path = write_report_data(envelope, args.output)
|
||||
print(f"Wrote {path}")
|
||||
return 0
|
||||
|
||||
|
||||
COMMANDS = {
|
||||
"test": cmd_test,
|
||||
"list": cmd_list,
|
||||
"reports": cmd_reports,
|
||||
"summary": cmd_summary,
|
||||
"calculate": cmd_calculate,
|
||||
"export": cmd_export,
|
||||
}
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
parser = _build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
_configure_logging(args.verbose)
|
||||
|
||||
try:
|
||||
client = TEIClient()
|
||||
except ValueError as e:
|
||||
print(f"error: {e}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
handler = COMMANDS[args.command]
|
||||
try:
|
||||
return handler(client, args)
|
||||
except AthenaAPIError as e:
|
||||
print(f"Athena API error {e.status_code}: {e.detail}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
raise SystemExit(main())
|
||||
5
core/export/__init__.py
Normal file
5
core/export/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Export utilities — build the LLM-ready JSON envelope."""
|
||||
|
||||
from core.export.report_data import build_report_data, write_report_data
|
||||
|
||||
__all__ = ["build_report_data", "write_report_data"]
|
||||
224
core/export/report_data.py
Normal file
224
core/export/report_data.py
Normal file
@@ -0,0 +1,224 @@
|
||||
"""
|
||||
Build the structured JSON consumed by the report pipeline.
|
||||
|
||||
The Athena ``GET /tools/{public_id}/export/`` endpoint already returns most
|
||||
of what we need; this module:
|
||||
|
||||
1. Calls the export endpoint.
|
||||
2. Optionally augments it with locally computed scenario analysis
|
||||
(conservative / moderate / aggressive).
|
||||
3. Stamps Palladium metadata (export timestamp, study slug, generator).
|
||||
4. Serializes to a stable JSON file that html2docx / Peitho can consume.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from core import __version__
|
||||
from core.calculations import (
|
||||
apply_scenario,
|
||||
npv,
|
||||
payback_months,
|
||||
risk_adjust_benefit,
|
||||
risk_adjust_cost,
|
||||
roi_percentage,
|
||||
)
|
||||
|
||||
|
||||
def _split_by_table(values: list[dict]) -> tuple[list[dict], list[dict]]:
|
||||
benefits = [v for v in values if v.get("table") == "benefits"]
|
||||
costs = [v for v in values if v.get("table") == "costs"]
|
||||
return benefits, costs
|
||||
|
||||
|
||||
def _yearly_totals(items: list[dict], analysis_years: int) -> list[float]:
|
||||
totals = [0.0] * analysis_years
|
||||
for it in items:
|
||||
yv = it.get("year_values") or {}
|
||||
for k, v in yv.items():
|
||||
try:
|
||||
year = int(k)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if 1 <= year <= analysis_years:
|
||||
totals[year - 1] += float(v or 0)
|
||||
return totals
|
||||
|
||||
|
||||
def _initial_total(items: list[dict]) -> float:
|
||||
return sum(float(it.get("initial") or 0) for it in items)
|
||||
|
||||
|
||||
def _risk_adjusted(items: list[dict], for_table: str) -> list[dict]:
|
||||
out: list[dict] = []
|
||||
for it in items:
|
||||
rf = float(it.get("risk_adjustment") or 0.0)
|
||||
adj_year_values: dict[str, float] = {}
|
||||
for k, v in (it.get("year_values") or {}).items():
|
||||
v = float(v or 0)
|
||||
adj_year_values[str(k)] = (
|
||||
risk_adjust_benefit(v, rf)
|
||||
if for_table == "benefits"
|
||||
else risk_adjust_cost(v, rf)
|
||||
)
|
||||
adj = dict(it)
|
||||
adj["year_values"] = adj_year_values
|
||||
if "initial" in adj and adj["initial"] is not None:
|
||||
adj["initial"] = (
|
||||
risk_adjust_cost(float(adj["initial"]), rf)
|
||||
if for_table == "costs"
|
||||
else float(adj["initial"])
|
||||
)
|
||||
out.append(adj)
|
||||
return out
|
||||
|
||||
|
||||
def _compute_summary(
|
||||
benefits: list[dict],
|
||||
costs: list[dict],
|
||||
discount_rate: float,
|
||||
analysis_years: int,
|
||||
) -> dict[str, Any]:
|
||||
benefits_ra = _risk_adjusted(benefits, "benefits")
|
||||
costs_ra = _risk_adjusted(costs, "costs")
|
||||
|
||||
benefits_yr = _yearly_totals(benefits_ra, analysis_years)
|
||||
costs_yr = _yearly_totals(costs_ra, analysis_years)
|
||||
initial_costs = _initial_total(costs_ra)
|
||||
|
||||
benefits_pv = npv(benefits_yr, discount_rate)
|
||||
costs_pv = npv(costs_yr, discount_rate, initial=initial_costs)
|
||||
nominal_benefits = sum(benefits_yr)
|
||||
nominal_costs = sum(costs_yr) + initial_costs
|
||||
|
||||
net_yearly = [b - c for b, c in zip(benefits_yr, costs_yr, strict=False)]
|
||||
pb = payback_months(initial_costs, net_yearly)
|
||||
|
||||
return {
|
||||
"discount_rate": discount_rate,
|
||||
"analysis_years": analysis_years,
|
||||
"total_benefits_nominal": nominal_benefits,
|
||||
"total_benefits_pv": benefits_pv,
|
||||
"total_costs_nominal": nominal_costs,
|
||||
"total_costs_pv": costs_pv,
|
||||
"npv": benefits_pv - costs_pv,
|
||||
"roi_pct": roi_percentage(benefits_pv, costs_pv),
|
||||
"payback_months": pb,
|
||||
"yearly_breakdown": [
|
||||
{
|
||||
"year": idx + 1,
|
||||
"benefits": benefits_yr[idx],
|
||||
"costs": costs_yr[idx],
|
||||
"net": net_yearly[idx],
|
||||
"cumulative_net": sum(net_yearly[: idx + 1]) - initial_costs,
|
||||
}
|
||||
for idx in range(analysis_years)
|
||||
],
|
||||
"initial_costs": initial_costs,
|
||||
}
|
||||
|
||||
|
||||
def build_report_data(
|
||||
client,
|
||||
public_id: str,
|
||||
*,
|
||||
include_scenarios: bool = True,
|
||||
study_slug: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Build the full export envelope for a TEI tool.
|
||||
|
||||
Args:
|
||||
client: a :class:`core.tei_client.TEIClient` instance.
|
||||
public_id: the TEI tool's public_id.
|
||||
include_scenarios: if True, locally compute conservative / moderate /
|
||||
aggressive summaries and attach them under ``scenarios``.
|
||||
study_slug: optional human-friendly study identifier (e.g.
|
||||
``"202602_AmazonConnect"``) — written into ``metadata``.
|
||||
|
||||
Returns:
|
||||
A dict with keys::
|
||||
|
||||
{
|
||||
"metadata": {...}, # client / opportunity / study / generator
|
||||
"report": {...}, # report template echo
|
||||
"values": {benefits, costs},
|
||||
"summary": {...}, # locally recomputed (mirrors Athena)
|
||||
"athena_export": {...}, # raw payload from Athena (if available)
|
||||
"scenarios": {...} # optional
|
||||
}
|
||||
"""
|
||||
# Pull everything we need
|
||||
bundle = client.get_tool_with_data(public_id)
|
||||
tool = bundle["tool"]
|
||||
fields = bundle["fields"]
|
||||
values = bundle["values"]
|
||||
|
||||
report_obj = tool.get("report")
|
||||
if isinstance(report_obj, str):
|
||||
report = client.get_report(report_obj)
|
||||
elif isinstance(report_obj, dict):
|
||||
report = report_obj
|
||||
else:
|
||||
report = {}
|
||||
|
||||
discount_rate = float(report.get("discount_rate") or 0.10)
|
||||
analysis_years = int(report.get("analysis_period_years") or 3)
|
||||
|
||||
benefits, costs = _split_by_table(values)
|
||||
summary = _compute_summary(benefits, costs, discount_rate, analysis_years)
|
||||
|
||||
try:
|
||||
athena_export = client.export(public_id)
|
||||
except Exception as e: # pragma: no cover – best effort
|
||||
athena_export = {"error": str(e)}
|
||||
|
||||
envelope: dict[str, Any] = {
|
||||
"metadata": {
|
||||
"study_slug": study_slug or "",
|
||||
"tool_public_id": public_id,
|
||||
"tool_name": tool.get("name", ""),
|
||||
"report_name": report.get("name", ""),
|
||||
"report_vendor": report.get("vendor", ""),
|
||||
"report_version": report.get("version", ""),
|
||||
"report_public_id": report.get("id", ""),
|
||||
"proposal": tool.get("proposal") or tool.get("opportunity"),
|
||||
"engagement": tool.get("engagement"),
|
||||
"generated_at": datetime.now(UTC).isoformat(),
|
||||
"generator": f"palladium core {__version__}",
|
||||
},
|
||||
"report": report,
|
||||
"fields": fields,
|
||||
"values": {"benefits": benefits, "costs": costs},
|
||||
"summary": summary,
|
||||
"athena_export": athena_export,
|
||||
}
|
||||
|
||||
if include_scenarios:
|
||||
scenario_results: dict[str, Any] = {}
|
||||
for scenario_name in ("conservative", "moderate", "aggressive"):
|
||||
sb = apply_scenario(benefits, scenario_name, table="benefits")
|
||||
sc = apply_scenario(costs, scenario_name, table="costs")
|
||||
scenario_results[scenario_name] = _compute_summary(
|
||||
sb, sc, discount_rate, analysis_years
|
||||
)
|
||||
envelope["scenarios"] = scenario_results
|
||||
|
||||
return envelope
|
||||
|
||||
|
||||
def write_report_data(
|
||||
envelope: dict[str, Any],
|
||||
output_path: str | Path,
|
||||
*,
|
||||
indent: int = 2,
|
||||
) -> Path:
|
||||
"""Serialize ``envelope`` to ``output_path`` and return the Path."""
|
||||
path = Path(output_path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(envelope, indent=indent, default=str))
|
||||
return path
|
||||
5
core/notebook_helpers/__init__.py
Normal file
5
core/notebook_helpers/__init__.py
Normal 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"]
|
||||
193
core/notebook_helpers/charts.py
Normal file
193
core/notebook_helpers/charts.py
Normal 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
|
||||
141
core/notebook_helpers/display.py
Normal file
141
core/notebook_helpers/display.py
Normal 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))
|
||||
127
core/notebook_helpers/tables.py
Normal file
127
core/notebook_helpers/tables.py
Normal 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
|
||||
13
core/tei_client/__init__.py
Normal file
13
core/tei_client/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
||||
"""TEI Client — Athena API wrapper for Palladium."""
|
||||
|
||||
from core.tei_client.client import AthenaAPIError, TEIClient
|
||||
from core.tei_client.models import TEIField, TEIReport, TEISummary, TEIValue
|
||||
|
||||
__all__ = [
|
||||
"AthenaAPIError",
|
||||
"TEIClient",
|
||||
"TEIField",
|
||||
"TEIReport",
|
||||
"TEISummary",
|
||||
"TEIValue",
|
||||
]
|
||||
563
core/tei_client/client.py
Normal file
563
core/tei_client/client.py
Normal file
@@ -0,0 +1,563 @@
|
||||
"""
|
||||
TEI Client — Athena API wrapper for Palladium.
|
||||
|
||||
Endpoints (per Athena API.yaml, all under ``/api/v1/tei/``):
|
||||
|
||||
Reports (templates)
|
||||
GET /reports/ list_reports
|
||||
GET /reports/{public_id}/ get_report
|
||||
GET /reports/{public_id}/fields/ list_fields
|
||||
PATCH /reports/{public_id}/fields/reorder/ reorder_fields
|
||||
|
||||
Tools (instances)
|
||||
GET /tools/ list_tools
|
||||
POST /tools/ create_tool
|
||||
GET /tools/{public_id}/ get_tool
|
||||
PATCH /tools/{public_id}/ update_tool
|
||||
DELETE /tools/{public_id}/ delete_tool
|
||||
|
||||
Values (data entry)
|
||||
GET /tools/{public_id}/values/ get_values
|
||||
PUT /tools/{public_id}/values/ update_values (bulk)
|
||||
PATCH /tools/{public_id}/values/{field_key}/ patch_value (single)
|
||||
|
||||
Calculation & summary
|
||||
POST /tools/{public_id}/calculate/ calculate
|
||||
GET /tools/{public_id}/summary/ get_summary
|
||||
GET /summary/ aggregate_summary
|
||||
|
||||
Versions
|
||||
GET /tools/{public_id}/versions/ list_versions
|
||||
POST /tools/{public_id}/versions/ save_version
|
||||
GET /tools/{public_id}/versions/{n}/ get_version
|
||||
|
||||
Export
|
||||
GET /tools/{public_id}/export/ export
|
||||
|
||||
Authentication uses the ``Authorization: Api-Key {key}`` header.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
API_PREFIX = "/api/v1/tei"
|
||||
|
||||
|
||||
class AthenaAPIError(Exception):
|
||||
"""Raised when Athena returns a non-success response."""
|
||||
|
||||
def __init__(self, status_code: int, detail: str, url: str):
|
||||
self.status_code = status_code
|
||||
self.detail = detail
|
||||
self.url = url
|
||||
super().__init__(f"Athena API {status_code} at {url}: {detail}")
|
||||
|
||||
|
||||
class TEIClient:
|
||||
"""
|
||||
Client for Athena's TEI Calculator API.
|
||||
|
||||
Wraps every TEI endpoint and provides a few convenience helpers used by
|
||||
the Palladium notebooks and Streamlit app.
|
||||
|
||||
Environment variables (read via python-dotenv):
|
||||
ATHENA_BASE_URL e.g. https://athena.nttdata.com
|
||||
ATHENA_API_KEY Api-Key value (admin-issued)
|
||||
|
||||
Example::
|
||||
|
||||
from core.tei_client import TEIClient
|
||||
client = TEIClient()
|
||||
client.test_connection()
|
||||
for r in client.list_reports():
|
||||
print(r["name"])
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str | None = None,
|
||||
api_key: str | None = None,
|
||||
timeout: int = 30,
|
||||
):
|
||||
self.base_url = (base_url or os.getenv("ATHENA_BASE_URL", "")).rstrip("/")
|
||||
self.api_key = api_key or os.getenv("ATHENA_API_KEY", "")
|
||||
self.timeout = timeout
|
||||
|
||||
if not self.base_url:
|
||||
raise ValueError(
|
||||
"ATHENA_BASE_URL is required. Set it in .env or pass base_url."
|
||||
)
|
||||
if not self.api_key:
|
||||
raise ValueError(
|
||||
"ATHENA_API_KEY is required. Set it in .env or pass api_key."
|
||||
)
|
||||
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update(
|
||||
{
|
||||
"Authorization": f"Api-Key {self.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
)
|
||||
|
||||
logger.info("TEIClient initialised for %s", self.base_url)
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Internal HTTP helpers
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
def _url(self, path: str) -> str:
|
||||
if not path.startswith("/"):
|
||||
path = f"/{path}"
|
||||
return f"{self.base_url}{path}"
|
||||
|
||||
def _request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
params: dict | None = None,
|
||||
json_data: Any | None = None,
|
||||
) -> Any:
|
||||
url = self._url(path)
|
||||
logger.debug("%s %s", method.upper(), url)
|
||||
|
||||
try:
|
||||
response = self.session.request(
|
||||
method=method,
|
||||
url=url,
|
||||
params=params,
|
||||
json=json_data,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
except requests.ConnectionError as e:
|
||||
raise AthenaAPIError(0, f"Connection failed: {e}", url) from e
|
||||
except requests.Timeout as e:
|
||||
raise AthenaAPIError(408, "Request timed out", url) from e
|
||||
|
||||
if response.status_code >= 400:
|
||||
try:
|
||||
payload = response.json()
|
||||
detail = payload.get("detail") or json.dumps(payload)
|
||||
except (json.JSONDecodeError, ValueError, AttributeError):
|
||||
detail = response.text
|
||||
raise AthenaAPIError(response.status_code, detail, url)
|
||||
|
||||
if response.status_code == 204 or not response.content:
|
||||
return {}
|
||||
return response.json()
|
||||
|
||||
def _get(self, path: str, params: dict | None = None) -> Any:
|
||||
return self._request("GET", path, params=params)
|
||||
|
||||
def _post(self, path: str, data: Any | None = None) -> Any:
|
||||
return self._request("POST", path, json_data=data)
|
||||
|
||||
def _put(self, path: str, data: Any | None = None) -> Any:
|
||||
return self._request("PUT", path, json_data=data)
|
||||
|
||||
def _patch(self, path: str, data: Any | None = None) -> Any:
|
||||
return self._request("PATCH", path, json_data=data)
|
||||
|
||||
def _delete(self, path: str) -> Any:
|
||||
return self._request("DELETE", path)
|
||||
|
||||
def _paginated(self, path: str, params: dict | None = None) -> list[dict]:
|
||||
"""
|
||||
Fetch all pages of a paginated list endpoint.
|
||||
|
||||
Athena uses the standard DRF page/results envelope::
|
||||
|
||||
{"count": N, "next": url|None, "previous": ..., "results": [...]}
|
||||
"""
|
||||
out: list[dict] = []
|
||||
result = self._get(path, params=params)
|
||||
while True:
|
||||
if isinstance(result, list):
|
||||
out.extend(result)
|
||||
return out
|
||||
if not isinstance(result, dict):
|
||||
return out
|
||||
out.extend(result.get("results", []) or [])
|
||||
next_url = result.get("next")
|
||||
if not next_url:
|
||||
return out
|
||||
# Follow absolute next URL
|
||||
try:
|
||||
result = self.session.get(next_url, timeout=self.timeout).json()
|
||||
except Exception: # pragma: no cover – defensive
|
||||
return out
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Connection test
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
def test_connection(self) -> dict:
|
||||
"""Verify API connectivity and authentication."""
|
||||
try:
|
||||
result = self._get(f"{API_PREFIX}/reports/")
|
||||
count = (
|
||||
result.get("count", len(result.get("results", [])))
|
||||
if isinstance(result, dict)
|
||||
else len(result)
|
||||
)
|
||||
return {
|
||||
"status": "ok",
|
||||
"base_url": self.base_url,
|
||||
"authenticated": True,
|
||||
"reports_found": count,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
}
|
||||
except AthenaAPIError as e:
|
||||
return {
|
||||
"status": "error",
|
||||
"base_url": self.base_url,
|
||||
"authenticated": e.status_code != 401,
|
||||
"error_code": e.status_code,
|
||||
"detail": e.detail,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Reports (templates)
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
def list_reports(self) -> list[dict]:
|
||||
"""List all TEI report templates (auto-paginated)."""
|
||||
return self._paginated(f"{API_PREFIX}/reports/")
|
||||
|
||||
def get_report(self, public_id: str) -> dict:
|
||||
"""Get a TEI report template by its public_id."""
|
||||
return self._get(f"{API_PREFIX}/reports/{public_id}/")
|
||||
|
||||
def list_fields(
|
||||
self,
|
||||
report_public_id: str,
|
||||
table: str | None = None,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Get field definitions for a report.
|
||||
|
||||
Args:
|
||||
report_public_id: The report template's public_id (12-char short UUID).
|
||||
table: Optional filter — ``'benefits'`` or ``'costs'``.
|
||||
|
||||
Returns a list of field-definition dicts. See ``TEIField.from_dict``
|
||||
for the expected shape.
|
||||
"""
|
||||
params = {"table": table} if table else None
|
||||
rows = self._paginated(
|
||||
f"{API_PREFIX}/reports/{report_public_id}/fields/", params=params
|
||||
)
|
||||
# Defensive — server-side filter may not be implemented; filter locally.
|
||||
if table:
|
||||
rows = [r for r in rows if r.get("table") == table]
|
||||
rows.sort(key=lambda r: (r.get("table", ""), r.get("sort_order") or 0))
|
||||
return rows
|
||||
|
||||
def create_field(self, report_public_id: str, field: dict) -> dict:
|
||||
"""Create a new field definition under a report (admin only)."""
|
||||
return self._post(
|
||||
f"{API_PREFIX}/reports/{report_public_id}/fields/", data=field
|
||||
)
|
||||
|
||||
def update_field(self, report_public_id: str, field_id: int, **changes) -> dict:
|
||||
"""Patch one field definition by its integer id."""
|
||||
return self._patch(
|
||||
f"{API_PREFIX}/reports/{report_public_id}/fields/{field_id}/",
|
||||
data=changes,
|
||||
)
|
||||
|
||||
def delete_field(self, report_public_id: str, field_id: int) -> dict:
|
||||
return self._delete(
|
||||
f"{API_PREFIX}/reports/{report_public_id}/fields/{field_id}/"
|
||||
)
|
||||
|
||||
def reorder_fields(self, report_public_id: str, field_ids: list[int]) -> dict:
|
||||
"""Bulk-reorder fields. Spec: PATCH /reports/{id}/fields/reorder/."""
|
||||
return self._patch(
|
||||
f"{API_PREFIX}/reports/{report_public_id}/fields/reorder/",
|
||||
data={"field_ids": field_ids},
|
||||
)
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Tools (instances)
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
def list_tools(self) -> list[dict]:
|
||||
"""List TEI tool instances owned by the current API key."""
|
||||
return self._paginated(f"{API_PREFIX}/tools/")
|
||||
|
||||
def get_tool(self, public_id: str) -> dict:
|
||||
"""Get a TEI tool instance by public_id."""
|
||||
return self._get(f"{API_PREFIX}/tools/{public_id}/")
|
||||
|
||||
def create_tool(
|
||||
self,
|
||||
report_public_id: str,
|
||||
proposal: int | None = None,
|
||||
engagement: int | None = None,
|
||||
name: str | None = None,
|
||||
status: str = "draft",
|
||||
) -> dict:
|
||||
"""
|
||||
Create a new TEI tool instance from a report template.
|
||||
|
||||
Athena scopes a TEI tool to a *Proposal* (which itself belongs to an
|
||||
Opportunity) and/or an *Engagement*. Pass the integer PK of either or
|
||||
both to link the tool.
|
||||
"""
|
||||
data: dict[str, Any] = {"report": report_public_id, "status": status}
|
||||
if proposal is not None:
|
||||
data["proposal"] = proposal
|
||||
if engagement is not None:
|
||||
data["engagement"] = engagement
|
||||
if name:
|
||||
data["name"] = name
|
||||
return self._post(f"{API_PREFIX}/tools/", data=data)
|
||||
|
||||
def update_tool(
|
||||
self,
|
||||
public_id: str,
|
||||
name: str | None = None,
|
||||
status: str | None = None,
|
||||
) -> dict:
|
||||
"""Update tool metadata. Only ``name`` and ``status`` are mutable."""
|
||||
data: dict[str, Any] = {}
|
||||
if name is not None:
|
||||
data["name"] = name
|
||||
if status is not None:
|
||||
data["status"] = status
|
||||
return self._patch(f"{API_PREFIX}/tools/{public_id}/", data=data)
|
||||
|
||||
def delete_tool(self, public_id: str) -> dict:
|
||||
return self._delete(f"{API_PREFIX}/tools/{public_id}/")
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Values (data entry)
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _normalize_value(value: dict) -> dict:
|
||||
"""
|
||||
Normalize a value-row dict into the shape the API expects.
|
||||
|
||||
Accepts any of the following input forms and produces a uniform
|
||||
wire-format dict::
|
||||
|
||||
# annual fields
|
||||
{"field_key": "A1", "year_1": 100, "year_2": 200, "year_3": 300, ...}
|
||||
{"field_key": "A1", "year_values": {"1": 100, "2": 200, "3": 300}, ...}
|
||||
|
||||
# non-annual scalars
|
||||
{"field_key": "rate", "value": 0.10, ...}
|
||||
|
||||
Returns a dict like::
|
||||
|
||||
{"field_key": "A1",
|
||||
"year_values": {"1": 100.0, "2": 200.0, "3": 300.0},
|
||||
"risk_adjustment": 0.15,
|
||||
"notes": "…"}
|
||||
"""
|
||||
out: dict[str, Any] = {}
|
||||
if "field_key" in value:
|
||||
out["field_key"] = value["field_key"]
|
||||
elif "field" in value:
|
||||
out["field_key"] = value["field"]
|
||||
|
||||
# Collect annual year_N keys into year_values
|
||||
year_values: dict[str, float] = {}
|
||||
if "year_values" in value and isinstance(value["year_values"], dict):
|
||||
for k, v in value["year_values"].items():
|
||||
year_values[str(k)] = float(v) if v is not None else 0.0
|
||||
for key, raw in value.items():
|
||||
if key.startswith("year_"):
|
||||
try:
|
||||
n = int(key.split("_", 1)[1])
|
||||
except ValueError:
|
||||
continue
|
||||
year_values[str(n)] = float(raw) if raw is not None else 0.0
|
||||
|
||||
if year_values:
|
||||
out["year_values"] = year_values
|
||||
if "value" in value and value["value"] is not None and not year_values:
|
||||
out["value"] = value["value"]
|
||||
if value.get("initial") is not None:
|
||||
out["initial"] = float(value["initial"])
|
||||
if value.get("risk_adjustment") is not None:
|
||||
out["risk_adjustment"] = float(value["risk_adjustment"])
|
||||
if value.get("notes"):
|
||||
out["notes"] = str(value["notes"])
|
||||
return out
|
||||
|
||||
def get_values(self, public_id: str) -> list[dict]:
|
||||
"""Get all current field values for a TEI tool instance."""
|
||||
result = self._get(f"{API_PREFIX}/tools/{public_id}/values/")
|
||||
if isinstance(result, dict):
|
||||
# Could be {"values": [...]} envelope, the TEITool wrapper, or a page
|
||||
if "values" in result and isinstance(result["values"], list):
|
||||
return result["values"]
|
||||
if "results" in result and isinstance(result["results"], list):
|
||||
return result["results"]
|
||||
return []
|
||||
if isinstance(result, list):
|
||||
return result
|
||||
return []
|
||||
|
||||
def update_values(self, public_id: str, values: list[dict]) -> dict:
|
||||
"""
|
||||
Bulk-update field values. See ``_normalize_value`` for accepted shapes.
|
||||
"""
|
||||
payload = {"values": [self._normalize_value(v) for v in values]}
|
||||
return self._put(f"{API_PREFIX}/tools/{public_id}/values/", data=payload)
|
||||
|
||||
def patch_value(self, public_id: str, field_key: str, **changes) -> dict:
|
||||
"""
|
||||
Patch a single field value by its ``field_key``.
|
||||
|
||||
Accepts the same shorthand as ``update_values`` (``year_1=…``, etc).
|
||||
"""
|
||||
body = self._normalize_value({"field_key": field_key, **changes})
|
||||
body.pop("field_key", None) # carried in URL
|
||||
return self._patch(
|
||||
f"{API_PREFIX}/tools/{public_id}/values/{field_key}/", data=body
|
||||
)
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Calculation & summary
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
def calculate(self, public_id: str) -> dict:
|
||||
"""Trigger server-side calculation; returns the updated summary."""
|
||||
return self._post(f"{API_PREFIX}/tools/{public_id}/calculate/")
|
||||
|
||||
def get_summary(self, public_id: str) -> dict:
|
||||
"""Return the most-recent summary (404 if never calculated)."""
|
||||
return self._get(f"{API_PREFIX}/tools/{public_id}/summary/")
|
||||
|
||||
def aggregate_summary(self) -> dict:
|
||||
"""Aggregate NPV across all tools owned by the current API key."""
|
||||
return self._get(f"{API_PREFIX}/summary/")
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Versions
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
def list_versions(self, public_id: str) -> list[dict]:
|
||||
"""List all saved version snapshots for a TEI tool."""
|
||||
result = self._get(f"{API_PREFIX}/tools/{public_id}/versions/")
|
||||
if isinstance(result, list):
|
||||
return result
|
||||
if isinstance(result, dict):
|
||||
if "results" in result and isinstance(result["results"], list):
|
||||
return result["results"]
|
||||
if "versions" in result and isinstance(result["versions"], list):
|
||||
return result["versions"]
|
||||
return []
|
||||
|
||||
def save_version(self, public_id: str, note: str = "") -> dict:
|
||||
"""Snapshot current values + summary as a new version."""
|
||||
return self._post(
|
||||
f"{API_PREFIX}/tools/{public_id}/versions/",
|
||||
data={"note": note},
|
||||
)
|
||||
|
||||
def get_version(self, public_id: str, version_number: int) -> dict:
|
||||
"""Get a single version's full snapshot."""
|
||||
return self._get(
|
||||
f"{API_PREFIX}/tools/{public_id}/versions/{int(version_number)}/"
|
||||
)
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Export
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
def export(self, public_id: str) -> dict:
|
||||
"""
|
||||
Return the LLM-ready export payload for the report pipeline.
|
||||
|
||||
The shape is determined by Athena and consumed by Peitho /
|
||||
html2docx; Palladium's ``core.export.report_data`` builds on this.
|
||||
"""
|
||||
return self._get(f"{API_PREFIX}/tools/{public_id}/export/")
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Convenience
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
def get_benefits(self, public_id: str) -> list[dict]:
|
||||
"""Return only benefit-table values (table='benefits')."""
|
||||
return [v for v in self.get_values(public_id) if v.get("table") == "benefits"]
|
||||
|
||||
def get_costs(self, public_id: str) -> list[dict]:
|
||||
"""Return only cost-table values (table='costs')."""
|
||||
return [v for v in self.get_values(public_id) if v.get("table") == "costs"]
|
||||
|
||||
def get_tool_with_data(self, public_id: str) -> dict:
|
||||
"""
|
||||
Bundle a tool, its field definitions, current values, and summary.
|
||||
|
||||
Convenience for notebook initialisation. The summary is allowed to
|
||||
404 (returned as ``None``) when the tool has never been calculated.
|
||||
"""
|
||||
tool = self.get_tool(public_id)
|
||||
report_pid = tool.get("report")
|
||||
if isinstance(report_pid, dict):
|
||||
report_pid = report_pid.get("id") or report_pid.get("public_id")
|
||||
fields = self.list_fields(report_pid) if report_pid else []
|
||||
values = self.get_values(public_id)
|
||||
try:
|
||||
summary = self.get_summary(public_id)
|
||||
except AthenaAPIError as e:
|
||||
if e.status_code == 404:
|
||||
summary = None
|
||||
else:
|
||||
raise
|
||||
return {
|
||||
"tool": tool,
|
||||
"fields": fields,
|
||||
"values": values,
|
||||
"summary": summary,
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────
|
||||
# Display
|
||||
# ─────────────────────────────────────────────
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover
|
||||
return f"TEIClient(base_url='{self.base_url}')"
|
||||
|
||||
def print_summary(self, public_id: str) -> None:
|
||||
"""Pretty-print a financial summary block for notebooks/REPL."""
|
||||
s = self.get_summary(public_id)
|
||||
|
||||
def _f(v: Any, default: float = 0.0) -> float:
|
||||
try:
|
||||
return float(v) if v is not None else default
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
print("═" * 56)
|
||||
print(" TEI Financial Summary")
|
||||
print("═" * 56)
|
||||
print(f" Total Benefits (PV): ${_f(s.get('total_benefits_pv')):>16,.0f}")
|
||||
print(f" Total Costs (PV): ${_f(s.get('total_costs_pv')):>16,.0f}")
|
||||
print("─" * 56)
|
||||
print(f" Net Present Value: ${_f(s.get('npv')):>16,.0f}")
|
||||
print(f" ROI: {_f(s.get('roi')):>15,.0f}%")
|
||||
payback = s.get("payback_months")
|
||||
payback_str = f"{_f(payback):.1f} months" if payback is not None else "N/A"
|
||||
print(f" Payback: {payback_str:>17}")
|
||||
print("═" * 56)
|
||||
194
core/tei_client/models.py
Normal file
194
core/tei_client/models.py
Normal file
@@ -0,0 +1,194 @@
|
||||
"""
|
||||
Lightweight dataclasses for TEI API responses.
|
||||
|
||||
These are *optional* — the client returns raw dicts. Use these when you want
|
||||
attribute access or IDE help in notebooks.
|
||||
|
||||
>>> from core.tei_client import TEIClient, TEIReport
|
||||
>>> raw = TEIClient().get_report("abc123")
|
||||
>>> report = TEIReport.from_dict(raw)
|
||||
>>> report.discount_rate
|
||||
0.10
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _as_float(value: Any, default: float = 0.0) -> float:
|
||||
"""Coerce Decimal/str/None into float."""
|
||||
if value is None or value == "":
|
||||
return default
|
||||
if isinstance(value, Decimal):
|
||||
return float(value)
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
@dataclass
|
||||
class TEIReport:
|
||||
"""A TEI report template (model definition)."""
|
||||
|
||||
id: str # public_id
|
||||
name: str
|
||||
vendor: str
|
||||
version: str
|
||||
description: str = ""
|
||||
analysis_period_years: int = 3
|
||||
discount_rate: float = 0.10
|
||||
status: str = "active"
|
||||
field_count: int = 0
|
||||
instance_count: int = 0
|
||||
created_at: str = ""
|
||||
updated_at: str = ""
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> TEIReport:
|
||||
return cls(
|
||||
id=str(data.get("id", "")),
|
||||
name=data.get("name", ""),
|
||||
vendor=data.get("vendor", ""),
|
||||
version=data.get("version", ""),
|
||||
description=data.get("description", "") or "",
|
||||
analysis_period_years=int(data.get("analysis_period_years") or 3),
|
||||
discount_rate=_as_float(data.get("discount_rate"), 0.10),
|
||||
status=data.get("status", "active"),
|
||||
field_count=int(data.get("field_count") or 0),
|
||||
instance_count=int(data.get("instance_count") or 0),
|
||||
created_at=data.get("created_at", "") or "",
|
||||
updated_at=data.get("updated_at", "") or "",
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TEIField:
|
||||
"""A field definition belonging to a TEI report template."""
|
||||
|
||||
id: int
|
||||
table: str # 'benefits' | 'costs'
|
||||
field_key: str
|
||||
label: str
|
||||
field_type: str # currency | percentage | integer | decimal | text
|
||||
description: str = ""
|
||||
category: str = ""
|
||||
default_value: str = ""
|
||||
is_annual: bool = True
|
||||
risk_adjustment: float | None = None
|
||||
sort_order: int = 0
|
||||
is_required: bool = False
|
||||
source_notes: str = ""
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> TEIField:
|
||||
ra = data.get("risk_adjustment")
|
||||
return cls(
|
||||
id=int(data.get("id") or 0),
|
||||
table=data.get("table", "benefits"),
|
||||
field_key=data.get("field_key", ""),
|
||||
label=data.get("label", ""),
|
||||
field_type=data.get("field_type", "decimal"),
|
||||
description=data.get("description", "") or "",
|
||||
category=data.get("category", "") or "",
|
||||
default_value=data.get("default_value", "") or "",
|
||||
is_annual=bool(data.get("is_annual", True)),
|
||||
risk_adjustment=_as_float(ra) if ra is not None else None,
|
||||
sort_order=int(data.get("sort_order") or 0),
|
||||
is_required=bool(data.get("is_required", False)),
|
||||
source_notes=data.get("source_notes", "") or "",
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TEIValue:
|
||||
"""
|
||||
A field value for a specific TEI tool instance.
|
||||
|
||||
The exact wire format is not fully pinned in the OpenAPI spec; we use a
|
||||
convention that the client `_normalize_value` helper builds:
|
||||
|
||||
- annual fields: {field_key, year_values: {"1": ..., "2": ...},
|
||||
risk_adjustment, notes}
|
||||
- non-annual scalar: {field_key, value, risk_adjustment, notes}
|
||||
"""
|
||||
|
||||
field_key: str
|
||||
year_values: dict[str, float] = field(default_factory=dict)
|
||||
value: float | None = None
|
||||
risk_adjustment: float | None = None
|
||||
notes: str = ""
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> TEIValue:
|
||||
ra = data.get("risk_adjustment")
|
||||
yv_raw = data.get("year_values") or {}
|
||||
year_values = {str(k): _as_float(v) for k, v in yv_raw.items()}
|
||||
v = data.get("value")
|
||||
return cls(
|
||||
field_key=data.get("field_key", ""),
|
||||
year_values=year_values,
|
||||
value=_as_float(v) if v is not None else None,
|
||||
risk_adjustment=_as_float(ra) if ra is not None else None,
|
||||
notes=data.get("notes", "") or "",
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
out: dict[str, Any] = {"field_key": self.field_key}
|
||||
if self.year_values:
|
||||
out["year_values"] = self.year_values
|
||||
if self.value is not None:
|
||||
out["value"] = self.value
|
||||
if self.risk_adjustment is not None:
|
||||
out["risk_adjustment"] = self.risk_adjustment
|
||||
if self.notes:
|
||||
out["notes"] = self.notes
|
||||
return out
|
||||
|
||||
|
||||
@dataclass
|
||||
class TEISummary:
|
||||
"""Calculated financial summary for a TEI tool instance."""
|
||||
|
||||
npv: float = 0.0
|
||||
roi: float = 0.0
|
||||
payback_months: float | None = None
|
||||
discount_rate: float = 0.10
|
||||
analysis_years: int = 3
|
||||
total_benefits_nominal: float = 0.0
|
||||
total_benefits_risk_adjusted: float = 0.0
|
||||
total_benefits_pv: float = 0.0
|
||||
total_costs_nominal: float = 0.0
|
||||
total_costs_risk_adjusted: float = 0.0
|
||||
total_costs_pv: float = 0.0
|
||||
yearly_breakdown: list[dict] = field(default_factory=list)
|
||||
category_breakdown: list[dict] = field(default_factory=list)
|
||||
raw: dict = field(default_factory=dict)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> TEISummary:
|
||||
return cls(
|
||||
npv=_as_float(data.get("npv")),
|
||||
roi=_as_float(data.get("roi")),
|
||||
payback_months=(
|
||||
_as_float(data.get("payback_months"))
|
||||
if data.get("payback_months") is not None
|
||||
else None
|
||||
),
|
||||
discount_rate=_as_float(data.get("discount_rate"), 0.10),
|
||||
analysis_years=int(data.get("analysis_years") or 3),
|
||||
total_benefits_nominal=_as_float(data.get("total_benefits_nominal")),
|
||||
total_benefits_risk_adjusted=_as_float(
|
||||
data.get("total_benefits_risk_adjusted")
|
||||
),
|
||||
total_benefits_pv=_as_float(data.get("total_benefits_pv")),
|
||||
total_costs_nominal=_as_float(data.get("total_costs_nominal")),
|
||||
total_costs_risk_adjusted=_as_float(data.get("total_costs_risk_adjusted")),
|
||||
total_costs_pv=_as_float(data.get("total_costs_pv")),
|
||||
yearly_breakdown=list(data.get("yearly_breakdown") or []),
|
||||
category_breakdown=list(data.get("category_breakdown") or []),
|
||||
raw=dict(data),
|
||||
)
|
||||
30709
docs/Athena API.yaml
Normal file
30709
docs/Athena API.yaml
Normal file
File diff suppressed because it is too large
Load Diff
7
palladium/__init__.py
Normal file
7
palladium/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""
|
||||
Palladium CLI shim — exposes ``python -m palladium``.
|
||||
|
||||
The actual logic lives in :mod:`core.cli.main`. This package exists so the
|
||||
README's command line interface (``python -m palladium test``) works without
|
||||
clashing with the top-level project name.
|
||||
"""
|
||||
6
palladium/__main__.py
Normal file
6
palladium/__main__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""``python -m palladium`` entrypoint."""
|
||||
|
||||
from core.cli.main import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
49
pyproject.toml
Normal file
49
pyproject.toml
Normal file
@@ -0,0 +1,49 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "palladium"
|
||||
version = "0.1.0"
|
||||
description = "TEI (Total Economic Impact) Calculator — Palladium"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
license = {file = "LICENSE"}
|
||||
authors = [{name = "NTT Data"}]
|
||||
dependencies = [
|
||||
"requests>=2.31",
|
||||
"python-dotenv>=1.0",
|
||||
"pandas>=2.0",
|
||||
"plotly>=5.18",
|
||||
"numpy>=1.26",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
notebooks = ["jupyter>=1.0", "ipython>=8.0"]
|
||||
app = ["streamlit>=1.30"]
|
||||
dev = ["pytest>=7.4", "ruff>=0.1"]
|
||||
|
||||
[project.scripts]
|
||||
palladium = "core.cli.main:main"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["core*", "palladium*"]
|
||||
exclude = ["tests*", "studies*", "app*", "docs*"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
python_files = ["test_*.py"]
|
||||
addopts = "-q"
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py311"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "B", "UP", "W"]
|
||||
ignore = ["E501"] # line length handled by formatter
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"studies/*/notebooks/*.ipynb" = ["E402"]
|
||||
"tests/*" = ["F401"]
|
||||
"app/main.py" = ["E402"] # sys.path bootstrap before app imports
|
||||
9
requirements.txt
Normal file
9
requirements.txt
Normal file
@@ -0,0 +1,9 @@
|
||||
requests>=2.31
|
||||
python-dotenv>=1.0
|
||||
jupyter>=1.0
|
||||
streamlit>=1.30
|
||||
pandas>=2.0
|
||||
plotly>=5.18
|
||||
numpy>=1.26
|
||||
pytest>=7.4
|
||||
ruff>=0.1
|
||||
71
studies/202602_AmazonConnect/README.md
Normal file
71
studies/202602_AmazonConnect/README.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# 202602 — Amazon Connect TEI
|
||||
|
||||
Self-contained TEI study folder. All data, notebooks, and exports for the
|
||||
Forrester *Total Economic Impact™ Of Amazon Connect* (February 2026,
|
||||
commissioned by AWS) live here.
|
||||
|
||||
## Source
|
||||
|
||||
The full Forrester study is at [`docs/202602_TEI Report Amazon Connect.pdf`](docs/202602_TEI%20Report%20Amazon%20Connect.pdf).
|
||||
|
||||
Key composite numbers reproduced in `seed_data.py`:
|
||||
|
||||
| Metric | Value |
|
||||
|---|---|
|
||||
| ROI | **342%** |
|
||||
| NPV | **$78.7M** |
|
||||
| Benefits PV | $101.7M |
|
||||
| Costs PV | $23.0M |
|
||||
| Payback | <6 months |
|
||||
| Discount rate | 10% |
|
||||
| Analysis period | 3 years |
|
||||
|
||||
## Composite organization
|
||||
|
||||
* Global B2C, ~$10B revenue (Y1), 30% YoY growth
|
||||
* 2,000 contact-center agents, 200 supervisors
|
||||
* 20M annual contacts (75% calls, 25% chat)
|
||||
* 10-min average handle time
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
202602_AmazonConnect/
|
||||
├── README.md ← this file
|
||||
├── config.py ← TOOL_PUBLIC_ID, REPORT_PUBLIC_ID, study slug
|
||||
├── seed_data.py ← BENEFITS, COSTS, ASSUMPTIONS as Python dicts
|
||||
├── notebooks/
|
||||
│ ├── 01_benefits.ipynb ← quantify the 5 benefits, push to Athena
|
||||
│ ├── 02_costs.ipynb ← quantify the 3 costs
|
||||
│ ├── 03_business_case.ipynb ← /calculate, charts, scenarios
|
||||
│ └── 04_export.ipynb ← /export → exports/export.json
|
||||
├── exports/ ← generated; .gitignored
|
||||
└── docs/
|
||||
└── 202602_TEI Report Amazon Connect.pdf
|
||||
```
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Set up credentials** in the project root `.env` (see `.env.example`).
|
||||
2. **Create / link the TEI tool** in Athena, then put its `public_id` in
|
||||
[`config.py`](config.py).
|
||||
3. **Open `notebooks/01_benefits.ipynb`** and run all — pushes the 5
|
||||
benefit rows from `seed_data.py` into Athena.
|
||||
4. **`02_costs.ipynb`** — pushes the 3 cost rows.
|
||||
5. **`03_business_case.ipynb`** — calls `/calculate`, renders the cash
|
||||
flow chart, runs scenario analysis. Should reproduce the PDF's
|
||||
$78.7M NPV / 342% ROI.
|
||||
6. **`04_export.ipynb`** — writes `exports/export.json` for the report
|
||||
pipeline.
|
||||
|
||||
## Adding a new study
|
||||
|
||||
Copy this folder, rename to `YYYYMM_<Vendor><Solution>`, and:
|
||||
|
||||
1. Replace `seed_data.py` with your benefits/costs.
|
||||
2. Update `config.py` with the new tool/report public IDs.
|
||||
3. Tweak the notebooks' narrative; the helper imports are the same.
|
||||
|
||||
The only thing that changes between studies is the **data** and the
|
||||
**narrative prose** in the notebooks. All math, charts, and API calls
|
||||
come from `core/`.
|
||||
0
studies/202602_AmazonConnect/__init__.py
Normal file
0
studies/202602_AmazonConnect/__init__.py
Normal file
39
studies/202602_AmazonConnect/config.py
Normal file
39
studies/202602_AmazonConnect/config.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""
|
||||
Study configuration for the Amazon Connect TEI (February 2026).
|
||||
|
||||
Set ``TOOL_PUBLIC_ID`` to the public_id of the live TEI tool instance in
|
||||
Athena once it has been created. ``REPORT_PUBLIC_ID`` is the template
|
||||
this tool was created from (Athena admin sets up Report templates).
|
||||
|
||||
Until both are filled in, the notebooks fall back to local-only mode:
|
||||
they compute summaries from ``seed_data.py`` using ``core.calculations``
|
||||
and skip the network round-trip.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
#: Human-friendly study identifier — used in export metadata + filenames.
|
||||
STUDY_SLUG = "202602_AmazonConnect"
|
||||
|
||||
#: TEI Report template public_id (12-char short UUID). Provisioned in
|
||||
#: Athena admin → TEI → Reports.
|
||||
REPORT_PUBLIC_ID: str = os.getenv("PALLADIUM_REPORT_PUBLIC_ID", "")
|
||||
|
||||
#: TEI Tool instance public_id. Created via the API
|
||||
#: (``client.create_tool``) or the Streamlit app sidebar.
|
||||
TOOL_PUBLIC_ID: str = os.getenv("PALLADIUM_TOOL_PUBLIC_ID", "")
|
||||
|
||||
#: Default discount rate used for local validation of the study numbers.
|
||||
DISCOUNT_RATE = 0.10
|
||||
|
||||
#: Analysis horizon (years).
|
||||
ANALYSIS_YEARS = 3
|
||||
|
||||
#: Optional Athena Proposal ID this tool is linked to (when known).
|
||||
PROPOSAL_ID: int | None = (
|
||||
int(os.environ["PALLADIUM_PROPOSAL_ID"])
|
||||
if os.getenv("PALLADIUM_PROPOSAL_ID")
|
||||
else None
|
||||
)
|
||||
Binary file not shown.
0
studies/202602_AmazonConnect/exports/.gitkeep
Normal file
0
studies/202602_AmazonConnect/exports/.gitkeep
Normal file
269
studies/202602_AmazonConnect/notebooks/01_benefits.ipynb
Normal file
269
studies/202602_AmazonConnect/notebooks/01_benefits.ipynb
Normal file
@@ -0,0 +1,269 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "231c773a",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# 01 — Benefits Analysis\n",
|
||||
"\n",
|
||||
"**Study:** Forrester *Total Economic Impact™ Of Amazon Connect* (Feb 2026)\n",
|
||||
"\n",
|
||||
"Quantify the five benefit categories Forrester identified for the\n",
|
||||
"composite organization, push them into Athena, and verify the totals\n",
|
||||
"match the published study (Benefits PV ≈ **$101.7M**)."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "110d7e61",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Setup\n",
|
||||
"\n",
|
||||
"We add the project root to `sys.path` so the notebook can import `core` and\n",
|
||||
"the study's local modules without `pip install -e .`."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 1,
|
||||
"id": "c83c2758",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"Project root: /home/robert/notebook/git/palladium\n",
|
||||
"Study root: /home/robert/notebook/git/palladium/studies/202602_AmazonConnect\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"import sys\n",
|
||||
"from pathlib import Path\n",
|
||||
"\n",
|
||||
"ROOT = Path.cwd().resolve()\n",
|
||||
"while ROOT != ROOT.parent and not (ROOT / 'core').is_dir():\n",
|
||||
" ROOT = ROOT.parent\n",
|
||||
"if str(ROOT) not in sys.path:\n",
|
||||
" sys.path.insert(0, str(ROOT))\n",
|
||||
"\n",
|
||||
"STUDY = ROOT / 'studies' / '202602_AmazonConnect'\n",
|
||||
"if str(STUDY) not in sys.path:\n",
|
||||
" sys.path.insert(0, str(STUDY))\n",
|
||||
"print(f'Project root: {ROOT}')\n",
|
||||
"print(f'Study root: {STUDY}')"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 2,
|
||||
"id": "c371ef85",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"ename": "ModuleNotFoundError",
|
||||
"evalue": "No module named 'pandas'",
|
||||
"output_type": "error",
|
||||
"traceback": [
|
||||
"\u001b[31m---------------------------------------------------------------------------\u001b[39m",
|
||||
"\u001b[31mModuleNotFoundError\u001b[39m Traceback (most recent call last)",
|
||||
"\u001b[36mCell\u001b[39m\u001b[36m \u001b[39m\u001b[32mIn[2]\u001b[39m\u001b[32m, line 4\u001b[39m\n\u001b[32m 1\u001b[39m \u001b[38;5;28;01mimport\u001b[39;00m config\n\u001b[32m 2\u001b[39m \u001b[38;5;28;01mimport\u001b[39;00m seed_data\n\u001b[32m 3\u001b[39m \u001b[38;5;28;01mfrom\u001b[39;00m core.calculations \u001b[38;5;28;01mimport\u001b[39;00m npv, risk_adjust_benefit\n\u001b[32m----> \u001b[39m\u001b[32m4\u001b[39m \u001b[38;5;28;01mfrom\u001b[39;00m core.notebook_helpers \u001b[38;5;28;01mimport\u001b[39;00m charts, display, tables\n\u001b[32m 5\u001b[39m \n\u001b[32m 6\u001b[39m display.alert(\n\u001b[32m 7\u001b[39m f'Study: <b>{config.STUDY_SLUG}</b> • discount rate {config.DISCOUNT_RATE:.0%} '\n",
|
||||
"\u001b[36mFile \u001b[39m\u001b[32m~/notebook/git/palladium/core/notebook_helpers/__init__.py:3\u001b[39m\n\u001b[32m 1\u001b[39m \u001b[33;03m\"\"\"Notebook helpers — pandas tables, plotly charts, IPython display.\"\"\"\u001b[39;00m\n\u001b[32m----> \u001b[39m\u001b[32m3\u001b[39m \u001b[38;5;28;01mfrom\u001b[39;00m\u001b[38;5;250m \u001b[39m\u001b[34;01mcore\u001b[39;00m\u001b[34;01m.\u001b[39;00m\u001b[34;01mnotebook_helpers\u001b[39;00m\u001b[38;5;250m \u001b[39m\u001b[38;5;28;01mimport\u001b[39;00m charts, display, tables\n\u001b[32m 5\u001b[39m __all__ = [\u001b[33m\"\u001b[39m\u001b[33mcharts\u001b[39m\u001b[33m\"\u001b[39m, \u001b[33m\"\u001b[39m\u001b[33mdisplay\u001b[39m\u001b[33m\"\u001b[39m, \u001b[33m\"\u001b[39m\u001b[33mtables\u001b[39m\u001b[33m\"\u001b[39m]\n",
|
||||
"\u001b[36mFile \u001b[39m\u001b[32m~/notebook/git/palladium/core/notebook_helpers/tables.py:13\u001b[39m\n\u001b[32m 9\u001b[39m \u001b[38;5;28;01mfrom\u001b[39;00m\u001b[38;5;250m \u001b[39m\u001b[34;01m__future__\u001b[39;00m\u001b[38;5;250m \u001b[39m\u001b[38;5;28;01mimport\u001b[39;00m annotations\n\u001b[32m 11\u001b[39m \u001b[38;5;28;01mfrom\u001b[39;00m\u001b[38;5;250m \u001b[39m\u001b[34;01mtyping\u001b[39;00m\u001b[38;5;250m \u001b[39m\u001b[38;5;28;01mimport\u001b[39;00m Any, Iterable\n\u001b[32m---> \u001b[39m\u001b[32m13\u001b[39m \u001b[38;5;28;01mimport\u001b[39;00m\u001b[38;5;250m \u001b[39m\u001b[34;01mpandas\u001b[39;00m\u001b[38;5;250m \u001b[39m\u001b[38;5;28;01mas\u001b[39;00m\u001b[38;5;250m \u001b[39m\u001b[34;01mpd\u001b[39;00m\n\u001b[32m 15\u001b[39m \u001b[38;5;28;01mfrom\u001b[39;00m\u001b[38;5;250m \u001b[39m\u001b[34;01mcore\u001b[39;00m\u001b[34;01m.\u001b[39;00m\u001b[34;01mcalculations\u001b[39;00m\u001b[38;5;250m \u001b[39m\u001b[38;5;28;01mimport\u001b[39;00m risk_adjust_benefit, risk_adjust_cost\n\u001b[32m 18\u001b[39m \u001b[38;5;28;01mdef\u001b[39;00m\u001b[38;5;250m \u001b[39m\u001b[34m_years_in_data\u001b[39m(items: Iterable[\u001b[38;5;28mdict\u001b[39m]) -> \u001b[38;5;28mlist\u001b[39m[\u001b[38;5;28mint\u001b[39m]:\n",
|
||||
"\u001b[31mModuleNotFoundError\u001b[39m: No module named 'pandas'"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"import config\n",
|
||||
"import seed_data\n",
|
||||
"from core.calculations import npv, risk_adjust_benefit\n",
|
||||
"from core.notebook_helpers import charts, display, tables\n",
|
||||
"\n",
|
||||
"display.alert(\n",
|
||||
" f'Study: <b>{config.STUDY_SLUG}</b> • discount rate {config.DISCOUNT_RATE:.0%} '\n",
|
||||
" f'• {config.ANALYSIS_YEARS}-year horizon',\n",
|
||||
" 'info',\n",
|
||||
")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "fd94503d",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Benefits — nominal & risk-adjusted\n",
|
||||
"\n",
|
||||
"Forrester quantifies five benefit categories:\n",
|
||||
"\n",
|
||||
"| Ref | Benefit | Y1 | Y2 | Y3 | Risk Adj |\n",
|
||||
"|---|---|---|---|---|---|\n",
|
||||
"| At | AI-driven contact resolution efficiency | $13.9M | $23.9M | $37.8M | 15% |\n",
|
||||
"| Bt | AI-powered content & sentiment analysis | $4.6M | $5.4M | $6.3M | 15% |\n",
|
||||
"| Ct | AI-enabled forecasting & supervision | $6.7M | $9.1M | $12.4M | 15% |\n",
|
||||
"| Dt | Data-driven profit lift (conversion +20%) | $1.2M | $1.6M | $2.0M | 20% |\n",
|
||||
"| Et | Legacy solution cost savings | $6.2M | $8.0M | $10.4M | 20% |\n",
|
||||
"\n",
|
||||
"All five are seeded in `seed_data.BENEFITS` with full source notes."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "6177ea7c",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"df = tables.benefits_table(seed_data.BENEFITS)\n",
|
||||
"df.style.format({col: '${:,.0f}' for col in df.columns if col not in ('field_key','label','category','risk_adjustment')})"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "573f12d8",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Local validation against the PDF\n",
|
||||
"\n",
|
||||
"Re-derive the per-benefit risk-adjusted PV and confirm we land on Forrester's\n",
|
||||
"**$101,696,791** total within rounding."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "8cf32003",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"import pandas as pd\n",
|
||||
"\n",
|
||||
"rows = []\n",
|
||||
"for b in seed_data.BENEFITS:\n",
|
||||
" rf = b['risk_adjustment']\n",
|
||||
" yr = [b['year_values'][str(y)] for y in (1, 2, 3)]\n",
|
||||
" yr_ra = [risk_adjust_benefit(v, rf) for v in yr]\n",
|
||||
" pv = npv(yr_ra, config.DISCOUNT_RATE)\n",
|
||||
" rows.append({\n",
|
||||
" 'Benefit': b['label'],\n",
|
||||
" 'Y1 (RA)': yr_ra[0],\n",
|
||||
" 'Y2 (RA)': yr_ra[1],\n",
|
||||
" 'Y3 (RA)': yr_ra[2],\n",
|
||||
" 'PV': pv,\n",
|
||||
" })\n",
|
||||
"df_check = pd.DataFrame(rows)\n",
|
||||
"df_check.loc[len(df_check)] = ['TOTAL', df_check['Y1 (RA)'].sum(), df_check['Y2 (RA)'].sum(), df_check['Y3 (RA)'].sum(), df_check['PV'].sum()]\n",
|
||||
"df_check.style.format({c: '${:,.0f}' for c in df_check.columns if c != 'Benefit'})"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "3ded50c8",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"expected_pv = 101_696_791\n",
|
||||
"computed_pv = df_check.iloc[-1]['PV']\n",
|
||||
"delta = computed_pv - expected_pv\n",
|
||||
"kind = 'success' if abs(delta) < 1_000 else 'warning'\n",
|
||||
"display.alert(\n",
|
||||
" f'Computed Benefits PV: <b>${computed_pv:,.0f}</b><br>'\n",
|
||||
" f'Forrester target: <b>${expected_pv:,.0f}</b><br>'\n",
|
||||
" f'Δ = ${delta:,.0f} (rounding)',\n",
|
||||
" kind,\n",
|
||||
")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "a5ad453a",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Visualize\n",
|
||||
"\n",
|
||||
"Horizontal bar chart of risk-adjusted three-year totals — mirrors the PDF p.6\n",
|
||||
"*Benefits (Three-Year)* graphic."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "452b8408",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"charts.benefits_bar(seed_data.BENEFITS).show()"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "1c4591f5",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Push to Athena\n",
|
||||
"\n",
|
||||
"When `config.TOOL_PUBLIC_ID` is set, persist the seed values to the live\n",
|
||||
"TEI tool. Otherwise this cell is a no-op so the notebook still runs\n",
|
||||
"offline."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "d10a54b6",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"if config.TOOL_PUBLIC_ID:\n",
|
||||
" from core.tei_client import TEIClient\n",
|
||||
"\n",
|
||||
" client = TEIClient()\n",
|
||||
" result = client.update_values(config.TOOL_PUBLIC_ID, seed_data.BENEFITS)\n",
|
||||
" display.alert(f'Pushed {len(seed_data.BENEFITS)} benefit rows to '\n",
|
||||
" f'tool <code>{config.TOOL_PUBLIC_ID}</code>.', 'success')\n",
|
||||
"else:\n",
|
||||
" display.alert(\n",
|
||||
" 'No TOOL_PUBLIC_ID set in config.py — skipped Athena push. '\n",
|
||||
" 'Set <code>PALLADIUM_TOOL_PUBLIC_ID</code> in your environment '\n",
|
||||
" 'or edit config.py to enable.',\n",
|
||||
" 'info',\n",
|
||||
" )"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "78693c14",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"---\n",
|
||||
"\n",
|
||||
"Continue with [`02_costs.ipynb`](02_costs.ipynb) →"
|
||||
]
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"kernelspec": {
|
||||
"display_name": "Python 3 (ipykernel)",
|
||||
"language": "python",
|
||||
"name": "python3"
|
||||
},
|
||||
"language_info": {
|
||||
"codemirror_mode": {
|
||||
"name": "ipython",
|
||||
"version": 3
|
||||
},
|
||||
"file_extension": ".py",
|
||||
"mimetype": "text/x-python",
|
||||
"name": "python",
|
||||
"nbconvert_exporter": "python",
|
||||
"pygments_lexer": "ipython3",
|
||||
"version": "3.13.7"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5
|
||||
}
|
||||
213
studies/202602_AmazonConnect/notebooks/02_costs.ipynb
Normal file
213
studies/202602_AmazonConnect/notebooks/02_costs.ipynb
Normal file
@@ -0,0 +1,213 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "1a76b7ed",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# 02 — Costs Analysis\n",
|
||||
"\n",
|
||||
"**Study:** Forrester TEI™ Of Amazon Connect (Feb 2026)\n",
|
||||
"\n",
|
||||
"Three cost categories, three-year horizon, 10% discount rate.\n",
|
||||
"Target risk-adjusted PV = **$22,983,076**."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "46446223",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"import sys\n",
|
||||
"from pathlib import Path\n",
|
||||
"\n",
|
||||
"ROOT = Path.cwd().resolve()\n",
|
||||
"while ROOT != ROOT.parent and not (ROOT / 'core').is_dir():\n",
|
||||
" ROOT = ROOT.parent\n",
|
||||
"if str(ROOT) not in sys.path:\n",
|
||||
" sys.path.insert(0, str(ROOT))\n",
|
||||
"STUDY = ROOT / 'studies' / '202602_AmazonConnect'\n",
|
||||
"if str(STUDY) not in sys.path:\n",
|
||||
" sys.path.insert(0, str(STUDY))"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "4ec64198",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"import config\n",
|
||||
"import seed_data\n",
|
||||
"from core.calculations import npv, risk_adjust_cost\n",
|
||||
"from core.notebook_helpers import charts, display, tables"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "26f1d385",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Costs — nominal & risk-adjusted\n",
|
||||
"\n",
|
||||
"| Ref | Cost | Initial | Y1 | Y2 | Y3 | Risk Adj |\n",
|
||||
"|---|---|---|---|---|---|---|\n",
|
||||
"| Ft | Amazon Connect usage | — | $6.5M | $8.0M | $9.8M | ↑5% |\n",
|
||||
"| Gt | Implementation & migration | $1.09M | $188K | $188K | — | ↑10% |\n",
|
||||
"| Ht | Ongoing management | — | $256K | $187K | $187K | ↑15% |\n",
|
||||
"\n",
|
||||
"Note **costs are risk-adjusted *upward*** (higher risk → higher modelled cost)."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "9635f334",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"df = tables.costs_table(seed_data.COSTS)\n",
|
||||
"df.style.format({c: '${:,.0f}' for c in df.columns if c not in ('field_key','label','category','risk_adjustment')})"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "0667d1da",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Local validation\n",
|
||||
"\n",
|
||||
"Reproduce the **$22,983,076** Costs PV from the PDF Cash Flow Analysis."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "3e35a794",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"import pandas as pd\n",
|
||||
"\n",
|
||||
"rows = []\n",
|
||||
"for c in seed_data.COSTS:\n",
|
||||
" rf = c['risk_adjustment']\n",
|
||||
" init_ra = risk_adjust_cost(c.get('initial') or 0, rf)\n",
|
||||
" yr = [c['year_values'][str(y)] for y in (1, 2, 3)]\n",
|
||||
" yr_ra = [risk_adjust_cost(v, rf) for v in yr]\n",
|
||||
" pv = npv(yr_ra, config.DISCOUNT_RATE, initial=init_ra)\n",
|
||||
" rows.append({\n",
|
||||
" 'Cost': c['label'],\n",
|
||||
" 'Initial (RA)': init_ra,\n",
|
||||
" 'Y1 (RA)': yr_ra[0],\n",
|
||||
" 'Y2 (RA)': yr_ra[1],\n",
|
||||
" 'Y3 (RA)': yr_ra[2],\n",
|
||||
" 'PV': pv,\n",
|
||||
" })\n",
|
||||
"df_check = pd.DataFrame(rows)\n",
|
||||
"totals = df_check.drop(columns='Cost').sum()\n",
|
||||
"df_check.loc[len(df_check)] = ['TOTAL'] + totals.tolist()\n",
|
||||
"df_check.style.format({c: '${:,.0f}' for c in df_check.columns if c != 'Cost'})"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "4109784e",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"expected_pv = 22_983_076\n",
|
||||
"computed_pv = df_check.iloc[-1]['PV']\n",
|
||||
"delta = computed_pv - expected_pv\n",
|
||||
"kind = 'success' if abs(delta) < 1_000 else 'warning'\n",
|
||||
"display.alert(\n",
|
||||
" f'Computed Costs PV: <b>${computed_pv:,.0f}</b><br>'\n",
|
||||
" f'Forrester target: <b>${expected_pv:,.0f}</b><br>'\n",
|
||||
" f'Δ = ${delta:,.0f}',\n",
|
||||
" kind,\n",
|
||||
")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "dd1b3c04",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Cost mix\n",
|
||||
"\n",
|
||||
"Most of the three-year cost (~90%) is Amazon Connect *usage* (Ft) —\n",
|
||||
"consistent with the PDF's framing that consumption-based pricing dominates,\n",
|
||||
"with implementation a one-time investment."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "90e9b5e2",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"charts.cost_breakdown_pie(seed_data.COSTS).show()"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "3d15ae10",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Push to Athena"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "03547040",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"if config.TOOL_PUBLIC_ID:\n",
|
||||
" from core.tei_client import TEIClient\n",
|
||||
"\n",
|
||||
" client = TEIClient()\n",
|
||||
" client.update_values(config.TOOL_PUBLIC_ID, seed_data.COSTS)\n",
|
||||
" display.alert(f'Pushed {len(seed_data.COSTS)} cost rows to '\n",
|
||||
" f'tool <code>{config.TOOL_PUBLIC_ID}</code>.', 'success')\n",
|
||||
"else:\n",
|
||||
" display.alert('No TOOL_PUBLIC_ID set — skipped Athena push.', 'info')"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "6f5befbb",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"Continue with [`03_business_case.ipynb`](03_business_case.ipynb) →"
|
||||
]
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"kernelspec": {
|
||||
"display_name": "Python 3 (ipykernel)",
|
||||
"language": "python",
|
||||
"name": "python3"
|
||||
},
|
||||
"language_info": {
|
||||
"codemirror_mode": {
|
||||
"name": "ipython",
|
||||
"version": 3
|
||||
},
|
||||
"file_extension": ".py",
|
||||
"mimetype": "text/x-python",
|
||||
"name": "python",
|
||||
"nbconvert_exporter": "python",
|
||||
"pygments_lexer": "ipython3",
|
||||
"version": "3.13.7"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5
|
||||
}
|
||||
221
studies/202602_AmazonConnect/notebooks/03_business_case.ipynb
Normal file
221
studies/202602_AmazonConnect/notebooks/03_business_case.ipynb
Normal file
@@ -0,0 +1,221 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# 03 — Business Case\n",
|
||||
"\n",
|
||||
"Combine the benefits and costs into the consolidated TEI summary,\n",
|
||||
"render the Cash Flow chart, and run scenario analysis. This notebook\n",
|
||||
"should reproduce the headline numbers from the PDF Financial Summary:\n",
|
||||
"\n",
|
||||
"* **NPV $78.7M • ROI 342% • Payback <6 months**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"import sys\n",
|
||||
"from pathlib import Path\n",
|
||||
"\n",
|
||||
"ROOT = Path.cwd().resolve()\n",
|
||||
"while ROOT != ROOT.parent and not (ROOT / 'core').is_dir():\n",
|
||||
" ROOT = ROOT.parent\n",
|
||||
"if str(ROOT) not in sys.path:\n",
|
||||
" sys.path.insert(0, str(ROOT))\n",
|
||||
"STUDY = ROOT / 'studies' / '202602_AmazonConnect'\n",
|
||||
"if str(STUDY) not in sys.path:\n",
|
||||
" sys.path.insert(0, str(STUDY))"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"import config\n",
|
||||
"import seed_data\n",
|
||||
"from core.export import build_report_data\n",
|
||||
"from core.export.report_data import _compute_summary\n",
|
||||
"from core.notebook_helpers import charts, display, tables"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Local summary (no Athena round-trip)\n",
|
||||
"\n",
|
||||
"Compute the moderate-case TEI summary directly from `seed_data` so the\n",
|
||||
"notebook produces results even before the Athena tool is provisioned."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"summary = _compute_summary(\n",
|
||||
" seed_data.BENEFITS,\n",
|
||||
" seed_data.COSTS,\n",
|
||||
" config.DISCOUNT_RATE,\n",
|
||||
" config.ANALYSIS_YEARS,\n",
|
||||
")\n",
|
||||
"# `_compute_summary` returns roi_pct; expose it as `roi` for kpi_cards.\n",
|
||||
"summary['roi'] = summary.get('roi_pct')\n",
|
||||
"display.kpi_cards(summary, title='Forrester composite — moderate case')"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"df_cash = tables.cashflow_table(summary)\n",
|
||||
"df_cash.style.format({c: '${:,.0f}' for c in df_cash.columns if c != 'Year'})"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Cash flow chart\n",
|
||||
"\n",
|
||||
"Mirrors the chart on PDF page 25: stacked benefits/costs by year +\n",
|
||||
"cumulative-net line."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"charts.cashflow_chart(\n",
|
||||
" summary['yearly_breakdown'],\n",
|
||||
" initial_cost=summary.get('initial_costs', 0),\n",
|
||||
").show()"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Waterfall: Benefits PV → Costs PV → NPV"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"charts.waterfall([\n",
|
||||
" ('Benefits PV', summary['total_benefits_pv']),\n",
|
||||
" ('Costs PV', -summary['total_costs_pv']),\n",
|
||||
" ('NPV', summary['npv']),\n",
|
||||
"]).show()"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Scenario analysis\n",
|
||||
"\n",
|
||||
"Apply the default Palladium multipliers (see `core.calculations.SCENARIOS`):\n",
|
||||
"\n",
|
||||
"* **Conservative** — 80% adoption, +10pp risk on benefits / -10pp on costs\n",
|
||||
"* **Moderate** — base case (= the published Forrester study)\n",
|
||||
"* **Aggressive** — 115% adoption, -5pp risk on benefits / +5pp on costs"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"from core.calculations import apply_scenario\n",
|
||||
"import pandas as pd\n",
|
||||
"\n",
|
||||
"scenario_summaries = {}\n",
|
||||
"for name in ('conservative', 'moderate', 'aggressive'):\n",
|
||||
" sb = apply_scenario(seed_data.BENEFITS, name, table='benefits')\n",
|
||||
" sc = apply_scenario(seed_data.COSTS, name, table='costs')\n",
|
||||
" scenario_summaries[name] = _compute_summary(sb, sc, config.DISCOUNT_RATE, config.ANALYSIS_YEARS)\n",
|
||||
"\n",
|
||||
"scen_df = pd.DataFrame([\n",
|
||||
" {\n",
|
||||
" 'Scenario': k,\n",
|
||||
" 'Benefits PV': v['total_benefits_pv'],\n",
|
||||
" 'Costs PV': v['total_costs_pv'],\n",
|
||||
" 'NPV': v['npv'],\n",
|
||||
" 'ROI %': v['roi_pct'],\n",
|
||||
" 'Payback (mo)': round(v['payback_months'], 1) if v['payback_months'] is not None else None,\n",
|
||||
" }\n",
|
||||
" for k, v in scenario_summaries.items()\n",
|
||||
"])\n",
|
||||
"scen_df.style.format({\n",
|
||||
" 'Benefits PV': '${:,.0f}', 'Costs PV': '${:,.0f}', 'NPV': '${:,.0f}', 'ROI %': '{:,.0f}%'\n",
|
||||
"})"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"charts.scenario_comparison(scenario_summaries).show()"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Cross-check vs Athena (optional)\n",
|
||||
"\n",
|
||||
"When `TOOL_PUBLIC_ID` is set, ask Athena to recalculate the summary on\n",
|
||||
"the server side and confirm it matches our local computation."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"if config.TOOL_PUBLIC_ID:\n",
|
||||
" from core.tei_client import TEIClient\n",
|
||||
"\n",
|
||||
" client = TEIClient()\n",
|
||||
" client.calculate(config.TOOL_PUBLIC_ID)\n",
|
||||
" server_summary = client.get_summary(config.TOOL_PUBLIC_ID)\n",
|
||||
" display.kpi_cards(server_summary, title='Athena server-side summary')\n",
|
||||
"else:\n",
|
||||
" display.alert('Set TOOL_PUBLIC_ID to compare Athena vs local.', 'info')"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"Continue with [`04_export.ipynb`](04_export.ipynb) →"
|
||||
]
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"kernelspec": {"display_name": "Python 3", "language": "python", "name": "python3"},
|
||||
"language_info": {"name": "python", "version": "3.11"}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5
|
||||
}
|
||||
195
studies/202602_AmazonConnect/notebooks/04_export.ipynb
Normal file
195
studies/202602_AmazonConnect/notebooks/04_export.ipynb
Normal file
@@ -0,0 +1,195 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "15a4163e",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# 04 — Export for the report pipeline\n",
|
||||
"\n",
|
||||
"Build the structured JSON envelope consumed by the html2docx report\n",
|
||||
"generation pipeline (Peitho). Output goes to `exports/export.json`."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "18f02ef8",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"import sys\n",
|
||||
"from pathlib import Path\n",
|
||||
"\n",
|
||||
"ROOT = Path.cwd().resolve()\n",
|
||||
"while ROOT != ROOT.parent and not (ROOT / 'core').is_dir():\n",
|
||||
" ROOT = ROOT.parent\n",
|
||||
"if str(ROOT) not in sys.path:\n",
|
||||
" sys.path.insert(0, str(ROOT))\n",
|
||||
"STUDY = ROOT / 'studies' / '202602_AmazonConnect'\n",
|
||||
"if str(STUDY) not in sys.path:\n",
|
||||
" sys.path.insert(0, str(STUDY))"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "7d91c01d",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"import json\n",
|
||||
"from datetime import datetime, timezone\n",
|
||||
"\n",
|
||||
"import config\n",
|
||||
"import seed_data\n",
|
||||
"from core import __version__\n",
|
||||
"from core.calculations import apply_scenario\n",
|
||||
"from core.export.report_data import _compute_summary\n",
|
||||
"from core.notebook_helpers import display"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "cff0b35b",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Build the envelope\n",
|
||||
"\n",
|
||||
"Two paths:\n",
|
||||
"\n",
|
||||
"* **Live** — `core.export.build_report_data(client, public_id)` pulls\n",
|
||||
" authoritative values + summary from Athena and stamps it.\n",
|
||||
"* **Local** — when no `TOOL_PUBLIC_ID` is configured, build the envelope\n",
|
||||
" directly from `seed_data` so this notebook is always runnable."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "19416ff3",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"if config.TOOL_PUBLIC_ID:\n",
|
||||
" from core.export import build_report_data\n",
|
||||
" from core.tei_client import TEIClient\n",
|
||||
"\n",
|
||||
" client = TEIClient()\n",
|
||||
" envelope = build_report_data(\n",
|
||||
" client,\n",
|
||||
" config.TOOL_PUBLIC_ID,\n",
|
||||
" include_scenarios=True,\n",
|
||||
" study_slug=config.STUDY_SLUG,\n",
|
||||
" )\n",
|
||||
" source = 'live (Athena)'\n",
|
||||
"else:\n",
|
||||
" summary = _compute_summary(\n",
|
||||
" seed_data.BENEFITS, seed_data.COSTS, config.DISCOUNT_RATE, config.ANALYSIS_YEARS\n",
|
||||
" )\n",
|
||||
" summary['roi'] = summary.get('roi_pct')\n",
|
||||
" scenarios = {}\n",
|
||||
" for name in ('conservative', 'moderate', 'aggressive'):\n",
|
||||
" sb = apply_scenario(seed_data.BENEFITS, name, table='benefits')\n",
|
||||
" sc = apply_scenario(seed_data.COSTS, name, table='costs')\n",
|
||||
" scenarios[name] = _compute_summary(sb, sc, config.DISCOUNT_RATE, config.ANALYSIS_YEARS)\n",
|
||||
" envelope = {\n",
|
||||
" 'metadata': {\n",
|
||||
" 'study_slug': config.STUDY_SLUG,\n",
|
||||
" 'tool_public_id': '',\n",
|
||||
" 'tool_name': 'Amazon Connect TEI (local seed)',\n",
|
||||
" 'report_name': 'Total Economic Impact™ Of Amazon Connect',\n",
|
||||
" 'report_vendor': 'AWS',\n",
|
||||
" 'report_version': '1.0',\n",
|
||||
" 'generated_at': datetime.now(timezone.utc).isoformat(),\n",
|
||||
" 'generator': f'palladium core {__version__} (offline)',\n",
|
||||
" },\n",
|
||||
" 'report': {\n",
|
||||
" 'name': 'Total Economic Impact™ Of Amazon Connect',\n",
|
||||
" 'vendor': 'AWS',\n",
|
||||
" 'version': '1.0',\n",
|
||||
" 'discount_rate': config.DISCOUNT_RATE,\n",
|
||||
" 'analysis_period_years': config.ANALYSIS_YEARS,\n",
|
||||
" },\n",
|
||||
" 'values': {'benefits': seed_data.BENEFITS, 'costs': seed_data.COSTS},\n",
|
||||
" 'summary': summary,\n",
|
||||
" 'scenarios': scenarios,\n",
|
||||
" 'assumptions': seed_data.ASSUMPTIONS,\n",
|
||||
" }\n",
|
||||
" source = 'offline seed data'\n",
|
||||
"\n",
|
||||
"display.alert(f'Envelope built from <b>{source}</b>.', 'info')"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "98e94d07",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"out_path = STUDY / 'exports' / 'export.json'\n",
|
||||
"out_path.parent.mkdir(parents=True, exist_ok=True)\n",
|
||||
"out_path.write_text(json.dumps(envelope, indent=2, default=str))\n",
|
||||
"size_kb = out_path.stat().st_size / 1024\n",
|
||||
"display.alert(f'Wrote <code>{out_path.relative_to(ROOT)}</code> ({size_kb:.1f} KB).', 'success')"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "d09cad64",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Envelope shape\n",
|
||||
"\n",
|
||||
"Top-level keys consumed by the report pipeline:"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "841f12a1",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"for key in envelope:\n",
|
||||
" sub = envelope[key]\n",
|
||||
" if isinstance(sub, dict):\n",
|
||||
" print(f' {key}: dict with keys {list(sub.keys())}')\n",
|
||||
" elif isinstance(sub, list):\n",
|
||||
" print(f' {key}: list[{len(sub)}]')\n",
|
||||
" else:\n",
|
||||
" print(f' {key}: {type(sub).__name__}')"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "17d6d0ce",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"Done. Hand off `exports/export.json` to **Peitho** / **html2docx** to produce the final Word report."
|
||||
]
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"kernelspec": {
|
||||
"display_name": "Python 3 (ipykernel)",
|
||||
"language": "python",
|
||||
"name": "python3"
|
||||
},
|
||||
"language_info": {
|
||||
"codemirror_mode": {
|
||||
"name": "ipython",
|
||||
"version": 3
|
||||
},
|
||||
"file_extension": ".py",
|
||||
"mimetype": "text/x-python",
|
||||
"name": "python",
|
||||
"nbconvert_exporter": "python",
|
||||
"pygments_lexer": "ipython3",
|
||||
"version": "3.13.7"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5
|
||||
}
|
||||
162
studies/202602_AmazonConnect/seed_data.py
Normal file
162
studies/202602_AmazonConnect/seed_data.py
Normal file
@@ -0,0 +1,162 @@
|
||||
"""
|
||||
Seed dataset for the Amazon Connect TEI (Forrester, Feb 2026).
|
||||
|
||||
Each row matches the wire shape produced by
|
||||
``core.tei_client.TEIClient._normalize_value`` so it can be passed
|
||||
straight to ``client.update_values(public_id, BENEFITS + COSTS)``.
|
||||
|
||||
Numbers are the *nominal* (pre-risk-adjustment) values from the PDF —
|
||||
risk adjustment is stored as a factor and applied by Athena's
|
||||
calculator (or, locally, by ``core.calculations.risk_adjust_*``).
|
||||
|
||||
References for the totals (from the PDF):
|
||||
|
||||
Benefits (3-yr risk-adjusted PV @ 10%): $101,696,791
|
||||
Costs (3-yr risk-adjusted PV @ 10%): $ 22,983,076
|
||||
NPV $ 78,713,715
|
||||
ROI 342%
|
||||
Payback <6 months
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
#: 3-year nominal benefit cashflows. Risk adjustment factor is stored
|
||||
#: separately; calculator applies it.
|
||||
BENEFITS: list[dict] = [
|
||||
{
|
||||
"field_key": "ai_contact_resolution",
|
||||
"table": "benefits",
|
||||
"label": "AI-driven contact resolution efficiency",
|
||||
"category": "Productivity",
|
||||
"year_values": {"1": 13_911_040, "2": 23_932_480, "3": 37_797_760},
|
||||
"risk_adjustment": 0.15,
|
||||
"notes": (
|
||||
"PDF Section At/Atr. Composite: 20M annual contacts, 30% YoY "
|
||||
"growth, 75% calls, 10-min AHT with legacy. Connect drops AHT "
|
||||
"12% Y1 and shifts traffic to chat/self-service. 80% "
|
||||
"productivity recapture. Risk adj 15% (legacy performance, "
|
||||
"implementation depth, integration scope, growth)."
|
||||
),
|
||||
},
|
||||
{
|
||||
"field_key": "ai_content_sentiment",
|
||||
"table": "benefits",
|
||||
"label": "AI-powered content and sentiment analysis savings",
|
||||
"category": "Productivity",
|
||||
"year_values": {"1": 4_586_620, "2": 5_358_412, "3": 6_291_680},
|
||||
"risk_adjustment": 0.15,
|
||||
"notes": (
|
||||
"PDF Section Bt/Btr. Auto post-contact summaries reclaim ~60s "
|
||||
"per call; QA scaled from 1–3% to 100%; supervisors freed from "
|
||||
"manual review. Risk adj 15%."
|
||||
),
|
||||
},
|
||||
{
|
||||
"field_key": "ai_forecasting_supervision",
|
||||
"table": "benefits",
|
||||
"label": "AI-enabled forecasting, agent scheduling, and supervision",
|
||||
"category": "Productivity",
|
||||
"year_values": {"1": 6_651_680, "2": 9_133_760, "3": 12_391_712},
|
||||
"risk_adjustment": 0.15,
|
||||
"notes": (
|
||||
"PDF Section Ct/Ctr. ML-WFM yields 5% agent FTE optimization "
|
||||
"and supervisors managing 20% more agents (10→12). 80% "
|
||||
"productivity recapture. Risk adj 15%."
|
||||
),
|
||||
},
|
||||
{
|
||||
"field_key": "data_driven_profit_lift",
|
||||
"table": "benefits",
|
||||
"label": "Data-driven profit lift with increased conversion",
|
||||
"category": "Revenue",
|
||||
"year_values": {"1": 1_200_000, "2": 1_560_000, "3": 2_028_000},
|
||||
"risk_adjustment": 0.20,
|
||||
"notes": (
|
||||
"PDF Section Dt/Dtr. Composite revenue $10B Y1 (+30% YoY); "
|
||||
"5% from outbound contact-center marketing; conversion lifts "
|
||||
"from 10% to 12% (+20% relative); 12% operating margin. "
|
||||
"Risk adj 20%."
|
||||
),
|
||||
},
|
||||
{
|
||||
"field_key": "legacy_solution_savings",
|
||||
"table": "benefits",
|
||||
"label": "Legacy solution cost savings",
|
||||
"category": "Cost Savings",
|
||||
"year_values": {"1": 6_177_600, "2": 8_030_880, "3": 10_440_144},
|
||||
"risk_adjustment": 0.20,
|
||||
"notes": (
|
||||
"PDF Section Et/Etr. Avg legacy license $180/agent-month × "
|
||||
"(agents+supervisors) × 12, plus 30% overhead for infra & "
|
||||
"third-party tools. Risk adj 20%."
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
#: Costs include an "initial" (year-0, undiscounted) component for
|
||||
#: implementation. Cost risk adjustments are applied *upward*.
|
||||
COSTS: list[dict] = [
|
||||
{
|
||||
"field_key": "amazon_connect_usage",
|
||||
"table": "costs",
|
||||
"label": "Amazon Connect usage cost",
|
||||
"category": "Subscription",
|
||||
"initial": 0,
|
||||
"year_values": {"1": 6_456_448, "2": 7_951_164, "3": 9_832_961},
|
||||
"risk_adjustment": 0.05,
|
||||
"notes": (
|
||||
"PDF Section Ft/Ftr. Telephony $0.0106/min + Unlimited AI "
|
||||
"$0.0380/min on minutes that reach an agent, plus chat at "
|
||||
"$0.0100/message (10 messages/chat). Risk adj 5%."
|
||||
),
|
||||
},
|
||||
{
|
||||
"field_key": "implementation_migration",
|
||||
"table": "costs",
|
||||
"label": "Implementation and migration cost",
|
||||
"category": "Implementation",
|
||||
"initial": 1_087_500,
|
||||
"year_values": {"1": 188_333, "2": 188_333, "3": 0},
|
||||
"risk_adjustment": 0.10,
|
||||
"notes": (
|
||||
"PDF Section Gt/Gtr. 6-month initial migration: 5 internal "
|
||||
"FTE @ $115k + $800k pro-services. Y1/Y2 M&A integrations: 2 "
|
||||
"months × 2 FTE + $150k pro-services. Risk adj 10%."
|
||||
),
|
||||
},
|
||||
{
|
||||
"field_key": "ongoing_management",
|
||||
"table": "costs",
|
||||
"label": "Ongoing management",
|
||||
"category": "Operations",
|
||||
"initial": 0,
|
||||
"year_values": {"1": 256_200, "2": 187_200, "3": 187_200},
|
||||
"risk_adjustment": 0.15,
|
||||
"notes": (
|
||||
"PDF Section Ht/Htr. Y1: 5 IT/PM @ 30% × $115k + 5 business "
|
||||
"users @ 30% × $55,800. Y2/Y3: 3 IT/PM @ 30% + 5 business "
|
||||
"users @ 30%. Risk adj 15%."
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
#: Top-line composite assumptions — for the 03_business_case narrative.
|
||||
ASSUMPTIONS: dict = {
|
||||
"agents_fte": 2_000,
|
||||
"supervisors_fte": 200,
|
||||
"annual_contacts_y1": 20_000_000,
|
||||
"growth_rate": 0.30,
|
||||
"call_share": 0.75,
|
||||
"aht_legacy_minutes": 10,
|
||||
"agent_salary": 45_760,
|
||||
"supervisor_salary": 55_800,
|
||||
"discount_rate": 0.10,
|
||||
"analysis_years": 3,
|
||||
}
|
||||
|
||||
|
||||
def all_values() -> list[dict]:
|
||||
"""Return BENEFITS + COSTS — handy single-call payload for update_values."""
|
||||
return BENEFITS + COSTS
|
||||
0
studies/__init__.py
Normal file
0
studies/__init__.py
Normal file
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
32
tests/conftest.py
Normal file
32
tests/conftest.py
Normal 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
206
tests/test_calculations.py
Normal 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
174
tests/test_client.py
Normal 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
98
tests/test_export.py
Normal 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
|
||||
)
|
||||
Reference in New Issue
Block a user