129 lines
4.4 KiB
Python
129 lines
4.4 KiB
Python
"""
|
||
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: friendly value rows (``year_values`` / ``value`` / ``initial``)
|
||
as returned by :meth:`core.tei_client.TEIClient.get_values`.
|
||
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
|