Source code for climada.trajectories.interpolation

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

---

This modules implements different sparce matrices and numpy arrays
interpolation approaches.

"""

import logging
from abc import ABC
from collections.abc import Callable
from typing import Any, Dict, List, Optional

import numpy as np
from scipy import sparse

LOGGER = logging.getLogger(__name__)

__all__ = [
    "AllLinearStrategy",
    "ExponentialExposureStrategy",
    "linear_convex_combination",
    "linear_interp_matrix_elemwise",
    "exponential_convex_combination",
    "exponential_interp_matrix_elemwise",
]


[docs] def linear_interp_matrix_elemwise( mat_start: sparse.csr_matrix, mat_end: sparse.csr_matrix, number_of_interpolation_points: int, ) -> List[sparse.csr_matrix]: r""" Linearly interpolates between two sparse impact matrices. Creates a sequence of matrices representing a linear transition from a starting matrix to an ending matrix. The interpolation includes both the start and end points. Parameters ---------- mat_start : scipy.sparse.csr_matrix The starting impact matrix. Must have a shape compatible with `mat_end` for arithmetic operations. mat_end : scipy.sparse.csr_matrix The ending impact matrix. Must have a shape compatible with `mat_start` for arithmetic operations. number_of_interpolation_points : int The total number of matrices to return, including the start and end points. Must be $\ge 2$. Returns ------- list of scipy.sparse.csr_matrix A list of matrices, where the first element is `mat_start` and the last element is `mat_end`. The total length of the list is `number_of_interpolation_points`. Notes ----- The formula used for interpolation at proportion $p$ is: $$M_p = M_{start} \cdot (1 - p) + M_{end} \cdot p$$ The proportions $p$ range from 0 to 1, inclusive. """ return [ mat_start + prop * (mat_end - mat_start) for prop in np.linspace(0, 1, number_of_interpolation_points) ]
[docs] def exponential_interp_matrix_elemwise( mat_start: sparse.csr_matrix, mat_end: sparse.csr_matrix, number_of_interpolation_points: int, ) -> List[sparse.csr_matrix]: r""" Exponentially interpolates between two "impact matrices". This function performs interpolation in a logarithmic space, effectively achieving an exponential-like transition between `mat_start` and `mat_end`. It is designed for objects that wrap NumPy arrays and expose them via a `.data` attribute. Parameters ---------- mat_start : object The starting matrix object. Must have a `.data` attribute that is a NumPy array of positive values. mat_end : object The ending matrix object. Must have a `.data` attribute that is a NumPy array of positive values and have a compatible shape with `mat_start`. number_of_interpolation_points : int The total number of matrix objects to return, including the start and end points. Must be $\ge 2$. Returns ------- list of object A list of interpolated matrix objects. The first element corresponds to `mat_start` and the last to `mat_end` (after the conversion/reversion). The list length is `number_of_interpolation_points`. Notes ----- The interpolation is achieved by: 1. Mapping the matrix data to a transformed logarithmic space: $$M'_{i} = \ln(M_{i})}$$ (where $\ln$ is the natural logarithm, and $\epsilon$ is added to $M_{i}$ to prevent $\ln(0)$). 2. Performing standard linear interpolation on the transformed matrices $M'_{start}$ and $M'_{end}$ to get $M'_{interp}$: $$M'_{interp} = M'_{start} \cdot (1 - \text{ratio}) + M'_{end} \cdot \text{ratio}$$ 3. Mapping the result back to the original domain: $$M_{interp} = \exp(M'_{interp}$$ """ mat_start = mat_start.copy() mat_end = mat_end.copy() mat_start.data = np.log(mat_start.data + np.finfo(float).eps) mat_end.data = np.log(mat_end.data + np.finfo(float).eps) # Perform linear interpolation in the logarithmic domain res = [] num_points = number_of_interpolation_points for point in range(num_points): ratio = point / (num_points - 1) mat_interpolated = mat_start * (1 - ratio) + ratio * mat_end mat_interpolated.data = np.exp(mat_interpolated.data) res.append(mat_interpolated) return res
[docs] def linear_convex_combination(arr_start: np.ndarray, arr_end: np.ndarray) -> np.ndarray: r""" Performs a linear convex combination between two n x m NumPy arrays over their first dimension (n rows). This function interpolates each metric (column) linearly across the time steps (rows), including both the start and end states. Parameters ---------- arr_start : numpy.ndarray The starting array of metrics. The first dimension (rows) is assumed to represent the interpolation steps (e.g., dates/time points). arr_end : numpy.ndarray The ending array of metrics. Must have the exact same shape as `arr_start`. Returns ------- numpy.ndarray An array with the same shape as `arr_start` and `arr_end`. The values in the first dimension transition linearly from those in `arr_start` to those in `arr_end`. Raises ------ ValueError If `arr_start` and `arr_end` do not have the same shape. Example -------- >>> arr_start = [ [ 1, 1], [1, 2], [10, 20] ] >>> arr_end = [ [2, 2], [5, 6], [10, 30] ] >>> linear_interp_arrays(arr_start, arr_end) >>> [[1, 1], [3, 4], [10, 30]] Notes ----- The interpolation is performed element-wise along the first dimension (axis 0). For each row $i$ and proportion $p_i$, the result $R_i$ is calculated as: $$R_i = arr\_start_i \cdot (1 - p_i) + arr\_end_i \cdot p_i$$ where $p_i$ is generated by $\text{np.linspace}(0, 1, n)$ and $n$ is the size of the first dimension ($\text{arr\_start.shape}[0]$). """ if arr_start.shape != arr_end.shape: raise ValueError( f"Cannot interpolate arrays of different shapes: {arr_start.shape} and {arr_end.shape}." ) interpolation_range = arr_start.shape[0] prop1 = np.linspace(0, 1, interpolation_range) prop0 = 1 - prop1 if arr_start.ndim > 1: prop0, prop1 = prop0.reshape(-1, 1), prop1.reshape(-1, 1) return np.multiply(arr_start, prop0) + np.multiply(arr_end, prop1)
[docs] def exponential_convex_combination( arr_start: np.ndarray, arr_end: np.ndarray ) -> np.ndarray: r""" Performs exponential convex combination between two NumPy arrays over their first dimension. This function achieves an exponential-like transition by performing linear interpolation in the logarithmic space. Parameters ---------- arr_start : numpy.ndarray The starting array of metrics. Values must be positive. arr_end : numpy.ndarray The ending array of metrics. Must have the exact same shape as `arr_start`. Returns ------- numpy.ndarray An array with the same shape as `arr_start` and `arr_end`. The values in the first dimension transition exponentially from those in `arr_start` to those in `arr_end`. Raises ------ ValueError If `arr_start` and `arr_end` do not have the same shape. See Also --------- linear_interp_arrays: linear version of the interpolation. Notes ----- The interpolation is performed by transforming the arrays to a logarithmic domain, linearly interpolating, and then transforming back. The formula for the interpolated result $R$ at proportion $\text{prop}$ is: $$ R = \exp \left( \ln(A_{start}) \cdot (1 - \text{prop}) + \ln(A_{end}) \cdot \text{prop} \right) $$ where $A_{start}$ and $A_{end}$ are the input arrays (with $\epsilon$ added to prevent $\ln(0)$) and $\text{prop}$ ranges from 0 to 1. """ if arr_start.shape != arr_end.shape: raise ValueError( f"Cannot interpolate arrays of different shapes: {arr_start.shape} and {arr_end.shape}." ) interpolation_range = arr_start.shape[0] prop1 = np.linspace(0, 1, interpolation_range) prop0 = 1 - prop1 if arr_start.ndim > 1: prop0, prop1 = prop0.reshape(-1, 1), prop1.reshape(-1, 1) # Perform log transformation, linear interpolation, and exponential back-transformation log_arr_start = np.log(arr_start + np.finfo(float).eps) log_arr_end = np.log(arr_end + np.finfo(float).eps) interpolated_log_arr = np.multiply(log_arr_start, prop0) + np.multiply( log_arr_end, prop1 ) return np.exp(interpolated_log_arr)
class ImpactInterpolationStrategy(ABC): r""" Base abstract class for defining a set of interpolation strategies for impact outputs. This class serves as a blueprint for implementing specific interpolation methods (e.g., 'Linear', 'Exponential') describing how impact outputs should evolve between two points in time. Impacts result from three dimensions—Exposure, Hazard, and Vulnerability— each of which may change differently over time. Consequently, a distinct interpolation strategy is defined for each dimension. Exposure interpolation differs from Hazard and Vulnerability interpolation. Changes in exposure do not alter the shape of the impact matrices, which allows direct interpolation of the matrices themselves. For the Exposure dimension, interpolation therefore consists of generating intermediate impact matrices between the two time points, with exposure evolving while hazard and vulnerability remain fixed (to either the first or second point). In contrast, changes in Hazard may alter the set of events between the two time points, making direct interpolation of impact matrices impossible. Instead, impacts are first aggregated over the event dimension (i.e. the EAI metric). The evolution of impacts is then interpolated as a convex combination of metric sequences computed from two scenarios: one with hazard fixed at the initial time point and one with hazard fixed at the final time point. The same aggregation-based interpolation approach is applied to the Vulnerability dimension. Attributes ---------- exposure_interp : Callable The function used to interpolate sparse impact matrices over time with changing exposure dimension. Signature: (mat_start, mat_end, num_points, **kwargs) -> list[sparse.csr_matrix]. hazard_interp : Callable The function used to interpolate NumPy arrays of metrics over time with changing hazard dimension. Signature: (arr_start, arr_end, **kwargs) -> np.ndarray. vulnerability_interp : Callable The function used to interpolate NumPy arrays of metrics over time with changing vulnerability dimension. Signature: (arr_start, arr_end, **kwargs) -> np.ndarray. """ exposure_interp: Callable hazard_interp: Callable vulnerability_interp: Callable def interp_over_exposure_dim( self, imp_E0: sparse.csr_matrix, imp_E1: sparse.csr_matrix, interpolation_range: int, /, **kwargs: Optional[Dict[str, Any]], ) -> List[sparse.csr_matrix]: """ Interpolates between two impact matrices using the defined strategy for the exposure dimension. This method calls the function assigned to :attr:`exposure_interp` to generate a sequence of impact matrices of length "interpolation_range". Parameters ---------- imp_E0 : scipy.sparse.csr_matrix A sparse matrix of the impacts at the start of the range. imp_E1 : scipy.sparse.csr_matrix A sparse matrix of the impacts at the end of the range. interpolation_range : int The total number of time points to interpolate, including the start and end. **kwargs : Optional[Dict[str, Any]] Keyword arguments to pass to the underlying :attr:`exposure_interp` function. Returns ------- list of scipy.sparse.csr_matrix A list of ``interpolation_range`` interpolated impact matrices. Raises ------ ValueError If the underlying interpolation function raises a ``ValueError`` indicating incompatible matrix shapes. """ try: res = self.exposure_interp(imp_E0, imp_E1, interpolation_range, **kwargs) except ValueError as err: if str(err) == "inconsistent shapes": raise ValueError( "Tried to interpolate impact matrices of different shapes. " "A possible reason could be Exposures of different shapes." ) from err raise err return res def interp_over_hazard_dim( self, metric_0: np.ndarray, metric_1: np.ndarray, /, **kwargs: Optional[Dict[str, Any]], ) -> np.ndarray: """ Generates the convex combination between two arrays of metrics using the defined interpolation strategy for the hazard dimension. This method calls the function assigned to :attr:`hazard_interp`. Parameters ---------- metric_0 : numpy.ndarray The starting array of metrics. metric_1 : numpy.ndarray The ending array of metrics. Must have the same shape as ``metric_0``. **kwargs : Optional [Dict[str, Any]] Keyword arguments to pass to the underlying :attr:`hazard_interp` function. Returns ------- numpy.ndarray The resulting interpolated array. """ return self.hazard_interp(metric_0, metric_1, **kwargs) def interp_over_vulnerability_dim( self, metric_0: np.ndarray, metric_1: np.ndarray, /, **kwargs: Optional[Dict[str, Any]], ) -> np.ndarray: """ Generates the convex combination between two arrays of metrics using the defined interpolation strategy for the hazard dimension. This method calls the function assigned to :attr:`vulnerability_interp`. Parameters ---------- metric_0 : numpy.ndarray The starting array of metrics. metric_1 : numpy.ndarray The ending array of metrics. Must have the same shape as ``metric_0``. **kwargs : Optional[Dict[str, Any]] Keyword arguments to pass to the underlying :attr:`vulnerability_interp` function. Returns ------- numpy.ndarray The resulting interpolated array. """ # Note: Assuming the Callable takes the exact positional arguments return self.vulnerability_interp(metric_0, metric_1, **kwargs) class CustomImpactInterpolationStrategy(ImpactInterpolationStrategy): r"""Interface for interpolation strategies. This is the class to use to define custom interpolation strategies. """ def __init__( self, exposure_interp: Callable, hazard_interp: Callable, vulnerability_interp: Callable, ) -> None: super().__init__() self.exposure_interp = exposure_interp self.hazard_interp = hazard_interp self.vulnerability_interp = vulnerability_interp
[docs] class AllLinearStrategy(ImpactInterpolationStrategy): r"""Linear interpolation strategy over all dimensions."""
[docs] def __init__(self) -> None: super().__init__() self.exposure_interp = linear_interp_matrix_elemwise self.hazard_interp = linear_convex_combination self.vulnerability_interp = linear_convex_combination
[docs] class ExponentialExposureStrategy(ImpactInterpolationStrategy): r"""Exponential interpolation strategy for exposure and linear for Hazard and Vulnerability."""
[docs] def __init__(self) -> None: super().__init__() self.exposure_interp = exponential_interp_matrix_elemwise self.hazard_interp = linear_convex_combination self.vulnerability_interp = linear_convex_combination