Repository: IAMconsortium/pyam
Pull request #531 (2021-05-11 17:47:44)

Set explicit test for latest supported pandas version
# Please confirm that this PR has done the following:

- [x] Tests Added
- [x] Documentation Added
- ~Name of contributors Added to AUTHORS.rst~
- ~Description in RELEASE_NOTES.md Added~

# Description of PR

Per an issue reported by @GamzeUnlu95 that the current pyam version was incompatible with pandas 1.0.x, I added an explicit test for the "oldest" supported version of pandas and updated the requirements accordingly.



Patch:
diff --git a/.github/workflows/build-docs.yml b/.github/workflows/build-docs.yml
index 9dc8d3e..bb09619 100644
--- a/.github/workflows/build-docs.yml
+++ b/.github/workflows/build-docs.yml
@@ -5,7 +5,7 @@ name: build the docs
 
 on:
   push:
-    branches: [ 'master' ]
+    branches: [ 'main' ]
   pull_request:
     branches: [ '**' ]
 
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index 166ff07..e67c5eb 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -1,6 +1,9 @@
 # Next Release
 
+- [#537](https://github.com/IAMconsortium/pyam/pull/537) Enhance binary ops to support numerical arguments
+- [#533](https://github.com/IAMconsortium/pyam/pull/533) Add an `apply()` function for custom mathematical operations
 - [#527](https://github.com/IAMconsortium/pyam/pull/527) Add an in-dataframe basic mathematical operations `subtract`, `add`, `multiply`, `divide`
+- [#523](https://github.com/IAMconsortium/pyam/pull/523) Add feature to compute weights for de-biasing using count
 - [#519](https://github.com/IAMconsortium/pyam/pull/519) Enable explicit `label` and fix for non-string items in plot legend 
 
 # Release v0.11.0
diff --git a/pyam/_debiasing.py b/pyam/_debiasing.py
new file mode 100644
index 0000000..7378c8e
--- /dev/null
+++ b/pyam/_debiasing.py
@@ -0,0 +1,9 @@
+def _compute_bias(df, name, method, axis):
+    """Internal implementation for computing bias weights"""
+    if method == "count":
+        # invert from the count to obtain the weighting factor
+        count = 1 / df.meta.groupby(axis).count().exclude
+        count.name = name
+        df.meta = df.meta.join(count, on=axis, how="outer")
+    else:
+        raise ValueError(f"Unknown method {method} for computing bias weights!")
diff --git a/pyam/_ops.py b/pyam/_ops.py
index ba05c65..dfcdd44 100644
--- a/pyam/_ops.py
+++ b/pyam/_ops.py
@@ -1,5 +1,7 @@
 import operator
-from pyam.index import append_index_level
+import pandas as pd
+from pyam.index import append_index_level, get_index_levels
+from pyam.utils import to_list
 
 
 KNOWN_OPS = {
@@ -10,7 +12,7 @@ KNOWN_OPS = {
 }
 
 
-def _op_data(df, method_args, name, method, axis):
+def _op_data(df, name, method, axis, args=(), **kwds):
     """Internal implementation of numerical operations on timeseries"""
 
     if axis not in df._data.index.names:
@@ -18,13 +20,46 @@ def _op_data(df, method_args, name, method, axis):
 
     if method in KNOWN_OPS:
         method = KNOWN_OPS[method]
+    elif callable(method):
+        pass
     else:
         raise ValueError(f"Unknown method: {method}")
 
     cols = df._data.index.names.difference([axis])
-    _args = [df.filter(**{axis: i})._data.groupby(cols).sum() for i in method_args]
+    _args = [_get_values(df, axis, value, cols) for value in args]
 
-    _value = method(*_args)
+    for key, value in kwds.items():
+        kwds[key] = _get_values(df, axis, value, cols)
+
+    _value = method(*_args, **kwds)
+    if not isinstance(_value, pd.Series):
+        msg = f"Value returned by `{method.__name__}` cannot be cast to an IamDataFrame"
+        raise ValueError(f"{msg}: {_value}")
     _value.index = append_index_level(_value.index, codes=0, level=name, name=axis)
 
     return _value
+
+
+def _get_values(df, axis, value, cols):
+    """Return grouped data if value is in axis. Otherwise return value.
+
+    Parameters
+    ----------
+    df : IamDataFrame
+        IamDataFrame to select the values from.
+    axis : str
+        Axis in `df` that contains value.
+    value : str or list of str or any
+        Either str or list of str in axis or anything else.
+    cols : list
+        Columns in df that are not `axis`.
+
+    Returns
+    -------
+    Either filtered timeseries from `df` or `value`
+
+    """
+    if any(v in get_index_levels(df._data, axis) for v in to_list(value)):
+        return df.filter(**{axis: value})._data.groupby(cols).sum()
+    else:
+        return value
diff --git a/pyam/core.py b/pyam/core.py
index 655da42..50a00fe 100755
--- a/pyam/core.py
+++ b/pyam/core.py
@@ -11,6 +11,8 @@ import pandas as pd
 from pathlib import Path
 from tempfile import TemporaryDirectory
 
+from pyam._debiasing import _compute_bias
+
 try:
     from datapackage import Package
 
@@ -958,6 +960,52 @@ class IamDataFrame(object):
                 self._exclude_on_fail(df)
             return df.reset_index()
 
+    def compute_bias(self, name, method, axis):
+        """Compute the bias weights and add to 'meta'
+
+        Parameters
+        ----------
+        name : str
+           Column name in the 'meta' dataframe
+        method : str
+            Method to compute the bias weights, see the notes
+        axis : str
+            Index dimensions on which to apply the `method`
+
+        Notes
+        -----
+
+        The following methods are implemented:
+
+        - "count": use the inverse of the number of scenarios grouped by `axis` names.
+
+          Using the following method on an IamDataFrame with three scenarios
+
+          .. code-block:: python
+
+              df.compute_bias(name="bias-weight", method="count", axis="scenario")
+
+          results in the following column to be added to *df.meta*:
+
+          .. list-table::
+             :header-rows: 1
+
+             * - model
+               - scenario
+               - bias-weight
+             * - model_a
+               - scen_a
+               - 0.5
+             * - model_a
+               - scen_b
+               - 1
+             * - model_b
+               - scen_a
+               - 0.5
+
+        """
+        _compute_bias(self, name, method, axis)
+
     def rename(
         self, mapping=None, inplace=False, append=False, check_duplicates=True, **kwargs
     ):
@@ -1064,7 +1112,7 @@ class IamDataFrame(object):
     def convert_unit(
         self, current, to, factor=None, registry=None, context=None, inplace=False
     ):
-        r"""Convert all data having *current* units to new units.
+        """Convert all timeseries data having *current* units to new units.
 
         If *factor* is given, existing values are multiplied by it, and the
         *to* units are assigned to the 'unit' column.
@@ -1740,7 +1788,7 @@ class IamDataFrame(object):
 
         Parameters
         ----------
-        a, b : str or list of str
+        a, b : str, list of str or a number
             Items to be used for the addition.
         name : str
             Name of the computed timeseries data on the `axis`.
@@ -1754,7 +1802,7 @@ class IamDataFrame(object):
         :class:`IamDataFrame` or **None**
             Computed timeseries data or None if `append=True`.
         """
-        _value = _op_data(self, (a, b), name, "add", axis=axis)
+        _value = _op_data(self, name, "add", axis=axis, args=(a, b))
         if append:
             self.append(_value, inplace=True)
         else:
@@ -1770,7 +1818,7 @@ class IamDataFrame(object):
 
         Parameters
         ----------
-        a, b : str or list of str
+        a, b : str, list of str or a number
             Items to be used for the subtraction.
         name : str
             Name of the computed timeseries data on the `axis`.
@@ -1784,7 +1832,7 @@ class IamDataFrame(object):
         :class:`IamDataFrame` or **None**
             Computed timeseries data or None if `append=True`.
         """
-        _value = _op_data(self, (a, b), name, "subtract", axis=axis)
+        _value = _op_data(self, name, "subtract", axis=axis, args=(a, b))
         if append:
             self.append(_value, inplace=True)
         else:
@@ -1800,7 +1848,7 @@ class IamDataFrame(object):
 
         Parameters
         ----------
-        a, b : str or list of str
+        a, b : str, list of str or a number
             Items to be used for the division.
         name : str
             Name of the computed timeseries data on the `axis`.
@@ -1814,7 +1862,7 @@ class IamDataFrame(object):
         :class:`IamDataFrame` or **None**
             Computed timeseries data or None if `append=True`.
         """
-        _value = _op_data(self, (a, b), name, "multiply", axis=axis)
+        _value = _op_data(self, name, "multiply", axis=axis, args=(a, b))
         if append:
             self.append(_value, inplace=True)
         else:
@@ -1830,7 +1878,7 @@ class IamDataFrame(object):
 
         Parameters
         ----------
-        a, b : str or list of str
+        a, b : str, list of str or a number
             Items to be used for the division.
         name : str
             Name of the computed timeseries data on the `axis`.
@@ -1844,7 +1892,43 @@ class IamDataFrame(object):
         :class:`IamDataFrame` or **None**
             Computed timeseries data or None if `append=True`.
         """
-        _value = _op_data(self, (a, b), name, "divide", axis=axis)
+        _value = _op_data(self, name, "divide", axis=axis, args=(a, b))
+        if append:
+            self.append(_value, inplace=True)
+        else:
+            return IamDataFrame(_value, meta=self.meta)
+
+    def apply(self, func, name, axis="variable", append=False, args=(), **kwds):
+        """Apply a function to components of timeseries data along an `axis`
+
+        This function computes a function `func` using timeseries data selected
+        along an `axis` downselected by keyword arguments.
+        The length of components needs to match the number of required arguments
+        of `func`.
+
+        Parameters
+        ----------
+        func : function
+            Function to apply to `components` along `axis`.
+        name : str
+            Name of the computed timeseries data on the `axis`.
+        axis : str, optional
+            Axis along which to compute.
+        append : bool, optional
+            Whether to append aggregated timeseries data to this instance.
+        args : tuple or list of str
+            List of variables to pass as positional arguments to `func`.
+        **kwds
+            Additional keyword arguments to pass as keyword arguments to `func`. If the
+            name of a variable is given, the associated timeseries is passed. Otherwise
+            the value itself is passed.
+
+        Returns
+        -------
+        :class:`IamDataFrame` or **None**
+            Computed timeseries data or None if `append=True`.
+        """
+        _value = _op_data(self, name, func, axis=axis, args=args, **kwds)
         if append:
             self.append(_value, inplace=True)
         else:
@@ -2424,15 +2508,15 @@ def compare(
     """
     ret = pd.concat(
         {
-            right_label: right.data.set_index(right._LONG_IDX),
             left_label: left.data.set_index(left._LONG_IDX),
+            right_label: right.data.set_index(right._LONG_IDX),
         },
         axis=1,
     )
     ret.columns = ret.columns.droplevel(1)
     if drop_close:
         ret = ret[~np.isclose(ret[left_label], ret[right_label], **kwargs)]
-    return ret[[right_label, left_label]]
+    return ret
 
 
 def concat(dfs, ignore_meta_conflict=False, **kwargs):
diff --git a/pyam/utils.py b/pyam/utils.py
index 2ba3227..ee24135 100644
--- a/pyam/utils.py
+++ b/pyam/utils.py
@@ -264,11 +264,11 @@ def format_data(df, index, **kwargs):
     # check that index and required columns exist
     missing_index = [c for c in index if c not in df.columns]
     if missing_index:
-        raise ValueError(f"Missing index columns `{missing_index}`!")
+        raise ValueError(f"Missing index columns: {missing_index}")
 
     missing_required_col = [c for c in REQUIRED_COLS if c not in df.columns]
     if missing_required_col:
-        raise ValueError(f"Missing required columns `{missing_required_col}`!")
+        raise ValueError(f"Missing required columns: {missing_required_col}")
 
     # check whether data in wide format (IAMC) or long format (`value` column)
     if "value" in df.columns:
@@ -278,8 +278,7 @@ def format_data(df, index, **kwargs):
         elif "time" in df.columns:
             time_col = "time"
         else:
-            msg = "Invalid time format, must have either `year` or `time`!"
-            raise ValueError(msg)
+            raise ValueError("Invalid time format, must have either `year` or `time`!")
         extra_cols = [
             c
             for c in df.columns
@@ -306,8 +305,7 @@ def format_data(df, index, **kwargs):
             time_col = "time"
             melt_cols = time_cols
         else:
-            msg = "invalid column format, must be either years or `datetime`!"
-            raise ValueError(msg)
+            raise ValueError("Invalid time format, must be either years or `datetime`!")
         cols = index + REQUIRED_COLS + extra_cols
         df = pd.melt(
             df,
diff --git a/setup.py b/setup.py
index 9da783c..b00796e 100755
--- a/setup.py
+++ b/setup.py
@@ -16,11 +16,11 @@ REQUIREMENTS = [
     "iam-units>=2020.4.12",
     "numpy",
     "requests",
-    "pandas>=0.25.0",
+    "pandas>=1.1.1",
     "pint",
     "plotly",
     "PyYAML",
-    "matplotlib",
+    "matplotlib>=3.2.0",
     "seaborn",
     "six",
     "xlrd<2.0",


Test patch:
diff --git a/.github/workflows/pytest-dependency.yml b/.github/workflows/pytest-dependency.yml
new file mode 100644
index 0000000..196deed
--- /dev/null
+++ b/.github/workflows/pytest-dependency.yml
@@ -0,0 +1,33 @@
+# This workflow installs the package and runs the tests with older versions of dependencies
+# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions
+
+name: pytest-dependency
+
+on:
+  push:
+    branches: [ 'main' ]
+  pull_request:
+    branches: [ '**' ]
+
+jobs:
+  pytest-dependency:
+
+    runs-on: ubuntu-latest
+
+    steps:
+    - uses: actions/checkout@v2
+
+    - name: Set up Python
+      uses: actions/setup-python@v2
+      with:
+        python-version: '3.7'
+
+    - name: Install specific out-dated version of dependencies
+      run: pip install pandas==1.1.1 matplotlib==3.2.0
+      # Update the corresponding package requirements when changing dependency versions
+
+    - name: Install other dependencies and package
+      run: pip install -e .[tests,deploy,optional-io-formats,tutorials]
+
+    - name: Test with pytest
+      run: pytest tests --mpl
diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml
index bab79a8..008822a 100644
--- a/.github/workflows/pytest.yml
+++ b/.github/workflows/pytest.yml
@@ -5,7 +5,7 @@ name: pytest
 
 on:
   push:
-    branches: [ 'master', 'main' ]
+    branches: [ 'main' ]
   pull_request:
     branches: [ '**' ]
 
diff --git a/tests/test_feature_compare.py b/tests/test_feature_compare.py
index 986cf22..fa178fb 100644
--- a/tests/test_feature_compare.py
+++ b/tests/test_feature_compare.py
@@ -10,15 +10,15 @@ def test_compare(test_df):
     clone._data.iloc[0] = 2
     clone.rename(variable={"Primary Energy|Coal": "Primary Energy|Gas"}, inplace=True)
 
-    obs = compare(test_df, clone, right_label="test_df", left_label="clone")
+    obs = compare(test_df, clone, left_label="test_df", right_label="clone")
 
     exp = pd.DataFrame(
         [
-            ["Primary Energy", "EJ/yr", dt.datetime(2005, 6, 17), 2, 1],
-            ["Primary Energy|Coal", "EJ/yr", dt.datetime(2005, 6, 17), np.nan, 0.5],
-            ["Primary Energy|Coal", "EJ/yr", dt.datetime(2010, 7, 21), np.nan, 3],
-            ["Primary Energy|Gas", "EJ/yr", dt.datetime(2005, 6, 17), 0.5, np.nan],
-            ["Primary Energy|Gas", "EJ/yr", dt.datetime(2010, 7, 21), 3, np.nan],
+            ["Primary Energy", "EJ/yr", dt.datetime(2005, 6, 17), 1, 2],
+            ["Primary Energy|Coal", "EJ/yr", dt.datetime(2005, 6, 17), 0.5, np.nan],
+            ["Primary Energy|Coal", "EJ/yr", dt.datetime(2010, 7, 21), 3, np.nan],
+            ["Primary Energy|Gas", "EJ/yr", dt.datetime(2005, 6, 17), np.nan, 0.5],
+            ["Primary Energy|Gas", "EJ/yr", dt.datetime(2010, 7, 21), np.nan, 3],
         ],
         columns=["variable", "unit", "time", "test_df", "clone"],
     )
diff --git a/tests/test_feature_debiasing.py b/tests/test_feature_debiasing.py
new file mode 100644
index 0000000..590eac9
--- /dev/null
+++ b/tests/test_feature_debiasing.py
@@ -0,0 +1,25 @@
+from pyam import IamDataFrame
+import pytest
+from numpy.testing import assert_array_equal
+
+
+@pytest.mark.parametrize(
+    "axis, exp",
+    (["scenario", [0.5, 0.5, 1]], [["model", "scenario"], [1, 1, 1]]),
+)
+def test_debiasing_count(test_pd_df, axis, exp):
+    """Check computing bias weights counting the number of scenarios by scenario name"""
+
+    # modify the default test data to have three distinct scenarios
+    test_pd_df.loc[1, "model"] = "model_b"
+    df = IamDataFrame(test_pd_df)
+    df.compute_bias(method="count", name="bias", axis=axis)
+
+    assert_array_equal(df["bias"].values, exp)
+
+
+def test_debiasing_unknown_method(test_df_year):
+    """Check computing bias weights counting the number of scenarios by scenario name"""
+    msg = "Unknown method foo for computing bias weights!"
+    with pytest.raises(ValueError, match=msg):
+        test_df_year.compute_bias(method="foo", name="bias", axis="scenario")
diff --git a/tests/test_ops.py b/tests/test_ops.py
index 0869e58..61e9153 100644
--- a/tests/test_ops.py
+++ b/tests/test_ops.py
@@ -6,27 +6,39 @@ from pyam.testing import assert_iamframe_equal
 from pyam._ops import _op_data
 
 
+def test_add_raises(test_df_year):
+    """Calling an operation with args that don't return an IamDataFrame raises"""
+    match = "Value returned by `add` cannot be cast to an IamDataFrame: 5"
+    with pytest.raises(ValueError, match=match):
+        test_df_year.add(2, 3, "foo")
+
+
+@pytest.mark.parametrize(
+    "arg, value",
+    (
+        ("Primary Energy|Coal", ["scen_a", 1 + 0.5, 6 + 3]),
+        (2, [["scen_a", "scen_b"], [1 + 2, 2 + 2], [6 + 2, 7 + 2]]),
+    ),
+)
 @pytest.mark.parametrize("append", (False, True))
-def test_add_variable(test_df_year, append):
+def test_add_variable(test_df_year, arg, value, append):
     """Verify that in-dataframe addition works on the default `variable` axis"""
 
-    v = ("Primary Energy", "Primary Energy|Coal", "Sum")
     exp = IamDataFrame(
-        pd.DataFrame([1 + 0.5, 6 + 3], index=[2005, 2010]).T,
+        pd.DataFrame(value, index=["scenario", 2005, 2010]).T,
         model="model_a",
-        scenario="scen_a",
         region="World",
-        variable=v[2],
+        variable="Sum",
         unit="EJ/yr",
         meta=test_df_year.meta,
     )
 
     if append:
         obs = test_df_year.copy()
-        obs.add(*v, append=True)
+        obs.add("Primary Energy", arg, "Sum", append=True)
         assert_iamframe_equal(test_df_year.append(exp), obs)
     else:
-        assert_iamframe_equal(exp, test_df_year.add(*v))
+        assert_iamframe_equal(exp, test_df_year.add("Primary Energy", arg, "Sum"))
 
 
 @pytest.mark.parametrize("append", (False, True))
@@ -52,27 +64,32 @@ def test_add_scenario(test_df_year, append):
         assert_iamframe_equal(exp, obs)
 
 
+@pytest.mark.parametrize(
+    "arg, value",
+    (
+        ("Primary Energy|Coal", ["scen_a", 1 - 0.5, 6 - 3]),
+        (2, [["scen_a", "scen_b"], [1 - 2, 2 - 2], [6 - 2, 7 - 2]]),
+    ),
+)
 @pytest.mark.parametrize("append", (False, True))
-def test_subtract_variable(test_df_year, append):
+def test_subtract_variable(test_df_year, arg, value, append):
     """Verify that in-dataframe subtraction works on the default `variable` axis"""
 
-    v = ("Primary Energy", "Primary Energy|Coal", "Primary Energy|Other")
     exp = IamDataFrame(
-        pd.DataFrame([1 - 0.5, 6 - 3], index=[2005, 2010]).T,
+        pd.DataFrame(value, index=["scenario", 2005, 2010]).T,
         model="model_a",
-        scenario="scen_a",
         region="World",
-        variable=v[2],
+        variable="Diff",
         unit="EJ/yr",
         meta=test_df_year.meta,
     )
 
     if append:
         obs = test_df_year.copy()
-        obs.subtract(*v, append=True)
+        obs.subtract("Primary Energy", arg, "Diff", append=True)
         assert_iamframe_equal(test_df_year.append(exp), obs)
     else:
-        obs = test_df_year.subtract(*v)
+        obs = test_df_year.subtract("Primary Energy", arg, "Diff")
         assert_iamframe_equal(exp, obs)
 
 
@@ -99,27 +116,32 @@ def test_subtract_scenario(test_df_year, append):
         assert_iamframe_equal(exp, obs)
 
 
+@pytest.mark.parametrize(
+    "arg, value",
+    (
+        ("Primary Energy|Coal", ["scen_a", 1 * 0.5, 6 * 3]),
+        (2, [["scen_a", "scen_b"], [1 * 2, 2 * 2], [6 * 2, 7 * 2]]),
+    ),
+)
 @pytest.mark.parametrize("append", (False, True))
-def test_multiply_variable(test_df_year, append):
+def test_multiply_variable(test_df_year, arg, value, append):
     """Verify that in-dataframe addition works on the default `variable` axis"""
 
-    v = ("Primary Energy", "Primary Energy|Coal", "Product")
     exp = IamDataFrame(
-        pd.DataFrame([1 * 0.5, 6 * 3], index=[2005, 2010]).T,
+        pd.DataFrame(value, index=["scenario", 2005, 2010]).T,
         model="model_a",
-        scenario="scen_a",
         region="World",
-        variable=v[2],
+        variable="Prod",
         unit="EJ/yr",
         meta=test_df_year.meta,
     )
 
     if append:
         obs = test_df_year.copy()
-        obs.multiply(*v, append=True)
+        obs.multiply("Primary Energy", arg, "Prod", append=True)
         assert_iamframe_equal(test_df_year.append(exp), obs)
     else:
-        assert_iamframe_equal(exp, test_df_year.multiply(*v))
+        assert_iamframe_equal(exp, test_df_year.multiply("Primary Energy", arg, "Prod"))
 
 
 @pytest.mark.parametrize("append", (False, True))
@@ -145,27 +167,32 @@ def test_multiply_scenario(test_df_year, append):
         assert_iamframe_equal(exp, obs)
 
 
+@pytest.mark.parametrize(
+    "arg, value",
+    (
+        ("Primary Energy|Coal", ["scen_a", 1 / 0.5, 6 / 3]),
+        (2, [["scen_a", "scen_b"], [1 / 2, 2 / 2], [6 / 2, 7 / 2]]),
+    ),
+)
 @pytest.mark.parametrize("append", (False, True))
-def test_divide_variable(test_df_year, append):
+def test_divide_variable(test_df_year, arg, value, append):
     """Verify that in-dataframe addition works on the default `variable` axis"""
 
-    v = ("Primary Energy", "Primary Energy|Coal", "Ratio")
     exp = IamDataFrame(
-        pd.DataFrame([1 / 0.5, 6 / 3], index=[2005, 2010]).T,
+        pd.DataFrame(value, index=["scenario", 2005, 2010]).T,
         model="model_a",
-        scenario="scen_a",
         region="World",
-        variable=v[2],
+        variable="Ratio",
         unit="EJ/yr",
         meta=test_df_year.meta,
     )
 
     if append:
         obs = test_df_year.copy()
-        obs.divide(*v, append=True)
+        obs.divide("Primary Energy", arg, "Ratio", append=True)
         assert_iamframe_equal(test_df_year.append(exp), obs)
     else:
-        assert_iamframe_equal(exp, test_df_year.divide(*v))
+        assert_iamframe_equal(exp, test_df_year.divide("Primary Energy", arg, "Ratio"))
 
 
 @pytest.mark.parametrize("append", (False, True))
@@ -191,13 +218,50 @@ def test_divide_scenario(test_df_year, append):
         assert_iamframe_equal(exp, obs)
 
 
+@pytest.mark.parametrize("append", (False, True))
+def test_apply_variable(plot_stackplot_df, append):
+    """Verify that in-dataframe apply works on the default `variable` axis"""
+
+    def custom_func(a, b, c, d, e):
+        return a / c + b / d + e
+
+    args = ["Emissions|CO2|Tar", "Emissions|CO2|Cars", "Emissions|CO2|LUC"]
+    kwds = {"d": "Emissions|CO2|Agg", "e": 5}
+    exp = IamDataFrame(
+        pd.DataFrame(
+            [
+                0.3 / (-0.3) + 1.6 / 0.5 + 5,
+                0.35 / (-0.6) + 3.8 / (-0.1) + 5,
+                0.35 / (-1.2) + 3.0 / (-0.5) + 5,
+                0.33 / (-1.0) + 2.5 / (-0.7) + 5,
+            ],
+            index=[2005, 2010, 2015, 2020],
+        ).T,
+        model="IMG",
+        scenario="a_scen",
+        region="World",
+        variable="new variable",
+        unit="Mt CO2/yr",
+    )
+
+    if append:
+        obs = plot_stackplot_df.copy()
+        obs.apply(custom_func, name="new variable", append=True, args=args, **kwds)
+        assert_iamframe_equal(plot_stackplot_df.append(exp), obs)
+    else:
+        obs = plot_stackplot_df.apply(
+            custom_func, name="new variable", args=args, **kwds
+        )
+        assert_iamframe_equal(exp, obs)
+
+
 def test_ops_unknown_axis(test_df_year):
     """Using an unknown axis raises an error"""
     with pytest.raises(ValueError, match="Unknown axis: foo"):
-        _op_data(test_df_year, "_", "_", "_", "foo")
+        _op_data(test_df_year, "_", "_", "foo")
 
 
 def test_ops_unknown_method(test_df_year):
     """Using an unknown method raises an error"""
     with pytest.raises(ValueError, match="Unknown method: foo"):
-        _op_data(test_df_year, "_", "_", "foo", "variable")
+        _op_data(test_df_year, "_", "foo", "variable")