refactor: restructure repo into core/app modules with per-study folders

Reorganize Palladium codebase into a modular architecture with `core/`
shared logic and `app/` Streamlit UI, separating per-study assets into
`studies/YYYYMM_<Vendor>/` folders containing notebooks, seed data, and
configuration. Update README to reflect new structure, add `.gitignore`
entries for `.env` and study exports, and refresh component documentation.
This commit is contained in:
2026-05-20 22:28:12 -04:00
parent a6f3ee3676
commit a2420ed692
52 changed files with 35300 additions and 105 deletions

View File

@@ -0,0 +1,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