Repository: IAMconsortium/pyam
Pull request #527 (2021-05-07 08:15:56)

Add an in-dataframe subtraction operation
# 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 implements a first version of an in-IamDataFrame subtraction feature, e.g., to compute the difference between total primary energy and the components (and optionally add to the IamDataFrame).

```python
df.subtract("Primary Energy", "Primary Energy|Coal", "Primary Energy|Other", append=True)
```

This is just the most basic approach - it assumes that `a - b` is not computed for a specific row if either `a` or `b` is not defined.

# Future improvements and extensions

 - Allow setting a default value if `a` or `b` is not defined
 - Enable setting `b` as a numerical value (e.g., `Primary Energy * -1`)
 - Add support for across-IamDataFrame operations, e.g.
    ```python
    pyam.subtract(a, b, ...)
    ```
    where `a` and `b` are IamDataFrame-instances.
 - Add similar operations for addition, multiplication, shares... 

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

> :exclamation: Current head 588c64c differs from pull request most recent head 936c82f. Consider uploading reports for the commit 936c82f to get more accurate results
[![Impacted file tree graph](https://codecov.io/gh/IAMconsortium/pyam/pull/527/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/527?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=IAMconsortium)

```diff
@@           Coverage Diff           @@
##            main    #527     +/-   ##
=======================================
+ Coverage   93.1%   93.3%   +0.1%     
=======================================
  Files         43      45      +2     
  Lines       4945    5000     +55     
=======================================
+ Hits        4607    4665     +58     
+ Misses       338     335      -3     
```


| [Impacted Files](https://codecov.io/gh/IAMconsortium/pyam/pull/527?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=IAMconsortium) | Coverage Δ | |
|---|---|---|
| [pyam/index.py](https://codecov.io/gh/IAMconsortium/pyam/pull/527/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=IAMconsortium#diff-cHlhbS9pbmRleC5weQ==) | `94.8% <ø> (+7.6%)` | :arrow_up: |
| [pyam/\_ops.py](https://codecov.io/gh/IAMconsortium/pyam/pull/527/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=IAMconsortium#diff-cHlhbS9fb3BzLnB5) | `100.0% <100.0%> (ø)` | |
| [pyam/core.py](https://codecov.io/gh/IAMconsortium/pyam/pull/527/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=IAMconsortium#diff-cHlhbS9jb3JlLnB5) | `92.4% <100.0%> (+<0.1%)` | :arrow_up: |
| [pyam/utils.py](https://codecov.io/gh/IAMconsortium/pyam/pull/527/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=IAMconsortium#diff-cHlhbS91dGlscy5weQ==) | `91.9% <100.0%> (ø)` | |
| [tests/test\_ops.py](https://codecov.io/gh/IAMconsortium/pyam/pull/527/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=IAMconsortium#diff-dGVzdHMvdGVzdF9vcHMucHk=) | `100.0% <100.0%> (ø)` | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/IAMconsortium/pyam/pull/527?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/527?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=IAMconsortium). Last update [cf350e7...936c82f](https://codecov.io/gh/IAMconsortium/pyam/pull/527?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).

danielhuppmann: @pjuergens @gaurav-ganti, any comments?
Rlamboll: In this thread #355 we discuss importing a feature from Silicone that allows construction of any aggregate value using positive or negative multipliers, which would immediately allow this and also other operations when constructing more complex values (e.g. X-Y-Z). This looks like what you're proposing in your extension work. 
pjuergens: Thanks for this approach!

I have some proposals to make the _op_data-function more flexible:

- for the keyword method allow to pass a self-defined function as well, i.e.
```python
if method in KNOWN_OPS:
    method = KNOWN_OPS[method]
elif callable(method):
    pass
else:
    raise ValueError(f"Unknown method: {method}")
```
- instead of passing the variables a and b as single parameters, use a list [a, b, ...] to be able to pass more than 2 variables. _a, _b would then need to be put in a list as well and passed to method as *args
- the _op_data-function could then be used in a calculate(self, variables, name, method, axis="variable", append=False)-function, where I can define my own method

byersiiasa: lots of complicated things going on  - this iwll be very useful. I guess you are already thinking other opeartors too.

a while a go I had been converting to `.as_pandas()` and then using the operator package. Works well

```    import operator
    ops = { "+": operator.add, 
           "-": operator.sub,
           "/": operator.truediv,
           "*": operator.mul}```
danielhuppmann: Thanks for the fast feedback!

- @gidden, suggestion implemented, thanks!
 - @Rlamboll, thanks for bringing that discussion back to the radar, I added operations with numbers to the list of future ideas (maybe I'll move that section to an own issue to list all future developments)
 - @pjuergens, I was hoping you'd have these kind of ideas! I suggest that I'll let this PR sit over the weekend (in case of more comments or for a more thorough review), then merge, and then you can implement these improvements.

One suggestion: instead of `IamDataFrame.calculate()`, why not mimic pandas and do an `IamDataFrame.apply()` function? (see [here](https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.apply.html))
gidden: @danielhuppmann and @pjuergens would you welcome additional ops PR'd into this branch if people have time to include tests for them?
danielhuppmann: > @danielhuppmann and @pjuergens would you welcome additional ops PR'd into this branch if people have time to include tests for them?

I'd prefer to have a large number of small PRs rather than one ops-branch that diverges from the main branch over weeks. So if there are small additions or change that fit in the scope of a first "proof-of-concept" PR, great!

But let's merge this PR before tackling big chunks of additional features.
gidden: added other minimal ops if they seem fit for inclusion in https://github.com/danielhuppmann/pyam/pull/29

and in any case, don't we want to add major new features in a single squashed commit anyway (i.e., developed in a feature branch before being merged into `main`?)
danielhuppmann: Thanks @gidden for adding the other basic operations as a PR! I added a second test for each, refactored to use `operator` as suggested by @byersiiasa) and let `_op_data()` take any number of args (as suggested by @pjuergens) to prepare for custom methods.

I think the PR is good to go - this was fun! Have a nice weekend!
gidden: Looks great! Will let the CI run and then merge. Thanks @danielhuppmann !

Patch:
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index 2ae5dcb..166ff07 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -1,5 +1,6 @@
 # Next Release
 
+- [#527](https://github.com/IAMconsortium/pyam/pull/527) Add an in-dataframe basic mathematical operations `subtract`, `add`, `multiply`, `divide`
 - [#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/_ops.py b/pyam/_ops.py
new file mode 100644
index 0000000..ba05c65
--- /dev/null
+++ b/pyam/_ops.py
@@ -0,0 +1,30 @@
+import operator
+from pyam.index import append_index_level
+
+
+KNOWN_OPS = {
+    "add": operator.add,
+    "subtract": operator.sub,
+    "multiply": operator.mul,
+    "divide": operator.truediv,
+}
+
+
+def _op_data(df, method_args, name, method, axis):
+    """Internal implementation of numerical operations on timeseries"""
+
+    if axis not in df._data.index.names:
+        raise ValueError(f"Unknown axis: {axis}")
+
+    if method in KNOWN_OPS:
+        method = KNOWN_OPS[method]
+    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]
+
+    _value = method(*_args)
+    _value.index = append_index_level(_value.index, codes=0, level=name, name=axis)
+
+    return _value
diff --git a/pyam/core.py b/pyam/core.py
index 750e912..655da42 100755
--- a/pyam/core.py
+++ b/pyam/core.py
@@ -63,6 +63,7 @@ from pyam._aggregate import (
     _aggregate_recursive,
     _group_and_agg,
 )
+from pyam._ops import _op_data
 from pyam.units import convert_unit
 from pyam.index import (
     get_index_levels,
@@ -1182,7 +1183,7 @@ class IamDataFrame(object):
         Returns
         -------
         :class:`IamDataFrame` or **None**
-            Aggregated timeseries data or None if `inplace=True`.
+            Aggregated timeseries data or None if `append=True`.
 
         Notes
         -----
@@ -1729,6 +1730,126 @@ class IamDataFrame(object):
         else:
             self.meta[col] = self.meta[col].apply(func, *args, **kwargs)
 
+    def add(self, a, b, name, axis="variable", append=False):
+        """Compute the addition of timeseries data between `a` and `b` along an `axis`
+
+        This function computes `a + b`. If `a` or `b` are lists, the method applies
+        :meth:`pandas.groupby().sum() <pandas.core.groupby.GroupBy.sum>` on each group.
+        If either `a` or `b` are not defined for a row, no value is computed
+        for that row.
+
+        Parameters
+        ----------
+        a, b : str or list of str
+            Items to be used for the addition.
+        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.
+
+        Returns
+        -------
+        :class:`IamDataFrame` or **None**
+            Computed timeseries data or None if `append=True`.
+        """
+        _value = _op_data(self, (a, b), name, "add", axis=axis)
+        if append:
+            self.append(_value, inplace=True)
+        else:
+            return IamDataFrame(_value, meta=self.meta)
+
+    def subtract(self, a, b, name, axis="variable", append=False):
+        """Compute the difference of timeseries data between `a` and `b` along an `axis`
+
+        This function computes `a - b`. If `a` or `b` are lists, the method applies
+        :meth:`pandas.groupby().sum() <pandas.core.groupby.GroupBy.sum>` on each group.
+        If either `a` or `b` are not defined for a row, no value is computed
+        for that row.
+
+        Parameters
+        ----------
+        a, b : str or list of str
+            Items to be used for the subtraction.
+        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.
+
+        Returns
+        -------
+        :class:`IamDataFrame` or **None**
+            Computed timeseries data or None if `append=True`.
+        """
+        _value = _op_data(self, (a, b), name, "subtract", axis=axis)
+        if append:
+            self.append(_value, inplace=True)
+        else:
+            return IamDataFrame(_value, meta=self.meta)
+
+    def multiply(self, a, b, name, axis="variable", append=False):
+        """Compute the division of timeseries data between `a` and `b` along an `axis`
+
+        This function computes `a * b`. If `a` or `b` are lists, the method applies
+        :meth:`pandas.groupby().sum() <pandas.core.groupby.GroupBy.sum>` on each group.
+        If either `a` or `b` are not defined for a row, no value is computed
+        for that row.
+
+        Parameters
+        ----------
+        a, b : str or list of str
+            Items to be used for the division.
+        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.
+
+        Returns
+        -------
+        :class:`IamDataFrame` or **None**
+            Computed timeseries data or None if `append=True`.
+        """
+        _value = _op_data(self, (a, b), name, "multiply", axis=axis)
+        if append:
+            self.append(_value, inplace=True)
+        else:
+            return IamDataFrame(_value, meta=self.meta)
+
+    def divide(self, a, b, name, axis="variable", append=False):
+        """Compute the division of timeseries data between `a` and `b` along an `axis`
+
+        This function computes `a / b`. If `a` or `b` are lists, the method applies
+        :meth:`pandas.groupby().sum() <pandas.core.groupby.GroupBy.sum>` on each group.
+        If either `a` or `b` are not defined for a row, no value is computed
+        for that row.
+
+        Parameters
+        ----------
+        a, b : str or list of str
+            Items to be used for the division.
+        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.
+
+        Returns
+        -------
+        :class:`IamDataFrame` or **None**
+            Computed timeseries data or None if `append=True`.
+        """
+        _value = _op_data(self, (a, b), name, "divide", axis=axis)
+        if append:
+            self.append(_value, inplace=True)
+        else:
+            return IamDataFrame(_value, meta=self.meta)
+
     def _to_file_format(self, iamc_index):
         """Return a dataframe suitable for writing to a file"""
         df = self.timeseries(iamc_index=iamc_index).reset_index()
diff --git a/pyam/index.py b/pyam/index.py
index 87b482f..6166fbb 100644
--- a/pyam/index.py
+++ b/pyam/index.py
@@ -63,8 +63,8 @@ def replace_index_values(df, level, mapping):
 def append_index_level(index, codes, level, name, order=False):
     """Append a level to a pd.MultiIndex"""
     new_index = pd.MultiIndex(
-        codes=index.codes + [codes],
-        levels=index.levels + [level],
+        codes=index.codes + [[codes] * len(index.codes[0])],
+        levels=index.levels + [[level]],
         names=index.names + [name],
     )
     if order:
diff --git a/pyam/utils.py b/pyam/utils.py
index 0b3f5ba..2ba3227 100644
--- a/pyam/utils.py
+++ b/pyam/utils.py
@@ -398,7 +398,8 @@ def merge_meta(left, right, ignore_conflict=False):
     if not diff.empty:
         left = left.append(right.loc[diff, :], sort=False)
 
-    return left
+    # remove any columns that are all-nan
+    return left.dropna(axis=1, how="all")
 
 
 def find_depth(data, s="", level=None):


Test patch:
diff --git a/tests/test_index.py b/tests/test_index.py
index 129449d..2984854 100644
--- a/tests/test_index.py
+++ b/tests/test_index.py
@@ -1,7 +1,8 @@
 import pytest
+import pandas as pd
 import pandas.testing as pdt
 
-from pyam.index import get_index_levels, replace_index_values
+from pyam.index import get_index_levels, replace_index_values, append_index_level
 from pyam import IAMC_IDX
 
 
@@ -41,3 +42,22 @@ def test_replace_index_level_raises(test_df_index):
     """Assert that replace_index_value raises with non-existing level"""
     with pytest.raises(KeyError):
         replace_index_values(test_df_index, "foo", {"scen_a": "scen_c"})
+
+
+def test_append_index():
+    """Assert that appending and re-ordering to an index works as expected"""
+
+    index = pd.MultiIndex(
+        codes=[[0, 1]],
+        levels=[["scen_a", "scen_b"]],
+        names=["scenario"],
+    )
+
+    obs = append_index_level(index, 0, "World", "region", order=["region", "scenario"])
+
+    exp = pd.MultiIndex(
+        codes=[[0, 0], [0, 1]],
+        levels=[["World"], ["scen_a", "scen_b"]],
+        names=["region", "scenario"],
+    )
+    pdt.assert_index_equal(obs, exp)
diff --git a/tests/test_ops.py b/tests/test_ops.py
new file mode 100644
index 0000000..0869e58
--- /dev/null
+++ b/tests/test_ops.py
@@ -0,0 +1,203 @@
+import pandas as pd
+import pytest
+
+from pyam import IamDataFrame
+from pyam.testing import assert_iamframe_equal
+from pyam._ops import _op_data
+
+
+@pytest.mark.parametrize("append", (False, True))
+def test_add_variable(test_df_year, 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,
+        model="model_a",
+        scenario="scen_a",
+        region="World",
+        variable=v[2],
+        unit="EJ/yr",
+        meta=test_df_year.meta,
+    )
+
+    if append:
+        obs = test_df_year.copy()
+        obs.add(*v, append=True)
+        assert_iamframe_equal(test_df_year.append(exp), obs)
+    else:
+        assert_iamframe_equal(exp, test_df_year.add(*v))
+
+
+@pytest.mark.parametrize("append", (False, True))
+def test_add_scenario(test_df_year, append):
+    """Verify that in-dataframe addition works on a custom axis (`scenario`)"""
+
+    v = ("scen_a", "scen_b", "scen_sum")
+    exp = IamDataFrame(
+        pd.DataFrame([1 + 2, 6 + 7], index=[2005, 2010]).T,
+        model="model_a",
+        scenario=v[2],
+        region="World",
+        variable="Primary Energy",
+        unit="EJ/yr",
+    )
+
+    if append:
+        obs = test_df_year.copy()
+        obs.add(*v, axis="scenario", append=True)
+        assert_iamframe_equal(test_df_year.append(exp), obs)
+    else:
+        obs = test_df_year.add(*v, axis="scenario")
+        assert_iamframe_equal(exp, obs)
+
+
+@pytest.mark.parametrize("append", (False, True))
+def test_subtract_variable(test_df_year, 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,
+        model="model_a",
+        scenario="scen_a",
+        region="World",
+        variable=v[2],
+        unit="EJ/yr",
+        meta=test_df_year.meta,
+    )
+
+    if append:
+        obs = test_df_year.copy()
+        obs.subtract(*v, append=True)
+        assert_iamframe_equal(test_df_year.append(exp), obs)
+    else:
+        obs = test_df_year.subtract(*v)
+        assert_iamframe_equal(exp, obs)
+
+
+@pytest.mark.parametrize("append", (False, True))
+def test_subtract_scenario(test_df_year, append):
+    """Verify that in-dataframe subtraction works on a custom axis (`scenario`)"""
+
+    v = ("scen_a", "scen_b", "scen_diff")
+    exp = IamDataFrame(
+        pd.DataFrame([1 - 2, 6 - 7], index=[2005, 2010]).T,
+        model="model_a",
+        scenario=v[2],
+        region="World",
+        variable="Primary Energy",
+        unit="EJ/yr",
+    )
+
+    if append:
+        obs = test_df_year.copy()
+        obs.subtract(*v, axis="scenario", append=True)
+        assert_iamframe_equal(test_df_year.append(exp), obs)
+    else:
+        obs = test_df_year.subtract(*v, axis="scenario")
+        assert_iamframe_equal(exp, obs)
+
+
+@pytest.mark.parametrize("append", (False, True))
+def test_multiply_variable(test_df_year, 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,
+        model="model_a",
+        scenario="scen_a",
+        region="World",
+        variable=v[2],
+        unit="EJ/yr",
+        meta=test_df_year.meta,
+    )
+
+    if append:
+        obs = test_df_year.copy()
+        obs.multiply(*v, append=True)
+        assert_iamframe_equal(test_df_year.append(exp), obs)
+    else:
+        assert_iamframe_equal(exp, test_df_year.multiply(*v))
+
+
+@pytest.mark.parametrize("append", (False, True))
+def test_multiply_scenario(test_df_year, append):
+    """Verify that in-dataframe addition works on a custom axis (`scenario`)"""
+
+    v = ("scen_a", "scen_b", "scen_product")
+    exp = IamDataFrame(
+        pd.DataFrame([1 * 2, 6 * 7], index=[2005, 2010]).T,
+        model="model_a",
+        scenario=v[2],
+        region="World",
+        variable="Primary Energy",
+        unit="EJ/yr",
+    )
+
+    if append:
+        obs = test_df_year.copy()
+        obs.multiply(*v, axis="scenario", append=True)
+        assert_iamframe_equal(test_df_year.append(exp), obs)
+    else:
+        obs = test_df_year.multiply(*v, axis="scenario")
+        assert_iamframe_equal(exp, obs)
+
+
+@pytest.mark.parametrize("append", (False, True))
+def test_divide_variable(test_df_year, 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,
+        model="model_a",
+        scenario="scen_a",
+        region="World",
+        variable=v[2],
+        unit="EJ/yr",
+        meta=test_df_year.meta,
+    )
+
+    if append:
+        obs = test_df_year.copy()
+        obs.divide(*v, append=True)
+        assert_iamframe_equal(test_df_year.append(exp), obs)
+    else:
+        assert_iamframe_equal(exp, test_df_year.divide(*v))
+
+
+@pytest.mark.parametrize("append", (False, True))
+def test_divide_scenario(test_df_year, append):
+    """Verify that in-dataframe addition works on a custom axis (`scenario`)"""
+
+    v = ("scen_a", "scen_b", "scen_ratio")
+    exp = IamDataFrame(
+        pd.DataFrame([1 / 2, 6 / 7], index=[2005, 2010]).T,
+        model="model_a",
+        scenario=v[2],
+        region="World",
+        variable="Primary Energy",
+        unit="EJ/yr",
+    )
+
+    if append:
+        obs = test_df_year.copy()
+        obs.divide(*v, axis="scenario", append=True)
+        assert_iamframe_equal(test_df_year.append(exp), obs)
+    else:
+        obs = test_df_year.divide(*v, axis="scenario")
+        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")
+
+
+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")