"""Scenario consequences and annual risk quantities."""
from __future__ import annotations
from dataclasses import dataclass
from collections.abc import Callable, Mapping
from typing import Any
import numpy as np
import pandas as pd
from scipy.stats import norm
from .swtp import SWTP, _swtp_value
from .targets import _as_scalar_or_array
__all__ = [
"FatalityConsequence",
"RiskResult",
"ScenarioRiskModel",
"jcss_lqi_risk_cost",
"jcss_lqi_risk_cost_from_result",
]
[docs]
@dataclass(frozen=True)
class FatalityConsequence:
"""Expected fatalities conditional on structural failure."""
people_exposed: float
probability_death_given_failure: float = 1.0
def __post_init__(self):
if self.people_exposed < 0:
raise ValueError("people_exposed must be non-negative")
if not 0 <= self.probability_death_given_failure <= 1:
raise ValueError("probability_death_given_failure must be in [0, 1]")
@property
def expected_fatalities_given_failure(self) -> float:
"""Return expected fatalities conditional on failure."""
return self.people_exposed * self.probability_death_given_failure
[docs]
@dataclass(frozen=True)
class RiskResult:
"""Expected annual risk quantities for a component or scenario model.
Parameters
----------
annual_failure_rate : float
Annual probability or rate of the represented failure event. For
scenario studies this may be the sum of joint failure-state rates.
expected_fatalities : float
Expected fatalities per year.
expected_economic_loss : float
Expected economic loss per year, excluding SWTP life-safety valuation.
scenarios : pandas.DataFrame, optional
Scenario table used to derive the expected values. This is useful for
joint failure states and nonlinear consequence models.
metadata : mapping, optional
User-supplied context such as model name, design point, or units.
"""
annual_failure_rate: float
expected_fatalities: float = 0.0
expected_economic_loss: float = 0.0
scenarios: pd.DataFrame | None = None
metadata: Mapping[str, Any] | None = None
def __post_init__(self):
if self.annual_failure_rate < 0:
raise ValueError("annual_failure_rate must be non-negative")
if self.expected_fatalities < 0:
raise ValueError("expected_fatalities must be non-negative")
if self.expected_economic_loss < 0:
raise ValueError("expected_economic_loss must be non-negative")
if self.metadata is None:
object.__setattr__(self, "metadata", {})
[docs]
@classmethod
def from_scenarios(
cls,
scenarios: Any,
weight_col: str = "probability",
fatalities_col: str = "fatalities",
economic_loss_col: str = "economic_loss",
failure_col: str | None = None,
metadata: Mapping[str, Any] | None = None,
) -> "RiskResult":
"""Aggregate a scenario table into annual risk quantities.
The scenario weights may be annual probabilities or annual rates. No
independence assumptions are made, so rows may represent joint
failure states produced by an upstream reliability model.
If ``failure_col`` is omitted, every row is treated as a failure/loss
scenario for the purpose of ``annual_failure_rate``.
"""
df = pd.DataFrame(scenarios).copy()
for column in (weight_col, fatalities_col, economic_loss_col):
if column not in df:
raise KeyError(f"Scenario table is missing column {column!r}")
if failure_col is not None and failure_col not in df:
raise KeyError(f"Scenario table is missing column {failure_col!r}")
weights = df[weight_col].astype(float)
fatalities = df[fatalities_col].astype(float)
economic_loss = df[economic_loss_col].astype(float)
if (weights < 0).any():
raise ValueError("scenario weights must be non-negative")
if (fatalities < 0).any():
raise ValueError("scenario fatalities must be non-negative")
if (economic_loss < 0).any():
raise ValueError("scenario economic losses must be non-negative")
if failure_col is None:
annual_failure_rate = float(weights.sum())
else:
annual_failure_rate = float((weights * df[failure_col].astype(bool)).sum())
df["expected_fatalities_contribution"] = weights * fatalities
df["expected_economic_loss_contribution"] = weights * economic_loss
return cls(
annual_failure_rate=annual_failure_rate,
expected_fatalities=float(df["expected_fatalities_contribution"].sum()),
expected_economic_loss=float(
df["expected_economic_loss_contribution"].sum()
),
scenarios=df,
metadata=metadata,
)
@property
def beta(self) -> float | None:
"""Return the generalized reliability index when rate is probability-like."""
if self.annual_failure_rate == 0:
return float("inf")
if 0 < self.annual_failure_rate < 1:
return -float(norm.ppf(self.annual_failure_rate))
return None
[docs]
def get_failure(self) -> float:
"""Return ``annual_failure_rate`` for reliability-result compatibility."""
return self.annual_failure_rate
[docs]
def get_beta(self) -> float | None:
"""Return ``beta`` for reliability-result compatibility."""
return self.beta
[docs]
def life_safety_cost(self, swtp: float | SWTP) -> float:
"""Return SWTP-valued expected annual life-safety cost."""
return _swtp_value(swtp) * self.expected_fatalities
[docs]
def total_risk_cost(
self,
swtp: float | SWTP | None = None,
include_life_safety: bool = True,
) -> float:
"""Return expected annual economic plus optional life-safety risk cost."""
total = self.expected_economic_loss
if include_life_safety and swtp is not None:
total += self.life_safety_cost(swtp)
return float(total)
[docs]
def to_dict(self) -> dict:
"""Return scalar risk quantities as a dictionary."""
data = {
"annual_failure_rate": self.annual_failure_rate,
"beta": self.beta,
"expected_fatalities": self.expected_fatalities,
"expected_economic_loss": self.expected_economic_loss,
}
data.update(self.metadata or {})
return data
[docs]
@dataclass
class ScenarioRiskModel:
"""Scenario-table risk model for aggregated risk studies.
``scenarios`` may be a dataframe-like object or a callable returning one.
Callable scenarios are evaluated with the design value when supplied.
"""
scenarios: Any | Callable[..., Any]
weight_col: str = "probability"
fatalities_col: str = "fatalities"
economic_loss_col: str = "economic_loss"
failure_col: str | None = None
metadata: Mapping[str, Any] | None = None
[docs]
def evaluate(self, design: Any = None) -> RiskResult:
"""Evaluate the scenario model and return a :class:`RiskResult`."""
if callable(self.scenarios):
scenario_data = (
self.scenarios(design) if design is not None else self.scenarios()
)
else:
scenario_data = self.scenarios
metadata = dict(self.metadata or {})
if design is not None:
metadata.setdefault("design", design)
return RiskResult.from_scenarios(
scenario_data,
weight_col=self.weight_col,
fatalities_col=self.fatalities_col,
economic_loss_col=self.economic_loss_col,
failure_col=self.failure_col,
metadata=metadata,
)
[docs]
def jcss_lqi_risk_cost(
safety_cost: float | np.ndarray,
failure_rate: float | np.ndarray,
swtp: float | SWTP,
expected_fatalities_given_failure: float,
) -> float | np.ndarray:
"""Return the JCSS LQI life-safety risk cost.
This implements the canonical LQI optimization term
``S(p) = C(p) + G_x k N_F h(p)`` from the JCSS background documents,
where ``h(p)`` is a failure rate or annual failure probability.
"""
value_per_life = _swtp_value(swtp)
if value_per_life <= 0:
raise ValueError("swtp must be positive")
if expected_fatalities_given_failure < 0:
raise ValueError("expected_fatalities_given_failure must be non-negative")
result = np.asarray(
safety_cost
) + value_per_life * expected_fatalities_given_failure * np.asarray(failure_rate)
return _as_scalar_or_array(result)
[docs]
def jcss_lqi_risk_cost_from_result(
safety_cost: float,
risk: RiskResult,
swtp: float | SWTP,
include_economic_loss: bool = False,
) -> float:
"""Return JCSS LQI risk cost from an aggregated risk result.
This form is intended for scenario studies where the upstream model already
provides expected annual fatalities. Set
``include_economic_loss=True`` when the objective should include nonlinear
economic consequences in addition to the SWTP life-safety term.
"""
if safety_cost < 0:
raise ValueError("safety_cost must be non-negative")
risk_cost = risk.life_safety_cost(swtp)
if include_economic_loss:
risk_cost += risk.expected_economic_loss
return float(safety_cost + risk_cost)