Repository: IAMconsortium/pyam
Pull request #604 (2022-01-17 19:27:46)

Add an "annualized growth rate" feature
# Please confirm that this PR has done the following:

- [x] Tests Added
- [x] Documentation Added
- ~Name of contributors Added to AUTHORS.rst~
- [x] Description in RELEASE_NOTES.md Added

# Description of PR

This PR adds a `growth_rate()` method to the `timeseries` and `compute` modules so it can be called as

```python
df.compute.growth_rate({"Primary Energy|Wind": "Growth rate of Wind"})
```
where *df* is an IamDataFrame, or

```python
pyam.timeseries.growth_rate(x)
```
where *x* is a pd.Series indexed over an (integer) time domain.

codecov[bot]: # [Codecov](https://codecov.io/gh/IAMconsortium/pyam/pull/604?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=IAMconsortium) Report
> Merging [#604](https://codecov.io/gh/IAMconsortium/pyam/pull/604?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=IAMconsortium) (72fc10e) into [main](https://codecov.io/gh/IAMconsortium/pyam/commit/8dc1d275a3981d16c9cf34fa7022cc407948ef64?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=IAMconsortium) (8dc1d27) will **increase** coverage by `0.0%`.
> The diff coverage is `100.0%`.

[![Impacted file tree graph](https://codecov.io/gh/IAMconsortium/pyam/pull/604/graphs/tree.svg?width=650&height=150&src=pr&token=ThxWlNkonJ&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=IAMconsortium)](https://codecov.io/gh/IAMconsortium/pyam/pull/604?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=IAMconsortium)

```diff
@@          Coverage Diff          @@
##            main    #604   +/-   ##
=====================================
  Coverage   94.2%   94.3%           
=====================================
  Files         52      53    +1     
  Lines       5415    5471   +56     
=====================================
+ Hits        5106    5162   +56     
  Misses       309     309           
```


| [Impacted Files](https://codecov.io/gh/IAMconsortium/pyam/pull/604?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=IAMconsortium) | Coverage Δ | |
|---|---|---|
| [pyam/core.py](https://codecov.io/gh/IAMconsortium/pyam/pull/604/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=IAMconsortium#diff-cHlhbS9jb3JlLnB5) | `94.4% <ø> (ø)` | |
| [pyam/compute.py](https://codecov.io/gh/IAMconsortium/pyam/pull/604/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=IAMconsortium#diff-cHlhbS9jb21wdXRlLnB5) | `100.0% <100.0%> (ø)` | |
| [pyam/timeseries.py](https://codecov.io/gh/IAMconsortium/pyam/pull/604/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=IAMconsortium#diff-cHlhbS90aW1lc2VyaWVzLnB5) | `96.9% <100.0%> (+0.6%)` | :arrow_up: |
| [tests/test\_feature\_growth\_rate.py](https://codecov.io/gh/IAMconsortium/pyam/pull/604/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=IAMconsortium#diff-dGVzdHMvdGVzdF9mZWF0dXJlX2dyb3d0aF9yYXRlLnB5) | `100.0% <100.0%> (ø)` | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/IAMconsortium/pyam/pull/604?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=IAMconsortium).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=IAMconsortium)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/IAMconsortium/pyam/pull/604?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=IAMconsortium). Last update [8dc1d27...72fc10e](https://codecov.io/gh/IAMconsortium/pyam/pull/604?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=IAMconsortium). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=IAMconsortium).


Patch:
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index 455812e..2fbdf42 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -1,5 +1,6 @@
 # Next release
 
+- [#604](https://github.com/IAMconsortium/pyam/pull/604) Add an annualized-growth-rate method
 - [#602](https://github.com/IAMconsortium/pyam/pull/602) Add a `compute` module/accessor and a learning-rate method 
 - [#600](https://github.com/IAMconsortium/pyam/pull/600) Add a `diff()` method
 - [#592](https://github.com/IAMconsortium/pyam/pull/592) Fix for running in jupyter-lab notebooks
diff --git a/doc/source/api.rst b/doc/source/api.rst
index bc832f8..61d657b 100644
--- a/doc/source/api.rst
+++ b/doc/source/api.rst
@@ -14,9 +14,9 @@ and methods.
    api/iamdataframe
    api/database
    api/filtering
+   api/compute
    api/plotting
    api/iiasa
-   api/compute
    api/statistics
    api/timeseries
    api/variables
diff --git a/doc/source/api/compute.rst b/doc/source/api/compute.rst
index 523104f..8191b21 100644
--- a/doc/source/api/compute.rst
+++ b/doc/source/api/compute.rst
@@ -1,7 +1,7 @@
 .. currentmodule:: pyam
 
-Computing indicators
-====================
+Derived timeseries data
+=======================
 
 .. autoclass:: IamComputeAccessor
    :members:
diff --git a/pyam/__init__.py b/pyam/__init__.py
index d7f8d7d..7264f41 100644
--- a/pyam/__init__.py
+++ b/pyam/__init__.py
@@ -27,7 +27,7 @@ logger = logging.getLogger(__name__)
 
 # get version number either from git (preferred) or metadata
 try:
-    __version__ = get_version(Path(__file__).parent.parent)
+    __version__ = get_version(root=Path(__file__).parents[1])
 except LookupError:
     try:
         __version__ = version("pyam-iamc")
diff --git a/pyam/compute.py b/pyam/compute.py
index 5e14a68..dee80d2 100644
--- a/pyam/compute.py
+++ b/pyam/compute.py
@@ -1,5 +1,7 @@
 import math
 import pandas as pd
+from pyam.index import replace_index_values
+from pyam.timeseries import growth_rate
 from pyam.utils import remove_from_list
 
 
@@ -19,6 +21,55 @@ class IamComputeAccessor:
     def __init__(self, df):
         self._df = df
 
+    def growth_rate(self, mapping, append=False):
+        """Compute the annualized growth rate of a timeseries along the time dimension
+
+        The growth rate parameter in period *t* is computed based on the changes
+        to the subsequent period, i.e., from period *t* to period *t+1*.
+
+        Parameters
+        ----------
+        mapping : dict
+            Mapping of *variable* item(s) to the name(s) of the computed data,
+            e.g.,
+
+            .. code-block:: python
+
+               {"variable": "name of growth-rate variable", ...}
+
+        append : bool, optional
+            Whether to append computed timeseries data to this instance.
+
+        Returns
+        -------
+        :class:`IamDataFrame` or **None**
+            Computed timeseries data or None if `append=True`.
+
+        Raises
+        ------
+        ValueError
+            Math domain error when timeseries crosses 0.
+
+        See Also
+        --------
+        pyam.timeseries.growth_rate
+
+        """
+        value = (
+            self._df._data[self._df._apply_filters(variable=mapping)]
+            .groupby(remove_from_list(self._df.dimensions, ["year"]), group_keys=False)
+            .apply(growth_rate)
+        )
+        if value.empty:
+            value = empty_series(remove_from_list(self._df.dimensions, "unit"))
+        else:
+            # drop level "unit" and reinsert below, replace "variable"
+            value.index = replace_index_values(
+                value.index.droplevel("unit"), "variable", mapping
+            )
+
+        return self._df._finalize(value, append=append, unit="")
+
     def learning_rate(self, name, performance, experience, append=False):
         """Compute the implicit learning rate from timeseries data
 
@@ -90,12 +141,7 @@ def _compute_learning_rate(x, performance, experience):
 
     # return empty pd.Series if not all relevant variables exist
     if not all([v in x.index for v in [performance, experience]]):
-        names = remove_from_list(x.index.names, "variable")
-        empty_list = [[]] * len(names)
-        return pd.Series(
-            index=pd.MultiIndex(levels=empty_list, codes=empty_list, names=names),
-            dtype="float64",
-        )
+        return empty_series(remove_from_list(x.index.names, "variable"))
 
     # compute the "experience parameter" (slope of experience curve on double-log scale)
     b = (x[performance] - x[performance].shift(periods=-1)) / (
@@ -104,3 +150,12 @@ def _compute_learning_rate(x, performance, experience):
 
     # translate to "learning rate" (e.g., cost reduction per doubling of capacity)
     return b.apply(lambda y: 1 - math.pow(2, y))
+
+
+def empty_series(names):
+    """Return an empty pd.Series with correct index names"""
+    empty_list = [[]] * len(names)
+    return pd.Series(
+        index=pd.MultiIndex(levels=empty_list, codes=empty_list, names=names),
+        dtype="float64",
+    )
diff --git a/pyam/core.py b/pyam/core.py
index 6c12837..336f49e 100755
--- a/pyam/core.py
+++ b/pyam/core.py
@@ -2141,7 +2141,7 @@ class IamDataFrame(object):
             Periods to shift for calculating difference, accepts negative values;
             passed to :meth:`pandas.DataFrame.diff`.
         append : bool, optional
-            Whether to append aggregated timeseries data to this instance.
+            Whether to append computed timeseries data to this instance.
 
         Returns
         -------
diff --git a/pyam/timeseries.py b/pyam/timeseries.py
index a098b27..6476a8f 100644
--- a/pyam/timeseries.py
+++ b/pyam/timeseries.py
@@ -1,6 +1,8 @@
 import logging
+import math
 import numpy as np
-from pyam.utils import isstr, to_int
+import pandas as pd
+from pyam.utils import isstr, to_int, raise_data_error
 
 logger = logging.getLogger(__name__)
 
@@ -127,3 +129,47 @@ def cross_threshold(
     if return_type == int:
         return [y + 1 for y in map(int, years)]
     return years
+
+
+def growth_rate(x):
+    """Compute the annualized growth rate from timeseries data
+
+    The annualized growth rate parameter in period *t* is computed assuming exponential
+    growth based on the changes from period *t* to period *t+1*.
+
+    Parameters
+    ----------
+    x : :class:`pandas.Series`
+        Timeseries data indexed over the time domain.
+
+    Returns
+    -------
+    Indexed :class:`pandas.Series` of annualized growth rates
+
+    Raises
+    ------
+    ValueError
+        Math domain error when timeseries crosses 0.
+
+    See Also
+    --------
+    pyam.IamComputeAccessor.growth_rate
+
+    """
+
+    if not (all([v > 0 for v in x.values]) or all([v < 0 for v in x.values])):
+        raise_data_error("Cannot compute growth rate when timeseries crosses 0", x)
+
+    x = x.sort_index()
+    growth_rate = (-x.diff(periods=-1) / x).values[:-1]  # diff on latest period is nan
+
+    if isinstance(x.index, pd.MultiIndex):
+        periods = x.index.get_level_values("year")
+    else:
+        periods = x.index
+    period_length = -pd.Series(periods).diff(periods=-1).values[:-1]
+
+    return pd.Series(
+        [math.pow(1 + v, 1 / d) - 1 for v, d in zip(growth_rate, period_length)],
+        index=x.index[:-1],
+    )
diff --git a/setup.cfg b/setup.cfg
index 1af6d17..73d1dfe 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -29,7 +29,7 @@ install_requires =
     numpy >= 1.19.0
     requests
     openpyxl
-    pandas >= 1.1.1
+    pandas >= 1.1.1, < 1.4
     pint <= 0.17
     PyYAML
     matplotlib >= 3.2.0
@@ -61,8 +61,6 @@ optional_io_formats =
 deploy =
     twine
     wheel
-    setuptools
-    setuptools_scm
 tutorials =
     pypandoc
     nbformat


Test patch:
diff --git a/tests/test_feature_growth_rate.py b/tests/test_feature_growth_rate.py
new file mode 100644
index 0000000..c03fd5c
--- /dev/null
+++ b/tests/test_feature_growth_rate.py
@@ -0,0 +1,69 @@
+import math
+import pandas as pd
+import pandas.testing as pdt
+from pyam import IamDataFrame, IAMC_IDX
+from pyam.testing import assert_iamframe_equal
+from pyam.timeseries import growth_rate
+import pytest
+
+from conftest import META_DF
+
+
+EXP_DF = IamDataFrame(
+    pd.DataFrame(
+        [
+            ["model_a", "scen_a", "World", "Growth Rate", "", 0.430969],
+            ["model_a", "scen_b", "World", "Growth Rate", "", 0.284735],
+        ],
+        columns=IAMC_IDX + [2005],
+    ),
+    meta=META_DF,
+)
+
+
+@pytest.mark.parametrize("append", (False, True))
+def test_growth_rate(test_df_year, append):
+    """Check computing the growth rate from an IamDataFrame"""
+
+    if append:
+        obs = test_df_year.copy()
+        obs.compute.growth_rate({"Primary Energy": "Growth Rate"}, append=True)
+        assert_iamframe_equal(test_df_year.append(EXP_DF), obs)
+    else:
+        obs = test_df_year.compute.growth_rate({"Primary Energy": "Growth Rate"})
+        assert_iamframe_equal(EXP_DF, obs)
+
+
+@pytest.mark.parametrize("append", (False, True))
+def test_growth_rate_empty(test_df_year, append):
+    """Assert that computing the growth rate with invalid variables returns empty"""
+
+    if append:
+        obs = test_df_year.copy()
+        obs.compute.growth_rate({"foo": "bar"}, append=True)
+        assert_iamframe_equal(test_df_year, obs)  # assert that no data was added
+    else:
+        obs = test_df_year.compute.growth_rate({"foo": "bar"})
+        assert obs.empty
+
+
+@pytest.mark.parametrize("x2010", (1, 27, -3))
+@pytest.mark.parametrize("rates", ([0.05, 1.25], [0.5, -0.5]))
+def test_growth_rate_timeseries(x2010, rates):
+    """Check several combinations of growth rates directly on the timeseries"""
+
+    x2013 = x2010 * math.pow(1 + rates[0], 3)  # 3 years: 2010 - 2013
+    x2017 = x2013 * math.pow(1 + rates[1], 4)  # 4 years: 2013 - 2017
+
+    pdt.assert_series_equal(
+        growth_rate(pd.Series([x2010, x2013, x2017], index=[2010, 2013, 2017])),
+        pd.Series(rates, index=[2010, 2013]),
+    )
+
+
+@pytest.mark.parametrize("value", (0, -1))
+def test_growth_rate_timeseries_fails(value):
+    """Check that a timeseries reaching/crossing 0 raises"""
+
+    with pytest.raises(ValueError, match="Cannot compute growth rate when*."):
+        growth_rate(pd.Series([1.0, value]))