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
|
||||||
*/.ipynb_checkpoints/*
|
*/.ipynb_checkpoints/*
|
||||||
|
|
||||||
|
# Palladium-specific
|
||||||
|
.env
|
||||||
|
studies/*/exports/*
|
||||||
|
!studies/*/exports/.gitkeep
|
||||||
|
|
||||||
# IPython
|
# IPython
|
||||||
profile_default/
|
profile_default/
|
||||||
ipython_config.py
|
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.
|
**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.*
|
> *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
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────────────────────────────────────────────┐
|
┌──────────────────────────────────────────────────────────────────┐
|
||||||
│ Palladium │
|
│ Palladium │
|
||||||
│ │
|
│ │
|
||||||
│ ┌──────────────┐ ┌──────────────┐ ┌───────────┐ │
|
│ studies/202602_AmazonConnect/ ← one folder per TEI study │
|
||||||
│ │ Notebooks │ │ Streamlit │ │ Export │ │
|
│ studies/YYYYMM_<Vendor>/ │
|
||||||
│ │ (Analysis) │ │ (Data Entry)│ │ (Report) │ │
|
│ ├─ notebooks/ ─┐ │
|
||||||
│ └──────┬───────┘ └──────┬───────┘ └─────┬─────┘ │
|
│ ├─ seed_data.py │ │
|
||||||
│ │ │ │ │
|
│ └─ config.py │ │
|
||||||
│ └───────────┬───────┘ │ │
|
│ ▼ │
|
||||||
│ ▼ │ │
|
│ ┌──────────────┐ ┌──────────────┐ │
|
||||||
│ ┌──────────────────┐ │ │
|
│ │ core/ │ ←─ │ app/ │ │
|
||||||
│ │ TEI Client │ │ │
|
│ │ shared logic │ │ Streamlit │ │
|
||||||
│ │ (API Layer) │──────────────────┘ │
|
│ └──────┬───────┘ └──────┬───────┘ │
|
||||||
│ └────────┬─────────┘ │
|
│ │ │ │
|
||||||
└────────────────────┼────────────────────────────────────┘
|
│ ▼ ▼ │
|
||||||
│
|
│ tei_client → ───────────────────► Athena API │
|
||||||
▼
|
│ calculations │
|
||||||
┌─────────────┐ ┌──────────────────┐
|
│ export ──────────────────────────► export.json │
|
||||||
│ Athena │ │ Report Pipeline │
|
│ notebook_helpers │
|
||||||
│ (API) │ │ (html2docx) │
|
│ cli │
|
||||||
└─────────────┘ └──────────────────┘
|
└──────────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
### Components
|
### Components
|
||||||
|
|
||||||
| Component | Purpose |
|
| Component | Purpose |
|
||||||
|-----------|---------|
|
|-----------|---------|
|
||||||
| **TEI Client** | Python API client for Athena's TEI endpoints |
|
| **`core/tei_client`** | Python API client for Athena's TEI endpoints |
|
||||||
| **Calculations** | Financial logic — NPV, ROI, payback, risk adjustment |
|
| **`core/calculations`** | Financial logic — NPV, ROI, payback, risk adjustment, scenarios |
|
||||||
| **Notebooks** | Interactive analysis — benefits, costs, business case |
|
| **`core/export`** | Builds the structured JSON envelope consumed by the report pipeline |
|
||||||
| **Streamlit App** | Data entry UI with version management |
|
| **`core/notebook_helpers`** | Pandas tables, Plotly charts, IPython display widgets |
|
||||||
| **Export** | Structured JSON for the LLM report generation pipeline |
|
| **`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
|
python -m palladium test
|
||||||
```
|
```
|
||||||
|
|
||||||
Or in a notebook:
|
Or in Python:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from tei_client import TEIClient
|
from core.tei_client import TEIClient
|
||||||
|
|
||||||
client = TEIClient()
|
client = TEIClient()
|
||||||
result = client.test_connection()
|
print(client.test_connection()) # {'status': 'ok', 'authenticated': True, ...}
|
||||||
print(result) # {'status': 'ok', 'authenticated': True, ...}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Usage
|
## 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
|
```bash
|
||||||
jupyter lab notebooks/
|
jupyter lab studies/202602_AmazonConnect/notebooks/
|
||||||
```
|
```
|
||||||
|
|
||||||
| Notebook | Purpose |
|
| Notebook | Purpose |
|
||||||
@@ -109,11 +111,15 @@ jupyter lab notebooks/
|
|||||||
| `01_benefits.ipynb` | Quantify and risk-adjust benefit categories |
|
| `01_benefits.ipynb` | Quantify and risk-adjust benefit categories |
|
||||||
| `02_costs.ipynb` | Document implementation and ongoing costs |
|
| `02_costs.ipynb` | Document implementation and ongoing costs |
|
||||||
| `03_business_case.ipynb` | Financial summary, scenario analysis, visualizations |
|
| `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
|
```bash
|
||||||
streamlit run app/main.py
|
streamlit run app/main.py
|
||||||
@@ -125,21 +131,56 @@ streamlit run app/main.py
|
|||||||
# Test connection
|
# Test connection
|
||||||
python -m palladium test
|
python -m palladium test
|
||||||
|
|
||||||
# List TEI instances
|
# List TEI tool instances
|
||||||
python -m palladium list
|
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>
|
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
|
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
|
## TEI Methodology
|
||||||
|
|
||||||
Palladium implements the Forrester TEI™ framework [1]:
|
Palladium implements the Forrester TEI™ framework.
|
||||||
|
|
||||||
### Benefit Categories
|
### Benefit Categories
|
||||||
|
|
||||||
@@ -154,25 +195,32 @@ Benefits are quantified across categories, risk-adjusted, and discounted to pres
|
|||||||
|
|
||||||
### Risk Adjustment
|
### 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
|
### Financial Metrics
|
||||||
|
|
||||||
| Metric | Description |
|
| Metric | Description |
|
||||||
|--------|-------------|
|
|--------|-------------|
|
||||||
| **NPV** | Net Present Value — total risk-adjusted benefits minus costs, discounted |
|
| **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 |
|
| **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
|
### Scenario Analysis
|
||||||
|
|
||||||
Three scenarios model uncertainty in adoption and realization:
|
Three scenarios model uncertainty in adoption and realization
|
||||||
|
(see `core.calculations.SCENARIOS`):
|
||||||
|
|
||||||
| Scenario | Approach |
|
| Scenario | Adoption | Risk delta | Effect |
|
||||||
|----------|----------|
|
|----------|----------|------------|--------|
|
||||||
| Conservative | Higher risk adjustments, lower adoption rates |
|
| Conservative | 80% | +10pp on benefits | Lower benefits, higher modelled cost |
|
||||||
| Moderate | Balanced assumptions (base case) |
|
| Moderate | 100% | 0 | Base case (= published study) |
|
||||||
| Aggressive | Lower risk adjustments, faster adoption |
|
| Aggressive | 115% | –5pp on benefits | Higher benefits, lower padding on cost |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -180,40 +228,48 @@ Three scenarios model uncertainty in adoption and realization:
|
|||||||
|
|
||||||
```
|
```
|
||||||
palladium/
|
palladium/
|
||||||
├── app/ # Streamlit application
|
├── core/ # Shared, study-agnostic Python package
|
||||||
│ ├── main.py # App entry point
|
│ ├── tei_client/ # Athena API client
|
||||||
│ ├── pages/
|
│ │ ├── client.py # TEIClient with all /api/v1/tei/ methods
|
||||||
│ │ ├── benefits.py # Benefits data entry
|
│ │ └── models.py # Optional dataclasses for typed access
|
||||||
│ │ ├── costs.py # Costs data entry
|
│ ├── calculations/ # Pure-python financial math
|
||||||
│ │ ├── summary.py # Financial summary dashboard
|
│ │ ├── npv.py
|
||||||
│ │ └── versions.py # Version history & comparison
|
│ │ ├── roi.py
|
||||||
│ └── components/
|
│ │ ├── payback.py
|
||||||
│ ├── charts.py # Visualization components
|
│ │ └── scenarios.py
|
||||||
│ └── tables.py # Data table components
|
│ ├── export/
|
||||||
├── notebooks/ # Jupyter analysis notebooks
|
│ │ └── report_data.py # JSON envelope for the report pipeline
|
||||||
│ ├── 01_benefits.ipynb
|
│ ├── notebook_helpers/
|
||||||
│ ├── 02_costs.ipynb
|
│ │ ├── tables.py # Pandas dataframe builders
|
||||||
│ ├── 03_business_case.ipynb
|
│ │ ├── charts.py # Plotly figures
|
||||||
│ └── 04_export.ipynb
|
│ │ └── display.py # IPython KPI cards, alerts
|
||||||
├── tei_client/ # Athena API client
|
│ └── cli/
|
||||||
│ ├── __init__.py
|
│ └── main.py # `python -m palladium ...`
|
||||||
│ ├── client.py # HTTP client with auth
|
├── palladium/ # CLI shim (just exposes `python -m palladium`)
|
||||||
│ └── models.py # Response data models
|
│ └── __main__.py
|
||||||
├── calculations/ # Financial calculation engine
|
├── app/ # Streamlit UI — works with any TEI study
|
||||||
│ ├── __init__.py
|
│ ├── main.py # entry point
|
||||||
│ ├── npv.py # Net present value
|
│ ├── pages/ # benefits, costs, summary, versions
|
||||||
│ ├── roi.py # Return on investment
|
│ └── components/ # tables, charts
|
||||||
│ ├── payback.py # Payback period
|
├── studies/ # One folder per TEI engagement
|
||||||
│ └── scenarios.py # Scenario multipliers
|
│ └── 202602_AmazonConnect/
|
||||||
├── export/ # Report pipeline export
|
│ ├── README.md
|
||||||
│ ├── __init__.py
|
│ ├── config.py # TOOL_PUBLIC_ID, REPORT_PUBLIC_ID
|
||||||
│ └── report_data.py # JSON export for html2docx
|
│ ├── seed_data.py # 5 benefits + 3 costs from the PDF
|
||||||
├── tests/
|
│ ├── 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_client.py
|
||||||
│ ├── test_calculations.py
|
│ ├── test_calculations.py
|
||||||
│ └── test_export.py
|
│ └── test_export.py
|
||||||
|
├── Athena API.yaml # OpenAPI reference
|
||||||
├── .env.example
|
├── .env.example
|
||||||
├── .gitignore
|
|
||||||
├── requirements.txt
|
├── requirements.txt
|
||||||
├── pyproject.toml
|
├── pyproject.toml
|
||||||
└── README.md
|
└── README.md
|
||||||
@@ -227,18 +283,36 @@ Palladium connects to Athena's TEI module for data persistence and cross-tool re
|
|||||||
|
|
||||||
### API Endpoints Used
|
### API Endpoints Used
|
||||||
|
|
||||||
|
All endpoints are under `/api/v1/tei/` and require `Authorization: Api-Key {key}`.
|
||||||
|
|
||||||
| Endpoint | Purpose |
|
| Endpoint | Purpose |
|
||||||
|----------|---------|
|
|----------|---------|
|
||||||
| `GET /forge/api/tei/reports/` | List available TEI model templates |
|
| `GET /api/v1/tei/reports/` | List available TEI report templates |
|
||||||
| `GET /forge/api/tei/reports/{id}/fields/` | Get field definitions for a model |
|
| `GET /api/v1/tei/reports/{public_id}/` | Get a report template |
|
||||||
| `POST /forge/api/tei/tools/` | Create new TEI instance |
|
| `GET /api/v1/tei/reports/{public_id}/fields/` | Get field definitions for a template |
|
||||||
| `GET /forge/api/tei/tools/{public_id}/` | Get instance metadata |
|
| `POST /api/v1/tei/tools/` | Create a new TEI tool instance |
|
||||||
| `GET /forge/api/tei/tools/{public_id}/values/` | Get current field values |
|
| `GET /api/v1/tei/tools/{public_id}/` | Get instance metadata |
|
||||||
| `PUT /forge/api/tei/tools/{public_id}/values/` | Bulk update values |
|
| `PATCH /api/v1/tei/tools/{public_id}/` | Update name/status |
|
||||||
| `POST /forge/api/tei/tools/{public_id}/calculate/` | Trigger calculation |
|
| `GET /api/v1/tei/tools/{public_id}/values/` | Get current field values |
|
||||||
| `GET /forge/api/tei/tools/{public_id}/summary/` | Get financial summary |
|
| `PUT /api/v1/tei/tools/{public_id}/values/` | Bulk-update values |
|
||||||
| `POST /forge/api/tei/tools/{public_id}/versions/` | Save version snapshot |
|
| `PATCH /api/v1/tei/tools/{public_id}/values/{field_key}/` | Patch a single value |
|
||||||
| `GET /forge/api/tei/tools/{public_id}/export/` | Export for report pipeline |
|
| `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
|
### Authentication
|
||||||
|
|
||||||
@@ -258,7 +332,7 @@ Palladium's export produces structured JSON consumed by the LLM report generatio
|
|||||||
Palladium Export (JSON)
|
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
|
html2docx converts to native Word
|
||||||
@@ -267,23 +341,23 @@ html2docx converts to native Word
|
|||||||
Professional TEI Report (.docx)
|
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 benefit categories with risk-adjusted values
|
||||||
- All cost categories with yearly breakdown
|
- All cost categories with yearly breakdown (and Initial column)
|
||||||
- Financial summary (NPV, ROI, payback)
|
- Financial summary (NPV, ROI, payback, yearly cashflow)
|
||||||
- Yearly cash flow data (for waterfall/bar charts)
|
- Conservative / moderate / aggressive scenario analysis
|
||||||
- Scenario analysis results (conservative/moderate/aggressive)
|
- Metadata (study slug, proposal, engagement, generator stamp)
|
||||||
- Metadata (client, opportunity, analysis period, discount rate)
|
- The raw Athena `/export/` payload for reference
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Version Management
|
## 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
|
1. **Save Version** — Snapshots current values + summary with a descriptive note
|
||||||
2. **View History** — See all versions with headline metrics (NPV, ROI)
|
2. **View History** — All versions with headline metrics (NPV, ROI)
|
||||||
3. **Compare Versions** — Side-by-side diff showing what changed between any two versions
|
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
|
4. **Restore Version** — Load a previous version's values as the current state
|
||||||
|
|
||||||
Version notes should capture:
|
Version notes should capture:
|
||||||
@@ -302,6 +376,10 @@ Version notes should capture:
|
|||||||
pytest tests/ -v
|
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
|
### Code Style
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -311,10 +389,11 @@ ruff format .
|
|||||||
|
|
||||||
### Adding a New Benefit Category
|
### Adding a New Benefit Category
|
||||||
|
|
||||||
1. Define the field in Athena's TEI Model admin (field name, type, category, defaults)
|
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
|
2. The field automatically appears in Palladium via the API — no client changes
|
||||||
3. Update notebook analysis if category-specific logic is needed
|
3. Update notebook prose if category-specific commentary is needed
|
||||||
4. Update export mapping if the report template expects specific structure
|
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 |
|
| **Athena** | Platform API — data persistence, cross-tool reporting |
|
||||||
| **Peitho** | Document generation — consumes Palladium's export JSON |
|
| **Peitho** | Document generation — consumes Palladium's export JSON |
|
||||||
| **html2docx** | Converts LLM-generated HTML to native Word documents |
|
| **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