|
| 1 | +""" |
| 2 | +This file is part of CLIMADA. |
| 3 | +
|
| 4 | +Copyright (C) 2017 ETH Zurich, CLIMADA contributors listed in AUTHORS. |
| 5 | +
|
| 6 | +CLIMADA is free software: you can redistribute it and/or modify it under the |
| 7 | +terms of the GNU General Public License as published by the Free |
| 8 | +Software Foundation, version 3. |
| 9 | +
|
| 10 | +CLIMADA is distributed in the hope that it will be useful, but WITHOUT ANY |
| 11 | +WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A |
| 12 | +PARTICULAR PURPOSE. See the GNU General Public License for more details. |
| 13 | +
|
| 14 | +You should have received a copy of the GNU General Public License along |
| 15 | +with CLIMADA. If not, see <https://www.gnu.org/licenses/>. |
| 16 | +
|
| 17 | +--- |
| 18 | +
|
| 19 | +""" |
| 20 | + |
| 21 | +import copy |
| 22 | +import logging |
| 23 | +from typing import Iterable |
| 24 | + |
| 25 | +import pandas as pd |
| 26 | +from tqdm import tqdm |
| 27 | + |
| 28 | +from climada.engine.option_appraisal.constants import ( |
| 29 | + AVERTED_RISK_NAME, |
| 30 | + MEASURE_IMPL_COST_NAME, |
| 31 | + REFERENCE_RISK_NAME, |
| 32 | +) |
| 33 | +from climada.entity.disc_rates.base import DiscRates |
| 34 | +from climada.entity.measures.measure_set import MeasureSet |
| 35 | +from climada.trajectories.calc_risk_metrics import CalcRiskMetricsPoints |
| 36 | +from climada.trajectories.constants import ( |
| 37 | + COORD_ID_COL_NAME, |
| 38 | + DATE_COL_NAME, |
| 39 | + GROUP_COL_NAME, |
| 40 | + MEASURE_COL_NAME, |
| 41 | + METRIC_COL_NAME, |
| 42 | + NO_MEASURE_VALUE, |
| 43 | + RISK_COL_NAME, |
| 44 | +) |
| 45 | +from climada.trajectories.impact_calc_strat import ImpactComputationStrategy |
| 46 | +from climada.trajectories.snapshot import Snapshot |
| 47 | +from climada.trajectories.static_trajectory import StaticRiskTrajectory |
| 48 | +from climada.trajectories.trajectory import DEFAULT_DF_COLUMN_PRIORITY, DEFAULT_RP |
| 49 | +from climada.util import log_level |
| 50 | +from climada.util.config import CONFIG |
| 51 | +from climada.util.dataframe_handling import reorder_dataframe_columns |
| 52 | + |
| 53 | +tqdm.pandas() |
| 54 | + |
| 55 | +LOGGER = logging.getLogger(__name__) |
| 56 | + |
| 57 | + |
| 58 | +class StaticAppraiser(StaticRiskTrajectory): |
| 59 | + def __init__( |
| 60 | + self, |
| 61 | + snapshots_list: Iterable[Snapshot], |
| 62 | + *, |
| 63 | + measure_set: MeasureSet, |
| 64 | + return_periods: Iterable[int] = DEFAULT_RP, |
| 65 | + risk_disc_rates: DiscRates | None = None, |
| 66 | + cost_disc_rates: DiscRates | None = None, |
| 67 | + impact_computation_strategy: ImpactComputationStrategy | None = None, |
| 68 | + ): |
| 69 | + """Initialize a new `StaticAppraiser`. |
| 70 | +
|
| 71 | + Parameters |
| 72 | + ---------- |
| 73 | + snapshots_list : list[Snapshot] |
| 74 | + The list of `Snapshot` object to compute risk from. |
| 75 | + measure_set: MeasureSet |
| 76 | + The set of adaptation measures to appraise. |
| 77 | + return_periods: list[int], optional |
| 78 | + The return periods to use when computing the `return_periods_metric`. |
| 79 | + Defaults to `DEFAULT_RP` ([20, 50, 100]). |
| 80 | + all_groups_name: str, optional |
| 81 | + The string that should be used to define "all exposure points" subgroup. |
| 82 | + Defaults to `DEFAULT_ALLGROUP_NAME` ("All"). |
| 83 | + risk_disc_rates: DiscRates, optional |
| 84 | + The discount rate to apply to future risk. Defaults to None. |
| 85 | + cost_disc_rates: DiscRates, optional |
| 86 | + The discount rate to apply to future costs (of adaptation measures). |
| 87 | + Defaults to None. |
| 88 | + impact_computation_strategy: ImpactComputationStrategy, optional |
| 89 | + The method used to calculate the impact from the (Haz,Exp,Vul) |
| 90 | + of the two snapshots. Defaults to :class:`ImpactCalcComputation`. |
| 91 | +
|
| 92 | + """ |
| 93 | + |
| 94 | + self._cost_disc_rates = cost_disc_rates |
| 95 | + self.measure_set = copy.deepcopy(measure_set) |
| 96 | + super().__init__( |
| 97 | + snapshots_list, |
| 98 | + return_periods=return_periods, |
| 99 | + risk_disc_rates=risk_disc_rates, |
| 100 | + impact_computation_strategy=impact_computation_strategy, |
| 101 | + ) |
| 102 | + self._risk_metrics_calculators = self._add_adaptation_metrics_calculators( |
| 103 | + self._risk_metrics_calculators, measure_set |
| 104 | + ) |
| 105 | + |
| 106 | + @staticmethod |
| 107 | + def _add_adaptation_metrics_calculators( |
| 108 | + risk_metrics_calculators, measure_set: MeasureSet |
| 109 | + ) -> list[CalcRiskMetricsPoints]: |
| 110 | + """Adds the risk metric calculators for the different adaptation options.""" |
| 111 | + calculators = [risk_metrics_calculators] + [ |
| 112 | + risk_metrics_calculators.apply_measure(meas) |
| 113 | + for _, meas in measure_set.measures().items() |
| 114 | + ] |
| 115 | + return calculators |
| 116 | + |
| 117 | + @property |
| 118 | + def cost_disc_rates(self) -> DiscRates | None: |
| 119 | + """The discount rate applied to compute net present values of costs. |
| 120 | + None means no discount rate. |
| 121 | +
|
| 122 | + Notes |
| 123 | + ----- |
| 124 | +
|
| 125 | + Changing its value resets the metrics. |
| 126 | + """ |
| 127 | + return self._cost_disc_rates |
| 128 | + |
| 129 | + @cost_disc_rates.setter |
| 130 | + def cost_disc_rates(self, value, /): |
| 131 | + if value is not None and not isinstance(value, DiscRates): |
| 132 | + raise ValueError("Risk discount needs to be a `DiscRates` object.") |
| 133 | + |
| 134 | + self._reset_metrics() |
| 135 | + self._cost_disc_rates = value |
| 136 | + |
| 137 | + def _generic_metrics( |
| 138 | + self, |
| 139 | + metric_name: str | None = None, |
| 140 | + metric_meth: str | None = None, |
| 141 | + **kwargs, |
| 142 | + ) -> pd.DataFrame: |
| 143 | + """Generic method to compute metrics based on the provided metric name and method. |
| 144 | +
|
| 145 | + This method calls the appropriate method from each calculators (corresponding to |
| 146 | + each adaptation) to return the results for the given metric, |
| 147 | + in a tidy formatted dataframe. |
| 148 | +
|
| 149 | + It first checks whether the requested metric is a valid one. |
| 150 | + Then looks for a possible cached value and otherwised asks the |
| 151 | + calculators (`self._risk_metric_calculators`) to run the computation. |
| 152 | + The results are then regrouped in a nice and tidy DataFrame. |
| 153 | + If a `risk_disc_rates` was set, values are converted to net present values. |
| 154 | + Results are then cached within `self._<metric_name>_metrics` and returned. |
| 155 | +
|
| 156 | + Parameters |
| 157 | + ---------- |
| 158 | + metric_name : str, optional |
| 159 | + The name of the metric to return results for. |
| 160 | + metric_meth : str, optional |
| 161 | + The name of the specific method of the calculator to call. |
| 162 | +
|
| 163 | + Returns |
| 164 | + ------- |
| 165 | + pd.DataFrame |
| 166 | + A tidy formatted dataframe of the risk metric computed for the |
| 167 | + different snapshots. |
| 168 | +
|
| 169 | + Raises |
| 170 | + ------ |
| 171 | + NotImplementedError |
| 172 | + If the requested metric is not part of `POSSIBLE_METRICS`. |
| 173 | + ValueError |
| 174 | + If either of the arguments are not provided. |
| 175 | +
|
| 176 | + """ |
| 177 | + |
| 178 | + if metric_name is None or metric_meth is None: |
| 179 | + raise ValueError("Both metric_name and metric_meth must be provided.") |
| 180 | + |
| 181 | + if metric_name not in self.POSSIBLE_METRICS: |
| 182 | + raise NotImplementedError( |
| 183 | + f"{metric_name} not implemented ({self.POSSIBLE_METRICS})." |
| 184 | + ) |
| 185 | + |
| 186 | + # Construct the attribute name for storing the metric results |
| 187 | + attr_name = f"_{metric_name}_metrics" |
| 188 | + |
| 189 | + if getattr(self, attr_name) is not None: |
| 190 | + LOGGER.debug("Returning cached %s", attr_name) |
| 191 | + return getattr(self, attr_name) |
| 192 | + |
| 193 | + LOGGER.debug("Computing %s", attr_name) |
| 194 | + with log_level(level="WARNING", name_prefix="climada"): |
| 195 | + tmp = [ |
| 196 | + getattr(calc_period, metric_meth)(**kwargs) |
| 197 | + for calc_period in self._risk_metrics_calculators |
| 198 | + ] |
| 199 | + |
| 200 | + try: |
| 201 | + tmp = pd.concat(tmp) |
| 202 | + except ValueError as exc: |
| 203 | + if str(exc) == "All objects passed were None": |
| 204 | + return pd.DataFrame() |
| 205 | + raise exc |
| 206 | + |
| 207 | + if len(tmp) == 0: |
| 208 | + return pd.DataFrame() |
| 209 | + |
| 210 | + tmp = self._metric_post_treatment(tmp, metric_name) |
| 211 | + |
| 212 | + if CONFIG.trajectory_caching.bool(): |
| 213 | + LOGGER.debug("All computing done, caching value.") |
| 214 | + setattr(self, attr_name, tmp) |
| 215 | + return getattr(self, attr_name) |
| 216 | + |
| 217 | + return tmp |
| 218 | + |
| 219 | + def _metric_post_treatment( |
| 220 | + self, metric_df: pd.DataFrame, metric_name: str |
| 221 | + ) -> pd.DataFrame: |
| 222 | + # Notably for per_group_aai being None: |
| 223 | + def meas_impl_cost(measure_name: str) -> float: |
| 224 | + if measure_name == NO_MEASURE_VALUE: |
| 225 | + return 0.0 |
| 226 | + |
| 227 | + return self.measure_set.measures()[measure_name].cost_income.init_cost |
| 228 | + |
| 229 | + metric_df = self._handle_group_categories(metric_df) |
| 230 | + if self._risk_disc_rates: |
| 231 | + LOGGER.debug("Found risk discount rate. Computing NPV.") |
| 232 | + metric_df = self.npv_transform(metric_df, self._risk_disc_rates) |
| 233 | + |
| 234 | + LOGGER.debug("Computing averted risk for: %s.", metric_name) |
| 235 | + metric_df = self._calc_averted(metric_df) |
| 236 | + metric_df[MEASURE_IMPL_COST_NAME] = metric_df[MEASURE_COL_NAME].map( |
| 237 | + meas_impl_cost |
| 238 | + ) |
| 239 | + metric_df = reorder_dataframe_columns(metric_df, DEFAULT_DF_COLUMN_PRIORITY) |
| 240 | + return metric_df |
| 241 | + |
| 242 | + def _handle_group_categories(self, metric_df: pd.DataFrame) -> pd.DataFrame: |
| 243 | + if self._all_groups_name not in metric_df[GROUP_COL_NAME].cat.categories: |
| 244 | + metric_df[GROUP_COL_NAME] = metric_df[GROUP_COL_NAME].cat.add_categories( |
| 245 | + [self._all_groups_name] |
| 246 | + ) |
| 247 | + metric_df[GROUP_COL_NAME] = metric_df[GROUP_COL_NAME].fillna( |
| 248 | + self._all_groups_name |
| 249 | + ) |
| 250 | + |
| 251 | + return metric_df |
| 252 | + |
| 253 | + @staticmethod |
| 254 | + def _calc_averted(base_metrics: pd.DataFrame) -> pd.DataFrame: |
| 255 | + def subtract_no_measure(group, no_measure, merger): |
| 256 | + # Merge with no_measure to get the corresponding NO_MEASURE_VALUE value |
| 257 | + merged = group.merge( |
| 258 | + no_measure, on=merger, suffixes=("", "_" + NO_MEASURE_VALUE) |
| 259 | + ) |
| 260 | + # Subtract the NO_MEASURE_VALUE risk from the current risk |
| 261 | + merged[REFERENCE_RISK_NAME] = merged[RISK_COL_NAME + "_" + NO_MEASURE_VALUE] |
| 262 | + merged[AVERTED_RISK_NAME] = ( |
| 263 | + merged[RISK_COL_NAME + "_" + NO_MEASURE_VALUE] - merged[RISK_COL_NAME] |
| 264 | + ) |
| 265 | + return merged[ |
| 266 | + list(group.columns) + [REFERENCE_RISK_NAME, AVERTED_RISK_NAME] |
| 267 | + ] |
| 268 | + |
| 269 | + no_measures_metrics = base_metrics[ |
| 270 | + base_metrics[MEASURE_COL_NAME] == NO_MEASURE_VALUE |
| 271 | + ].copy() |
| 272 | + merger = [GROUP_COL_NAME, METRIC_COL_NAME, DATE_COL_NAME] |
| 273 | + if COORD_ID_COL_NAME in base_metrics.columns: |
| 274 | + merger.append(COORD_ID_COL_NAME) |
| 275 | + |
| 276 | + return base_metrics.groupby( |
| 277 | + [GROUP_COL_NAME, METRIC_COL_NAME, DATE_COL_NAME], |
| 278 | + group_keys=False, |
| 279 | + dropna=False, |
| 280 | + observed=False, |
| 281 | + ).apply(subtract_no_measure, no_measure=no_measures_metrics, merger=merger) |
0 commit comments