Source code for climada.entity.measures.measure_config

"""
This file is part of CLIMADA.

Copyright (C) 2017 ETH Zurich, CLIMADA contributors listed in AUTHORS.

CLIMADA is free software: you can redistribute it and/or modify it under the
terms of the GNU General Public License as published by the Free
Software Foundation, version 3.

CLIMADA is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
PARTICULAR PURPOSE.  See the GNU General Public License for more details.

You should have received a copy of the GNU General Public License along
with CLIMADA. If not, see <https://www.gnu.org/licenses/>.

---

Define configuration dataclasses for Measure reading and writing.
"""

from __future__ import annotations

import logging
import warnings
from abc import ABC
from dataclasses import asdict, dataclass, field, fields
from datetime import datetime
from typing import Dict, Optional, Tuple, Union

import numpy as np
import pandas as pd

LOGGER = logging.getLogger(__name__)


@dataclass
class _ModifierConfig(ABC):
    """
    Abstract base class for all modifier configuration dataclasses.

    Provides shared serialization, deserialization, and representation
    logic for all concrete modifier config subclasses. Not intended to
    be instantiated directly.
    """

    def _filter_out_default_fields(self):
        """
        Partition the instance's fields into non-default and default groups.

        The ``haz_type`` field is always excluded from the output, as it
        is managed at the ``MeasureConfig`` level.

        Returns
        -------
        non_defaults : dict
            Fields whose current value differs from the dataclass default.
        defaults : dict
            Fields whose current value equals the dataclass default.
        """

        non_defaults = {}
        defaults = {}
        for defined_field in fields(self):
            val = getattr(self, defined_field.name)
            default = defined_field.default
            if defined_field.default_factory is not field().default_factory:
                default = defined_field.default_factory()

            if val != default:
                non_defaults[defined_field.name] = val
            else:
                defaults[defined_field.name] = val

        if "haz_type" in non_defaults:
            non_defaults.pop("haz_type")
        return non_defaults, defaults

    def to_dict(self):
        """
        Serialize the config to a flat dictionary, omitting default values.

        The ``haz_type`` field is always excluded from the output, as it
        is managed at the ``MeasureConfig`` level.

        Returns
        -------
        dict
            Dictionary containing only fields whose values differ from
            their dataclass defaults.
        """
        non_default, _ = self._filter_out_default_fields()
        return non_default

    @classmethod
    def from_dict(cls, kwargs_dict: dict):
        """
        Instantiate a config from a dictionary, ignoring unknown keys.

        Parameters
        ----------
        kwargs_dict : dict
            Input dictionary. Keys not matching any dataclass field are
            silently discarded.

        Returns
        -------
        _ModifierConfig
            A new instance of the calling subclass.
        """

        filtered = cls._filter_dict_to_fields(kwargs_dict)
        return cls(**filtered)

    @classmethod
    def _filter_dict_to_fields(cls, to_filter: dict):
        """
        Filter a dictionary to only the keys matching the dataclass fields.

        Parameters
        ----------
        to_filter : dict
            Input dictionary, potentially containing extra keys.

        Returns
        -------
        dict
            A copy of ``to_filter`` restricted to keys that correspond to declared
            dataclass fields on this class.
        """

        filtered = dict(
            filter(lambda k: k[0] in [f.name for f in fields(cls)], to_filter.items())
        )
        return filtered

    def __repr__(self) -> str:
        """
        Return a human-readable representation highlighting non-default fields.

        Non-default fields are shown prominently; default fields are shown
        below them. This makes it easy to see at a glance what has been
        configured on an instance.

        Returns
        -------
        str
            A formatted string representation of the instance.
        """

        non_defaults, defaults = self._filter_out_default_fields()
        ndf_fields_str = (
            "\n\t\t\t".join(f"{k}={v!r}" for k, v in non_defaults.items())
            if non_defaults
            else None
        )
        _ = (
            "\n\t\t\t".join(f"{k}={v!r}" for k, v in defaults.items())
            if defaults
            else None
        )
        ndf_fields = (
            "(" "\n\t\tNon default fields:" f"\n\t\t\t{ndf_fields_str}" "\n)"
            if ndf_fields_str
            else "()"
        )
        return f"{self.__class__.__name__}{ndf_fields}"


[docs] @dataclass(repr=False) class ImpfsetModifierConfig(_ModifierConfig): """ Configuration for modifications to an impact function set. Supports scaling or shifting MDD, PAA, and intensity curves, as well as replacement of the impact function set, loaded from a file path. If both a new file path and modifier values are provided, modifiers are applied after the replacement (and a warning is issued). Parameters ---------- haz_type : str Hazard type identifier (e.g. ``"TC"``) that this modifier targets. impf_ids : int or str or list of int or str, optional Impact function ID(s) to which modifications are applied. If ``None``, all impact functions are affected. impf_mdd_mult : float, optional Multiplicative factor applied to the mean damage degree (MDD) curve. Default is ``1.0`` (no change). impf_mdd_add : float, optional Additive offset applied to the MDD curve after multiplication. Default is ``0.0``. impf_paa_mult : float, optional Multiplicative factor applied to the percentage of affected assets (PAA) curve. Default is ``1.0``. impf_paa_add : float, optional Additive offset applied to the PAA curve after multiplication. Default is ``0.0``. impf_int_mult : float, optional Multiplicative factor applied to the intensity axis. Default is ``1.0``. impf_int_add : float, optional Additive offset applied to the intensity axis after multiplication. Default is ``0.0``. new_impfset_path : str, optional Path to an Excel file containing a replacement impact function set. If provided alongside modifier values, a warning is issued and modifiers are applied after loading the new set. Warns ----- UserWarning If ``new_impfset_path`` is set alongside any non-default modifier values. """ haz_type: str impf_ids: Optional[Union[int, str, list[Union[int, str]]]] = None impf_mdd_mult: float = 1.0 impf_mdd_add: float = 0.0 impf_paa_mult: float = 1.0 impf_paa_add: float = 0.0 impf_int_mult: float = 1.0 impf_int_add: float = 0.0 new_impfset_path: Optional[str] = None def __post_init__(self): config = self.to_dict() if "new_impfset_path" in config and any( key in config for key in [ "impf_mdd_add", "impf_mdd_mult", "impf_paa_add", "impf_paa_mult", "impf_int_add", "impf_int_mult", ] ): warnings.warn( "Both new impfset object and impfset modifiers are provided, " "modifiers will be applied after changing the impfset." )
[docs] @dataclass(repr=False) class HazardModifierConfig(_ModifierConfig): """ Configuration for modifications to a hazard. Supports scaling or shifting hazard intensity, applying a return-period frequency cutoff, and replacement of the hazard, loaded from a file path. If both a new file path and modifier values are provided, modifiers are applied after the replacement. Parameters ---------- haz_type : str Hazard type identifier (e.g. ``"TC"``) that this modifier targets. haz_int_mult : float, optional Multiplicative factor applied to hazard intensity. Default is ``1.0`` (no change). haz_int_add : float, optional Additive offset applied to hazard intensity after multiplication. Default is ``0.0``. new_hazard_path : str, optional Path to an HDF5 file containing a replacement hazard. If provided alongside modifier values, a warning is issued and modifiers are applied after loading the new hazard. impact_rp_cutoff : float, optional Return period (in years) below which hazard events are discarded. If ``None``, no cutoff is applied. Warns ----- UserWarning If ``new_hazard_path`` is set alongside any non-default modifier values or a non-``None`` ``impact_rp_cutoff``. """ haz_type: str haz_int_mult: Optional[float] = 1.0 haz_int_add: Optional[float] = 0.0 haz_freq_mult: Optional[float] = 1.0 haz_freq_add: Optional[float] = 0.0 new_hazard_path: Optional[str] = None impact_rp_cutoff: Optional[float] = None def __post_init__(self): config = self.to_dict() if "new_hazard_path" in config and any( key in config for key in [ "haz_int_mult", "haz_int_add", "haz_freq_mult", "haz_freq_add", "impact_rp_cutoff", ] ): warnings.warn( "Both new hazard object and hazard modifiers are provided, " "modifiers will be applied after changing the hazard." )
[docs] @dataclass(repr=False) class ExposuresModifierConfig(_ModifierConfig): """ Configuration for modifications to an exposures object. Supports remapping impact function IDs, zeroing out selected regions, and replacement of the exposures from a new file. If both a new file path and modifier values are provided, modifiers are applied after the replacement. Parameters ---------- reassign_impf_id : dict of {str: dict of {int or str: int or str}}, optional Nested mapping ``{haz_type: {old_id: new_id}}`` used to reassign impact function IDs in the exposures. If ``None``, no remapping is performed. set_to_zero : list of int, optional Region IDs for which exposure values are set to zero. If ``None``, no zeroing is applied. new_exposures_path : str, optional Path to an HDF5 file containing replacement exposures. If provided alongside modifier values, a warning is issued and modifiers are applied after loading the new exposures. Warns ----- UserWarning If ``new_exposures_path`` is set alongside any non-``None`` modifier values. """ reassign_impf_id: Optional[Dict[str, Dict[int | str, int | str]]] = None set_to_zero: Optional[list[int]] = None new_exposures_path: Optional[str] = None def __post_init__(self): config = self.to_dict() if "new_exposures_path" in config and any( key in config for key in ["reassign_impf_id", "set_to_zero"] ): warnings.warn( "Both new exposures object and exposures modifiers are provided, " "modifiers will be applied after changing the exposures." )
[docs] @dataclass(repr=False) class CostIncomeConfig(_ModifierConfig): """ Serializable configuration for a ``CostIncome`` object. Encodes all parameters required to construct a ``CostIncome`` instance, including optional custom cash flow schedules. Parameters ---------- mkt_price_year : int, optional Reference year for market prices. Defaults to the current year. init_cost : float, optional One-time initial investment cost (positive value). Default is ``0.0``. periodic_cost : float, optional Recurring cost per period (positive value). Default is ``0.0``. periodic_income : float, optional Recurring income per period. Default is ``0.0``. cost_yearly_growth_rate : float, optional Annual growth rate applied to periodic costs. Default is ``0.0``. income_yearly_growth_rate : float, optional Annual growth rate applied to periodic income. Default is ``0.0``. freq : str, optional Pandas offset alias defining the period length (e.g. ``"Y"`` for yearly, ``"M"`` for monthly). Default is ``"Y"``. custom_cash_flows : list of dict, optional Explicit cash flow schedule as a list of records with at minimum a ``"date"`` key (ISO 8601 string) and a value key. If provided, overrides the periodic cost/income logic. """ mkt_price_year: Optional[int] = field(default_factory=lambda: datetime.today().year) init_cost: float = 0.0 periodic_cost: float = 0.0 periodic_income: float = 0.0 cost_yearly_growth_rate: float = 0.0 income_yearly_growth_rate: float = 0.0 freq: str = "Y" custom_cash_flows: Optional[list[dict]] = None
[docs] @classmethod def from_cost_income(cls, cost_income: "CostIncome") -> "CostIncomeConfig": """ Construct a :class:`CostIncomeConfig` from a live :class:`CostIncome` object. Parameters ---------- cost_income : CostIncome The live ``CostIncome`` instance to serialise. Returns ------- CostIncomeConfig The config instance equivalent to the ``CostIncome``. """ custom = None if cost_income.custom_cash_flows is not None: custom = ( cost_income.custom_cash_flows.reset_index() .rename(columns={"index": "date"}) .assign(date=lambda df: df["date"].dt.strftime("%Y-%m-%d")) .to_dict(orient="records") ) return cls( mkt_price_year=cost_income.mkt_price_year.year, # datetime → int init_cost=abs(cost_income.init_cost), # stored negative → positive periodic_cost=abs(cost_income.periodic_cost), periodic_income=cost_income.periodic_income, cost_yearly_growth_rate=cost_income.cost_growth_rate, income_yearly_growth_rate=cost_income.income_growth_rate, freq=cost_income.freq, custom_cash_flows=custom, )
[docs] @dataclass(repr=False) class MeasureConfig(_ModifierConfig): """ Top-level serializable configuration for a single adaptation measure. Aggregates all modifier sub-configs (hazard, impact functions, exposures, cost/income) into a single object that can be round-tripped through dict, YAML, or a legacy Excel row. This class is the primary entry point for defining measures in a declarative, file-based workflow and serves as the serialization counterpart to :class:`~climada.entity.measures.base.Measure`. Parameters ---------- name : str Unique name identifying this measure. haz_type : str Hazard type identifier (e.g. ``"TC"``) this measure is designed for. impfset_modifier : ImpfsetModifierConfig Configuration describing modifications to the impact function set. hazard_modifier : HazardModifierConfig Configuration describing modifications to the hazard. exposures_modifier : ExposuresModifierConfig Configuration describing modifications to the exposures. cost_income : CostIncomeConfig Financial parameters associated with implementing this measure. implementation_duration : str, optional Pandas offset alias (e.g. ``"2Y"``) representing the time before the measure is fully operational. If ``None``, the measure takes effect immediately. color_rgb : tuple of float, optional RGB colour triple in the range ``[0, 1]`` used for visualisation. If ``None``, defaults to black ``(0, 0, 0)``. """ name: str haz_type: str impfset_modifier: ImpfsetModifierConfig hazard_modifier: HazardModifierConfig exposures_modifier: ExposuresModifierConfig cost_income: CostIncomeConfig implementation_duration: Optional[str] = None color_rgb: Optional[Tuple[float, float, float]] = None def __repr__(self) -> str: """ Return a detailed string representation of the measure configuration. All fields are shown, including sub-configs, with each on its own indented line. Returns ------- str A formatted multi-line string representation. """ fields_str = "\n\t".join(f"{k}={v!r}" for k, v in self.__dict__.items()) return f"{self.__class__.__name__}(\n\t{fields_str})"
[docs] def to_dict(self) -> dict: """ Serialize the measure configuration to a flat dictionary. Sub-config dictionaries are merged into the top-level dict (i.e. their keys are inlined, not nested). ``haz_type`` is always included at the top level. Fields with ``None`` values are preserved. Returns ------- dict Flat dictionary representation suitable for YAML or Excel serialization. """ return { "name": self.name, "haz_type": self.haz_type, **self.impfset_modifier.to_dict(), **self.hazard_modifier.to_dict(), **self.exposures_modifier.to_dict(), **self.cost_income.to_dict(), "implementation_duration": self.implementation_duration, "color_rgb": list(self.color_rgb) if self.color_rgb is not None else None, }
[docs] @classmethod def from_dict(cls, kwargs_dict: dict) -> "MeasureConfig": """ Instantiate a :class:`MeasureConfig` from a flat dictionary. Delegates sub-config construction to the respective ``from_dict`` classmethods. Unknown keys are silently discarded by each sub-config parser. Parameters ---------- kwargs_dict : dict Flat dictionary, as produced by :meth:`to_dict` or read from a legacy Excel row. Must contain at minimum ``"name"`` and ``"haz_type"``. Returns ------- MeasureConfig A fully populated configuration instance. """ return cls( name=kwargs_dict["name"], haz_type=kwargs_dict["haz_type"], impfset_modifier=ImpfsetModifierConfig.from_dict(kwargs_dict), hazard_modifier=HazardModifierConfig.from_dict(kwargs_dict), exposures_modifier=ExposuresModifierConfig.from_dict(kwargs_dict), cost_income=CostIncomeConfig.from_dict(kwargs_dict), implementation_duration=kwargs_dict.get("implementation_duration"), color_rgb=cls._normalize_color(kwargs_dict.get("color_rgb")), )
@staticmethod def _normalize_color(color_rgb): # 1. Handle None and NaN (np.nan, pd.NA, float('nan')) if color_rgb is None or pd.isna(color_rgb) is True: return None # 2. Convert sequence types (list, np.array, tuple) to a standard tuple try: # Flatten in case it's a nested numpy array, then convert to tuple result = tuple(np.array(color_rgb).flatten().tolist()) # 3. Enforce the length of three if len(result) != 3: raise ValueError(f"Expected 3 digits, got {len(result)}") return result except (TypeError, ValueError) as err: # Handle cases where input isn't iterable or wrong length raise ValueError(f"Invalid color format: {color_rgb}.") from err
[docs] def to_yaml(self, path: str) -> None: """ Write this configuration to a YAML file. The file is structured as ``{"measures": [<this config as dict>]}``, matching the expected format for :meth:`from_yaml`. Parameters ---------- path : str Destination file path. Will be created or overwritten. """ import yaml with open(path, "w") as opened_file: yaml.dump( {"measures": [self.to_dict()]}, opened_file, default_flow_style=False, sort_keys=False, )
[docs] @classmethod def from_yaml(cls, path: str) -> "MeasureConfig": """ Load a :class:`MeasureConfig` from a YAML file. Expects the file to contain a top-level ``"measures"`` list; reads only the first entry. Parameters ---------- path : str Path to the YAML file to read. Returns ------- MeasureConfig The configuration parsed from the first entry in ``measures``. """ import yaml with open(path) as opened_file: return cls.from_dict(yaml.safe_load(opened_file)["measures"][0])
[docs] @classmethod def from_row(cls, row: pd.Series) -> "MeasureConfig": """ Construct a :class:`MeasureConfig` from a legacy Excel row. Converts the row to a dictionary and delegates to :meth:`from_dict`. This is the primary migration path for measures currently stored in the legacy Excel-based ``MeasureSet`` format. Parameters ---------- row : pd.Series A single row from a legacy measures Excel sheet, with column names matching the flat dictionary keys expected by :meth:`from_dict`. Returns ------- MeasureConfig A configuration instance populated from the row data. """ row_dict = row.to_dict() return cls.from_dict(row_dict)