""" 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