Repository: IAMconsortium/pyam
Pull request #543 (2021-06-08 10:02:19)

Add a tutorial for working with algebraic operations
# 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 tutorial for algebraic operations, which is rendered [here](https://pyam-iamc.readthedocs.io/en/ops-tutorial/tutorials/algebraic_operations.html).

Also, while writing the tutorial, I noticed that the `ignore_units` feature didn't quite sufficiently ignore units, so I added a fix.



Patch:
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index 0966bb1..b69a011 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -1,5 +1,6 @@
 # Next Release
 
+- [#543](https://github.com/IAMconsortium/pyam/pull/543) Add a tutorial for working with algebraic operations
 - [#541](https://github.com/IAMconsortium/pyam/pull/541) Support units in binary operations
 - [#538](https://github.com/IAMconsortium/pyam/pull/538) Add option to set defaults in binary operations
 - [#537](https://github.com/IAMconsortium/pyam/pull/537) Enhance binary ops to support numerical arguments
diff --git a/doc/source/_static/custom.css b/doc/source/_static/custom.css
index be33f00..96cf8ea 100644
--- a/doc/source/_static/custom.css
+++ b/doc/source/_static/custom.css
@@ -1,1 +1,14 @@
-div.body h1 {font-size: 200%}
\ No newline at end of file
+div.body h1 {font-size: 200%}
+
+div.admonition p.admonition-title {
+    font-size: 17px;
+    font-weight: bold;
+}
+
+div.admonition code.xref:hover {
+    background-color: #AAA;
+}
+
+dl.py {
+    margin: 20px 0 0 0;
+}
diff --git a/doc/source/conf.py b/doc/source/conf.py
index 75fe88e..e6a0c3e 100644
--- a/doc/source/conf.py
+++ b/doc/source/conf.py
@@ -147,6 +147,11 @@ html_theme_options = {
     "github_button": True,
     "github_user": "iamconsortium",
     "github_repo": "pyam",
+    "code_bg": "#EEE",
+    "note_bg": "#EEE",
+    "seealso_bg": "#EEE",
+    "admonition_xref_bg": "#EEE",
+    "admonition_xref_border": "#444",
 }
 
 # Add any paths that contain custom themes here, relative to this directory.
diff --git a/doc/source/examples/README.txt b/doc/source/examples/README.txt
index 859ea1d..e1d9d9b 100644
--- a/doc/source/examples/README.txt
+++ b/doc/source/examples/README.txt
@@ -5,4 +5,4 @@ Plotting Gallery
 
 All examples in this gallery currently use the *IAMC template* for yearly data,
 but |pyam| also supports timeseries data with a sub-annual resolution.
-Please read the `Data Model <data.html>`_ section for more information.
+Please read the `Data Model <../data.html>`_ section for more information.
diff --git a/doc/source/tutorials.rst b/doc/source/tutorials.rst
index 624044a..3f8b7f1 100644
--- a/doc/source/tutorials.rst
+++ b/doc/source/tutorials.rst
@@ -23,6 +23,7 @@ The source code is available in the folder
    tutorials/GAMS_to_pyam.ipynb
    tutorials/unit_conversion.ipynb
    tutorials/aggregating_downscaling_consistency.ipynb
+   tutorials/algebraic_operations.ipynb
    tutorials/subannual_time_resolution.ipynb
    tutorials/ipcc_colors.ipynb
    tutorials/legends.ipynb
diff --git a/doc/source/tutorials/GAMS_to_pyam.ipynb b/doc/source/tutorials/GAMS_to_pyam.ipynb
index 976fb10..944b9d1 100644
--- a/doc/source/tutorials/GAMS_to_pyam.ipynb
+++ b/doc/source/tutorials/GAMS_to_pyam.ipynb
@@ -1003,7 +1003,7 @@
    "name": "python",
    "nbconvert_exporter": "python",
    "pygments_lexer": "ipython3",
-   "version": "3.8.0"
+   "version": "3.8.3"
   },
   "nbsphinx": {
    "execute": "never"
diff --git a/doc/source/tutorials/aggregating_downscaling_consistency.ipynb b/doc/source/tutorials/aggregating_downscaling_consistency.ipynb
index 983fa73..e560138 100644
--- a/doc/source/tutorials/aggregating_downscaling_consistency.ipynb
+++ b/doc/source/tutorials/aggregating_downscaling_consistency.ipynb
@@ -29,12 +29,23 @@
     "\n",
     "This notebook illustrates the following features:\n",
     "\n",
-    "0. Load timeseries data from a snapshot file and inspect the scenario\n",
+    "0. Import data from file and inspect the scenario\n",
     "1. Aggregate timeseries over sectors (i.e., sub-categories)\n",
     "2. Aggregate timeseries over regions including weighted average\n",
     "3. Downscale timeseries given at a region level to sub-regions using a proxy variable\n",
     "4. Downscale timeseries using an explicit weighting dataframe\n",
-    "5. Check the internal consistency of a scenario (ensemble)"
+    "5. Check the internal consistency of a scenario (ensemble)\n",
+    "\n",
+    "<div class=\"alert alert-info\">\n",
+    "\n",
+    "**See Also**\n",
+    "\n",
+    "The **pyam** package also supports algebraic operations (addition, subtraction, multiplication, division)\n",
+    "on the timeseries data along any axis or dimension.\n",
+    "See the [algebraic operations tutorial notebook](https://pyam-iamc.readthedocs.io/en/stable/tutorials/algebraic_operations.html)\n",
+    "for more information.\n",
+    "\n",
+    "</div>"
    ]
   },
   {
@@ -51,7 +62,7 @@
    "cell_type": "markdown",
    "metadata": {},
    "source": [
-    "## 0. Load timeseries data from snapshot file and inspect the scenario\n",
+    "## 0. Import data from file and inspect the scenario\n",
     "\n",
     "The stylized scenario used in this tutorial has data for two regions (`reg_a` & `reg_b`) as well as the `World` aggregate, and for categories of variables: primary energy demand, emissions, carbon price, and population."
    ]
@@ -462,7 +473,7 @@
     "The log also shows that the other two variables (coal and wind) cannot be assessed because they have no subcategories.\n",
     "\n",
     "<div class=\"alert alert-info\">\n",
-    "    \n",
+    "\n",
     "In practice, it would now be up to the user to determine\n",
     "the cause of the inconsistency (or confirm that this is expected for some reason).\n",
     "\n",
@@ -486,7 +497,7 @@
    "name": "python",
    "nbconvert_exporter": "python",
    "pygments_lexer": "ipython3",
-   "version": "3.7.6"
+   "version": "3.8.3"
   }
  },
  "nbformat": 4,
diff --git a/doc/source/tutorials/algebraic_operations.ipynb b/doc/source/tutorials/algebraic_operations.ipynb
new file mode 100644
index 0000000..a44078d
--- /dev/null
+++ b/doc/source/tutorials/algebraic_operations.ipynb
@@ -0,0 +1,356 @@
+{
+ "cells": [
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "# Algebraic operations on timeseries data\n",
+    "\n",
+    "The **pyam** package offers many tools to facilitate processing of scenario data.\n",
+    "In this notebook, we illustrate algebraic operations on the timeseries data of an **IamDataFrame**:\n",
+    "addition, subtraction, multiplication, and division.\n",
+    "\n",
+    "The algebraic operations are (by default) \"unit-aware\", meaning that **pyam** tries to handle units correctly.\n",
+    "This is implemented via the [iam-units](https://github.com/IAMconsortium/units) package,\n",
+    "an extension of [pint](https://pint.readthedocs.io) package.\n",
+    "\n",
+    "The **pint** package natively handles conversion of standard (SI) units and commonly used equivalents \n",
+    "(e.g., exajoule to terawatt-hours, *EJ -> TWh*), and it can parse combined units\n",
+    "(e.g., exajoule per year, *EJ/yr*).\n",
+    "To better support common use cases when working with energy systems analysis and integrated-assessment scenarios,\n",
+    "the default [pint.UnitRegistry](https://pint.readthedocs.io/en/stable/developers_reference.html#pint.UnitRegistry)\n",
+    "used by **pyam** uses the **iam-units** registry (see [IAMconsortium/units](https://github.com/IAMconsortium/units)),\n",
+    "which extends the pint-defaults with a wide range of conversion factors commonly used in that domain.\n",
+    "\n",
+    "## Overview\n",
+    "\n",
+    "0. Import data from file and inspect the scenario\n",
+    "1. A simple subtraction\n",
+    "2. Multiplying timeseries data with scalars\n",
+    "3. Calculating shares and dealing with units\n",
+    "4. Overriding unit handling\n",
+    "5. Working on other dimensions of timeseries data\n",
+    "\n",
+    "<div class=\"alert alert-info\">\n",
+    "\n",
+    "**See Also**\n",
+    "\n",
+    "The **pyam** package also supports aggregation and downscaling\n",
+    "along the sectoral and regional dimensions including consistency checks.\n",
+    "See the [aggregation/downscaling tutorial notebook](https://pyam-iamc.readthedocs.io/en/stable/tutorials/aggregating_downscaling_consistency.html)\n",
+    "for more information.\n",
+    "\n",
+    "</div>"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "import pandas as pd\n",
+    "import pyam"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## 0. Import data from file and inspect the scenario\n",
+    "\n",
+    "The stylized scenario used in this tutorial has data for two regions (`reg_a` & `reg_b`) as well as the `World` aggregate, and for categories of variables: primary energy demand, emissions, carbon price, and population."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "df = pyam.IamDataFrame(data='tutorial_data_aggregating_downscaling.csv')\n",
+    "df"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "df.variable"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## 1. A simple subtraction\n",
+    "\n",
+    "We first display the existing variables *Primary Energy* and *Primary Energy|Coal*."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "df.filter(variable=[\"Primary Energy\", \"Primary Energy|Coal\"]).timeseries()"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Now, we subtract fossil fuels (coal) from the total to see non-fossil energy use, and display the timeseries in wide format.\n",
+    "\n",
+    "All algebraic-operations functions follow the syntax:\n",
+    "\n",
+    "```\n",
+    "df.<method>(a, b, c) => a <op> b = c\n",
+    "```\n",
+    "\n",
+    "Note that in simple cases, **pyam** will try to keep the unit consistent during the operation."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "(\n",
+    "    df.subtract(\"Primary Energy\", \"Primary Energy|Coal\", \"Primary Energy|Non-Fossil\")\n",
+    "    .timeseries()\n",
+    ")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "We can also directly merge newly computed timeseries directly into the original **IamDataFrame** using the keyword argument ``append=True``.\n",
+    "\n",
+    "The new variable *Primary Energy|Non-Fossil* is then part of the variable list."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "(\n",
+    "    df.subtract(\"Primary Energy\", \"Primary Energy|Coal\", \"Primary Energy|Non-Fossil\",\n",
+    "                append=True)\n",
+    ")"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "df.variable"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## 2. Multiplying timeseries data with scalars\n",
+    "\n",
+    "The algebraic operations do not only work on items in the **IamDataFrame**, but you can also pass scalars.\n",
+    "\n",
+    "You will see that in more elaborate computations, **pyam** may change the notation of the units.\n",
+    "In the example below, *EJ/yr* is changed to *EJ / a*.\n",
+    "This is due to how the **pint** package works internally."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "df.multiply(\"Primary Energy\", 3, \"PE * 3\").timeseries()"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "You can also define a [pint.Quantity](https://pint.readthedocs.io/en/stable/developers_reference.html#pint.Quantity) from the **iam-units** registry\n",
+    "and use this in the calculation. Note that **pyam** will (try to) correctly reduce the fraction."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "from iam_units import registry\n",
+    "\n",
+    "q = registry.Quantity(3, \"t / EJ\")\n",
+    "df.multiply(\"Primary Energy\", q, \"custom variable\").timeseries()"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## 3. Calculating shares and dealing with units\n",
+    "\n",
+    "As a next step, we calculate the primary energy use per capita."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "(\n",
+    "    df.divide(\"Primary Energy\", \"Population\", \"Energy/Capita\")\n",
+    "    .timeseries()\n",
+    ")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "As illustrated above, the notation of the units may be changed during the computation.\n",
+    "\n",
+    "If you do not like the returned units, you can change that using the [rename()](https://pyam-iamc.readthedocs.io/en/stable/api/iamdataframe.html#pyam.IamDataFrame.rename) function."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "(\n",
+    "    df.divide(\"Primary Energy\", \"Population\", \"Energy/Capita\")\n",
+    "    .rename(unit={\"EJ / a / million\": \"EJ/yr/million\"})\n",
+    "    .timeseries()\n",
+    ")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Or you can use the [convert_unit()](https://pyam-iamc.readthedocs.io/en/stable/api/iamdataframe.html#pyam.IamDataFrame.convert_unit) function;\n",
+    "see the [unit conversion tutorial notebook](https://pyam-iamc.readthedocs.io/en/stable/tutorials/unit_conversion.html) for more information."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "(\n",
+    "    df.divide(\"Primary Energy\", \"Population\", \"Energy/Capita\")\n",
+    "    .convert_unit(\"EJ / a / million\", \"GWh/yr\")\n",
+    "    .timeseries()\n",
+    ")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## 4. Overriding unit handling\n",
+    "\n",
+    "Even though **pint** is quite powerful, it does not always work as expected.\n",
+    "For example, *Mt CO2* is (strictly speaking) not a unit, but a species indicator *CO2* combined with a unit.\n",
+    "\n",
+    "For illustration, computing the emissions per capita will raise a [pint.UndefinedUnitError](https://pint.readthedocs.io/en/stable/developers_reference.html#pint.errors.UndefinedUnitError).\n",
+    "\n",
+    "We can override this behavior by setting ``ignore_units=True``; in this case, the unit of the returned timeseries data will be set to *unknown*."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "(\n",
+    "    df.divide(\"Emissions|CO2\", \"Population\", \"Emissions/Capita\",\n",
+    "              ignore_units=True)\n",
+    "    .timeseries()\n",
+    ")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "You can also pass a string as the ``ignore_units`` keyword argument. Then, this string will be used as unit.\n",
+    "\n",
+    "Seeing that the unit of emissions is *Mt CO2* and Population is given in *million*, we know that the returned value should be given in *tons of CO2*."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "(\n",
+    "    df.divide(\"Emissions|CO2\", \"Population\", \"Emissions/Capita\",\n",
+    "              ignore_units=\"t CO2\")\n",
+    "    .timeseries()\n",
+    ")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## 5. Working on other dimensions of timeseries data\n",
+    "\n",
+    "By default, algebraic operations in **pyam** will work on the *variable* dimenion.\n",
+    "But you can pass an ``axis`` keyword argument to, for example, perform computations\n",
+    "between scenarios or regions.\n",
+    "\n",
+    "Try it!"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": []
+  }
+ ],
+ "metadata": {
+  "kernelspec": {
+   "display_name": "Python 3",
+   "language": "python",
+   "name": "python3"
+  },
+  "language_info": {
+   "codemirror_mode": {
+    "name": "ipython",
+    "version": 3
+   },
+   "file_extension": ".py",
+   "mimetype": "text/x-python",
+   "name": "python",
+   "nbconvert_exporter": "python",
+   "pygments_lexer": "ipython3",
+   "version": "3.8.3"
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 2
+}
diff --git a/doc/source/tutorials/data_table_formats.ipynb b/doc/source/tutorials/data_table_formats.ipynb
index 254acc2..b57e864 100644
--- a/doc/source/tutorials/data_table_formats.ipynb
+++ b/doc/source/tutorials/data_table_formats.ipynb
@@ -464,7 +464,7 @@
    "name": "python",
    "nbconvert_exporter": "python",
    "pygments_lexer": "ipython3",
-   "version": "3.7.4"
+   "version": "3.8.3"
   }
  },
  "nbformat": 4,
diff --git a/doc/source/tutorials/pyam_first_steps.ipynb b/doc/source/tutorials/pyam_first_steps.ipynb
index 6212d54..9fd0332 100644
--- a/doc/source/tutorials/pyam_first_steps.ipynb
+++ b/doc/source/tutorials/pyam_first_steps.ipynb
@@ -95,7 +95,7 @@
    "cell_type": "markdown",
    "metadata": {},
    "source": [
-    "## Load timeseries data from a snapshot file and inspect the scenario ensemble\n",
+    "## Import data from file and inspect the scenario\n",
     "\n",
     "We import the snapshot of the timeseries data from the file ``tutorial_data.csv``.\n",
     "\n",
@@ -931,7 +931,7 @@
    "name": "python",
    "nbconvert_exporter": "python",
    "pygments_lexer": "ipython3",
-   "version": "3.7.4"
+   "version": "3.8.3"
   }
  },
  "nbformat": 4,
diff --git a/doc/source/tutorials/subannual_time_resolution.ipynb b/doc/source/tutorials/subannual_time_resolution.ipynb
index 26704ac..3ea4729 100644
--- a/doc/source/tutorials/subannual_time_resolution.ipynb
+++ b/doc/source/tutorials/subannual_time_resolution.ipynb
@@ -20,7 +20,7 @@
     "\n",
     "This notebook illustrates the following features:\n",
     "\n",
-    "0. Load timeseries data from a snapshot file and inspect the scenario\n",
+    "0. Import data from file and inspect the scenario\n",
     "1. Aggregate timeseries data given at a sub-annual time resolution to a yearly value\n",
     "\n",
     "***"
@@ -40,7 +40,7 @@
    "cell_type": "markdown",
    "metadata": {},
    "source": [
-    "## 0. Load timeseries data from snapshot file and inspect the scenario\n",
+    "## 0. Import data from file and inspect the scenario\n",
     "\n",
     "The stylized scenario used in this tutorial has data for primary-energy timeseries for two subannual timeslices `summer` and `winter`."
    ]
@@ -133,7 +133,7 @@
    "name": "python",
    "nbconvert_exporter": "python",
    "pygments_lexer": "ipython3",
-   "version": "3.7.4"
+   "version": "3.8.3"
   }
  },
  "nbformat": 4,
diff --git a/doc/source/tutorials/tutorial_data_aggregating_downscaling.csv b/doc/source/tutorials/tutorial_data_aggregating_downscaling.csv
index df15bc7..6aa8007 100644
--- a/doc/source/tutorials/tutorial_data_aggregating_downscaling.csv
+++ b/doc/source/tutorials/tutorial_data_aggregating_downscaling.csv
@@ -1,26 +1,26 @@
 Model,Scenario,Region,Variable,Unit,2005,2010
-model_a,scen_a,World,Emissions|CO2,EJ/y,10.0,14.0
-model_a,scen_a,World,Emissions|CO2|AFOLU,EJ/y,3.0,4.0
-model_a,scen_a,World,Emissions|CO2|Bunkers,EJ/y,1.0,2.0
-model_a,scen_a,World,Emissions|CO2|Energy,EJ/y,6.0,8.0
-model_a,scen_a,World,Population,m,3.0,5.0
-model_a,scen_a,World,Price|Carbon,USD/tCO2,4.0,27.0
-model_a,scen_a,World,Primary Energy,EJ/y,12.0,15.0
-model_a,scen_a,World,Primary Energy|Coal,EJ/y,9.0,10.0
-model_a,scen_a,World,Primary Energy|Wind,EJ/y,3.0,5.0
-model_a,scen_a,reg_a,Emissions|CO2,EJ/y,6.0,8.0
-model_a,scen_a,reg_a,Emissions|CO2|AFOLU,EJ/y,2.0,3.0
-model_a,scen_a,reg_a,Emissions|CO2|Energy,EJ/y,4.0,5.0
-model_a,scen_a,reg_a,Population,m,2.0,3.0
-model_a,scen_a,reg_a,Price|Carbon,USD/tCO2,1.0,30.0
-model_a,scen_a,reg_a,Primary Energy,EJ/y,8.0,9.0
-model_a,scen_a,reg_a,Primary Energy|Coal,EJ/y,6.0,6.0
-model_a,scen_a,reg_a,Primary Energy|Wind,EJ/y,2.0,3.0
-model_a,scen_a,reg_b,Emissions|CO2,EJ/y,3.0,4.0
-model_a,scen_a,reg_b,Emissions|CO2|AFOLU,EJ/y,1.0,1.0
-model_a,scen_a,reg_b,Emissions|CO2|Energy,EJ/y,2.0,3.0
-model_a,scen_a,reg_b,Population,m,1.0,2.0
-model_a,scen_a,reg_b,Price|Carbon,USD/tCO2,10.0,21.0
-model_a,scen_a,reg_b,Primary Energy,EJ/y,4.0,6.0
-model_a,scen_a,reg_b,Primary Energy|Coal,EJ/y,3.0,4.0
-model_a,scen_a,reg_b,Primary Energy|Wind,EJ/y,1.0,2.0
+model_a,scen_a,World,Emissions|CO2,Mt CO2,10.0,14.0
+model_a,scen_a,World,Emissions|CO2|AFOLU,Mt CO2,3.0,4.0
+model_a,scen_a,World,Emissions|CO2|Bunkers,Mt CO2,1.0,2.0
+model_a,scen_a,World,Emissions|CO2|Energy,Mt CO2,6.0,8.0
+model_a,scen_a,World,Population,million,3.0,5.0
+model_a,scen_a,World,Price|Carbon,USD/t CO2,4.0,27.0
+model_a,scen_a,World,Primary Energy,EJ/yr,12.0,15.0
+model_a,scen_a,World,Primary Energy|Coal,EJ/yr,9.0,10.0
+model_a,scen_a,World,Primary Energy|Wind,EJ/yr,3.0,5.0
+model_a,scen_a,reg_a,Emissions|CO2,Mt CO2,6.0,8.0
+model_a,scen_a,reg_a,Emissions|CO2|AFOLU,Mt CO2,2.0,3.0
+model_a,scen_a,reg_a,Emissions|CO2|Energy,Mt CO2,4.0,5.0
+model_a,scen_a,reg_a,Population,million,1.5,2.5
+model_a,scen_a,reg_a,Price|Carbon,USD/t CO2,1.0,30.0
+model_a,scen_a,reg_a,Primary Energy,EJ/yr,8.0,9.0
+model_a,scen_a,reg_a,Primary Energy|Coal,EJ/yr,6.0,6.0
+model_a,scen_a,reg_a,Primary Energy|Wind,EJ/yr,2.0,3.0
+model_a,scen_a,reg_b,Emissions|CO2,Mt CO2,3.0,4.0
+model_a,scen_a,reg_b,Emissions|CO2|AFOLU,Mt CO2,1.0,1.0
+model_a,scen_a,reg_b,Emissions|CO2|Energy,Mt CO2,2.0,3.0
+model_a,scen_a,reg_b,Population,million,1.5,2.5
+model_a,scen_a,reg_b,Price|Carbon,USD/t CO2,10.0,21.0
+model_a,scen_a,reg_b,Primary Energy,EJ/yr,4.0,6.0
+model_a,scen_a,reg_b,Primary Energy|Coal,EJ/yr,3.0,4.0
+model_a,scen_a,reg_b,Primary Energy|Wind,EJ/yr,1.0,2.0
diff --git a/doc/source/tutorials/unit_conversion.ipynb b/doc/source/tutorials/unit_conversion.ipynb
index 423b113..f10401a 100644
--- a/doc/source/tutorials/unit_conversion.ipynb
+++ b/doc/source/tutorials/unit_conversion.ipynb
@@ -288,7 +288,7 @@
    "name": "python",
    "nbconvert_exporter": "python",
    "pygments_lexer": "ipython3",
-   "version": "3.7.4"
+   "version": "3.8.3"
   }
  },
  "nbformat": 4,
diff --git a/pyam/_ops.py b/pyam/_ops.py
index 4aff3b2..921b3a0 100644
--- a/pyam/_ops.py
+++ b/pyam/_ops.py
@@ -94,6 +94,14 @@ def _op_data(df, name, method, axis, fillna=None, args=(), ignore_units=False, *
             _args[i] = _to_quantity(_args[i]) if is_data else _args[i]
         for key, value in kwds.items():
             kwds[key] = _to_quantity(value) if _data_kwds[key] else value
+    # else remove units from pd.Series
+    else:
+        for i, is_data in enumerate(_data_args):
+            _args[i] = _args[i].reset_index("unit", drop=True) if is_data else _args[i]
+        for key, value in kwds.items():
+            kwds[key] = (
+                value.reset_index("unit", drop=True) if _data_kwds[key] else value
+            )
 
     # merge all args and kwds that are based on `df._data` to apply fillna
     if fillna:
@@ -128,7 +136,7 @@ def _op_data(df, name, method, axis, fillna=None, args=(), ignore_units=False, *
     # otherwise, set unit (as index) to "unknown" or the value given by "ignore_units"
     else:
         index = append_index_level(
-            result.reset_index("unit", drop=True).index,
+            result.index,
             codes=0,
             level="unknown" if ignore_units is True else ignore_units,
             name="unit",


Test patch:
diff --git a/tests/test_ops.py b/tests/test_ops.py
index 1c0be34..e7884d0 100644
--- a/tests/test_ops.py
+++ b/tests/test_ops.py
@@ -59,6 +59,14 @@ def test_add_raises(test_df_year):
 def test_add_variable(test_df_year, arg, df_func, fillna, ignore_units, append):
     """Verify that in-dataframe addition works on the default `variable` axis"""
 
+    # change one unit to make ignore-units strictly necessary
+    if ignore_units:
+        test_df_year.rename(
+            variable={"Primary Energy": "Primary Energy"},
+            unit={"EJ/yr": "custom_unit"},
+            inplace=True,
+        )
+
     unit = "EJ/yr" if ignore_units is False else ignore_units
     exp = df_func(operator.add, "Sum", unit=unit, meta=test_df_year.meta)
 
@@ -71,7 +79,7 @@ def test_add_variable(test_df_year, arg, df_func, fillna, ignore_units, append):
     else:
         # check that incompatible units raise the expected error
         if ignore_units:
-            with pytest.raises(pint.DimensionalityError):
+            with pytest.raises(pint.UndefinedUnitError):
                 test_df_year.add(*args, fillna=fillna, ignore_units=False)
 
         assert_iamframe_equal(exp, test_df_year.add(*args, **kwds))
@@ -114,6 +122,14 @@ def test_add_scenario(test_df_year, append):
 def test_subtract_variable(test_df_year, arg, df_func, fillna, append, ignore_units):
     """Verify that in-dataframe subtraction works on the default `variable` axis"""
 
+    # change one unit to make ignore-units strictly necessary
+    if ignore_units:
+        test_df_year.rename(
+            variable={"Primary Energy": "Primary Energy"},
+            unit={"EJ/yr": "custom_unit"},
+            inplace=True,
+        )
+
     unit = "EJ/yr" if ignore_units is False else ignore_units
     exp = df_func(operator.sub, "Diff", unit=unit, meta=test_df_year.meta)
 
@@ -126,7 +142,7 @@ def test_subtract_variable(test_df_year, arg, df_func, fillna, append, ignore_un
     else:
         # check that incompatible units raise the expected error
         if ignore_units:
-            with pytest.raises(pint.DimensionalityError):
+            with pytest.raises(pint.UndefinedUnitError):
                 test_df_year.add(*args, fillna=fillna, ignore_units=False)
 
         assert_iamframe_equal(exp, test_df_year.subtract(*args, **kwds))
@@ -170,6 +186,12 @@ def test_multiply_variable(test_df_year, arg, df_func, fillna, ignore_units, app
     """Verify that in-dataframe addition works on the default `variable` axis"""
 
     if ignore_units:
+        # change one unit to make ignore-units strictly necessary
+        test_df_year.rename(
+            variable={"Primary Energy": "Primary Energy"},
+            unit={"EJ/yr": "custom_unit"},
+            inplace=True,
+        )
         unit = ignore_units
     else:
         unit = "EJ / a" if isinstance(arg, int) else "EJ ** 2 / a ** 2"
@@ -184,7 +206,7 @@ def test_multiply_variable(test_df_year, arg, df_func, fillna, ignore_units, app
     else:
         # check that incompatible units raise the expected error
         if ignore_units:
-            with pytest.raises(pint.DimensionalityError):
+            with pytest.raises(pint.UndefinedUnitError):
                 test_df_year.add(*args, fillna=fillna, ignore_units=False)
 
         assert_iamframe_equal(exp, test_df_year.multiply(*args, **kwds))
@@ -229,6 +251,12 @@ def test_divide_variable(test_df_year, arg, df_func, fillna, append, ignore_unit
 
     # note that dividing with pint reformats the unit
     if ignore_units:
+        # change one unit to make ignore-units strictly necessary
+        test_df_year.rename(
+            variable={"Primary Energy": "Primary Energy"},
+            unit={"EJ/yr": "custom_unit"},
+            inplace=True,
+        )
         unit = ignore_units
     else:
         unit = "EJ / a" if isinstance(arg, int) else ""
@@ -243,7 +271,7 @@ def test_divide_variable(test_df_year, arg, df_func, fillna, append, ignore_unit
     else:
         # check that incompatible units raise the expected error
         if ignore_units:
-            with pytest.raises(pint.DimensionalityError):
+            with pytest.raises(pint.UndefinedUnitError):
                 test_df_year.add(*args, fillna=fillna, ignore_units=False)
 
         assert_iamframe_equal(exp, test_df_year.divide(*args, **kwds))