Skip to content

Commit 1ac9541

Browse files
Improved handling for division by zero (#943)
1 parent 4b60586 commit 1ac9541

File tree

5 files changed

+81
-21
lines changed

5 files changed

+81
-21
lines changed

RELEASE_NOTES.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# Next Release
22

3+
- [#943](https://github.com/IAMconsortium/pyam/pull/943) Improved handling for division by zero
34
- [#941](https://github.com/IAMconsortium/pyam/pull/941) Add a `compute.share()` method
45
- [#938](https://github.com/IAMconsortium/pyam/pull/938) Add a function to initialize an IamDataFrame from an *
56
*ixmp4.Run**

pyam/compute.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
import wquantiles
66

77
from pyam._debiasing import _compute_bias
8-
from pyam._ops import _op_data
98
from pyam.index import replace_index_values
9+
from pyam.operations import apply_ops
1010
from pyam.timeseries import growth_rate
1111
from pyam.utils import remove_from_list
1212

@@ -62,7 +62,7 @@ def share(self, a, b, name, axis="variable", append=False):
6262
raise ValueError(f"Mismatching units: '{a_unit[0]}' != '{b_unit[0]}'")
6363

6464
# compute the share by dividing "a / b" and multiplying by 100
65-
_value = _op_data(
65+
_value = apply_ops(
6666
self._df,
6767
name,
6868
"divide",

pyam/core.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@
2222
HAS_DATAPACKAGE = False
2323

2424
from pyam._compare import _compare
25-
from pyam._ops import _op_data
2625
from pyam.aggregation import (
2726
_aggregate,
2827
_aggregate_recursive,
@@ -49,6 +48,7 @@
4948
verify_index_integrity,
5049
)
5150
from pyam.ixmp4 import write_to_ixmp4
51+
from pyam.operations import apply_ops
5252
from pyam.plotting import PlotAccessor
5353
from pyam.run_control import run_control
5454
from pyam.slice import IamSlice
@@ -2115,7 +2115,7 @@ def add(
21152115
For example, the unit :code:`EJ/yr` may be reformatted to :code:`EJ / a`.
21162116
"""
21172117
kwds = dict(axis=axis, fillna=fillna, ignore_units=ignore_units)
2118-
_value = _op_data(self, name, "add", **kwds, a=a, b=b)
2118+
_value = apply_ops(self, name, "add", **kwds, a=a, b=b)
21192119

21202120
# append to `self` or return as `IamDataFrame`
21212121
return self._finalize(_value, append=append)
@@ -2171,7 +2171,7 @@ def subtract(
21712171
For example, the unit :code:`EJ/yr` may be reformatted to :code:`EJ / a`.
21722172
"""
21732173
kwds = dict(axis=axis, fillna=fillna, ignore_units=ignore_units)
2174-
_value = _op_data(self, name, "subtract", **kwds, a=a, b=b)
2174+
_value = apply_ops(self, name, "subtract", **kwds, a=a, b=b)
21752175

21762176
# append to `self` or return as `IamDataFrame`
21772177
return self._finalize(_value, append=append)
@@ -2226,7 +2226,7 @@ def multiply(
22262226
For example, the unit :code:`EJ/yr` may be reformatted to :code:`EJ / a`.
22272227
"""
22282228
kwds = dict(axis=axis, fillna=fillna, ignore_units=ignore_units)
2229-
_value = _op_data(self, name, "multiply", **kwds, a=a, b=b)
2229+
_value = apply_ops(self, name, "multiply", **kwds, a=a, b=b)
22302230

22312231
# append to `self` or return as `IamDataFrame`
22322232
return self._finalize(_value, append=append)
@@ -2281,7 +2281,7 @@ def divide(
22812281
For example, the unit :code:`EJ/yr` may be reformatted to :code:`EJ / a`.
22822282
"""
22832283
kwds = dict(axis=axis, fillna=fillna, ignore_units=ignore_units)
2284-
_value = _op_data(self, name, "divide", **kwds, a=a, b=b)
2284+
_value = apply_ops(self, name, "divide", **kwds, a=a, b=b)
22852285

22862286
# append to `self` or return as `IamDataFrame`
22872287
return self._finalize(_value, append=append)
@@ -2338,7 +2338,7 @@ def apply(
23382338
"""
23392339

23402340
return self._finalize(
2341-
_op_data(self, name, func, axis=axis, fillna=fillna, args=args, **kwargs),
2341+
apply_ops(self, name, func, axis=axis, fillna=fillna, args=args, **kwargs),
23422342
append=append,
23432343
)
23442344

pyam/_ops.py renamed to pyam/operations.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
1+
import logging
12
import operator
23

34
import pandas as pd
45
from iam_units import registry
56
from pint import Quantity
67

8+
from pyam.exceptions import format_log_message
79
from pyam.index import append_index_level, get_index_levels, replace_index_values
810
from pyam.utils import to_list
911

12+
logger = logging.getLogger(__name__)
13+
1014

1115
# these functions have to be defined explicitly to allow calling them with keyword args
1216
def add(a, b):
@@ -22,6 +26,22 @@ def multiply(a, b):
2226

2327

2428
def divide(a, b):
29+
if isinstance(b, (int, float)) and b == 0:
30+
raise ZeroDivisionError
31+
if isinstance(b, Quantity) and b.magnitude == 0:
32+
raise ZeroDivisionError
33+
if isinstance(b, pd.Series):
34+
# remove any zeros from the denominator
35+
zeroes = b == 0
36+
if any(zeroes):
37+
logger.warning(
38+
format_log_message(
39+
f"Dropped {sum(zeroes)} datapoints to avoid division by zero",
40+
b[zeroes].index,
41+
)
42+
)
43+
b = b[~zeroes]
44+
2545
return operator.truediv(*_make_series(a, b))
2646

2747

@@ -46,7 +66,7 @@ def _make_series(a, b):
4666
}
4767

4868

49-
def _op_data(df, name, method, axis, fillna=None, args=(), ignore_units=False, **kwds): # noqa: C901
69+
def apply_ops(df, name, method, axis, fillna=None, args=(), ignore_units=False, **kwds): # noqa: C901
5070
"""Internal implementation of numerical operations on timeseries"""
5171

5272
if axis not in df.dimensions:

tests/test_ops.py renamed to tests/test_operations.py

Lines changed: 51 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from iam_units import registry
77

88
from pyam import IamDataFrame
9-
from pyam._ops import _op_data
9+
from pyam.operations import apply_ops
1010
from pyam.testing import assert_iamframe_equal
1111
from pyam.utils import IAMC_IDX
1212

@@ -119,6 +119,7 @@ def test_add_variable_ignore_units(test_df_year, arg, df_func, fillna, append):
119119

120120
@pytest.mark.parametrize("append", (False, True))
121121
def test_add_variable_non_si_unit(test_df_year, append):
122+
"""Check that in-dataframe addition works with non-SI-units"""
122123
df = test_df_year.rename(unit={"EJ/yr": "foo"})
123124

124125
exp = df_ops_variable(operator.add, "Sum", unit="foo", meta=test_df_year.meta)
@@ -135,7 +136,7 @@ def test_add_variable_non_si_unit(test_df_year, append):
135136

136137
@pytest.mark.parametrize("append", (False, True))
137138
def test_add_scenario(test_df_year, append):
138-
"""Verify that in-dataframe addition works on a custom axis (`scenario`)"""
139+
"""Check that in-dataframe addition works on a custom axis (`scenario`)"""
139140

140141
v = ("scen_a", "scen_b", "scen_sum")
141142
exp = IamDataFrame(
@@ -218,7 +219,8 @@ def test_subtract_variable_ignore_units(test_df_year, arg, df_func, fillna, appe
218219

219220

220221
@pytest.mark.parametrize("append", (False, True))
221-
def test_subtract_variable_non_si_unit_unit(test_df_year, append):
222+
def test_subtract_variable_non_si_unit(test_df_year, append):
223+
"""Check that in-dataframe addition works with non-SI units"""
222224
df = test_df_year.rename(unit={"EJ/yr": "foo"})
223225

224226
exp = df_ops_variable(operator.sub, "Diff", unit="foo", meta=test_df_year.meta)
@@ -235,7 +237,7 @@ def test_subtract_variable_non_si_unit_unit(test_df_year, append):
235237

236238
@pytest.mark.parametrize("append", (False, True))
237239
def test_subtract_scenario(test_df_year, append):
238-
"""Verify that in-dataframe subtraction works on a custom axis (`scenario`)"""
240+
"""Check that in-dataframe subtraction works on a custom axis (`scenario`)"""
239241

240242
v = ("scen_a", "scen_b", "scen_diff")
241243
exp = IamDataFrame(
@@ -265,7 +267,7 @@ def test_subtract_scenario(test_df_year, append):
265267
)
266268
@pytest.mark.parametrize("append", (False, True))
267269
def test_multiply_variable(test_df_year, arg, df_func, expected_unit, append):
268-
"""Check that in-dataframe addition works on the default `variable` axis"""
270+
"""Check that in-dataframe multiplication works on the default `variable` axis"""
269271

270272
exp = df_func(operator.mul, "Prod", unit=expected_unit, meta=test_df_year.meta)
271273

@@ -316,7 +318,7 @@ def test_multiply_variable_ignore_units(test_df_year, arg, df_func, fillna, appe
316318

317319
@pytest.mark.parametrize("append", (False, True))
318320
def test_multiply_scenario(test_df_year, append):
319-
"""Verify that in-dataframe addition works on a custom axis (`scenario`)"""
321+
"""Check that in-dataframe multiplication works on a custom axis (`scenario`)"""
320322

321323
v = ("scen_a", "scen_b", "scen_product")
322324
exp = IamDataFrame(
@@ -347,7 +349,7 @@ def test_multiply_scenario(test_df_year, append):
347349
)
348350
@pytest.mark.parametrize("append", (False, True))
349351
def test_divide_variable(test_df_year, arg, df_func, expected_unit, append):
350-
"""Check that in-dataframe addition works on the default `variable` axis"""
352+
"""Check that in-dataframe division works on the default `variable` axis"""
351353

352354
exp = df_func(operator.truediv, "Ratio", unit=expected_unit, meta=test_df_year.meta)
353355

@@ -361,6 +363,42 @@ def test_divide_variable(test_df_year, arg, df_func, expected_unit, append):
361363
assert_iamframe_equal(exp, obs)
362364

363365

366+
@pytest.mark.parametrize("value", (0, 0.0, registry.Quantity(0, "EJ/yr")))
367+
def test_divide_by_zero_raises(test_df_year, value):
368+
"""Check that division by zero (as single value) raises an error"""
369+
with pytest.raises(ZeroDivisionError):
370+
test_df_year.divide("Primary Energy", value, "Ratio")
371+
372+
373+
@pytest.mark.parametrize("append", (False, True))
374+
def test_divide_by_zero_drop_zero(test_df_year, append, caplog):
375+
"""Check that division by zero in a series removes zeroes and writes to log"""
376+
377+
exp = df_ops_variable(operator.truediv, "Ratio", unit="", meta=test_df_year.meta)
378+
exp.filter(year=2005, inplace=True)
379+
380+
test_df_year._data.loc[
381+
"model_a", "scen_a", "World", "Primary Energy|Coal", "EJ/yr", 2010
382+
] = 0
383+
384+
if append:
385+
obs = test_df_year.copy()
386+
obs.divide("Primary Energy", "Primary Energy|Coal", "Ratio", append=True)
387+
exp = test_df_year.append(exp)
388+
else:
389+
obs = test_df_year.divide("Primary Energy", "Primary Energy|Coal", "Ratio")
390+
391+
assert_iamframe_equal(exp, obs)
392+
393+
msg = (
394+
"Dropped 1 datapoints to avoid division by zero:\n"
395+
" model scenario region year\n"
396+
"0 model_a scen_a World 2010"
397+
)
398+
idx = caplog.messages.index(msg)
399+
assert caplog.records[idx].levelname == "WARNING"
400+
401+
364402
@pytest.mark.parametrize(
365403
"arg, df_func, fillna",
366404
(
@@ -370,7 +408,7 @@ def test_divide_variable(test_df_year, arg, df_func, expected_unit, append):
370408
)
371409
@pytest.mark.parametrize("append", (False, True))
372410
def test_divide_variable_ignore_units(test_df_year, arg, df_func, fillna, append):
373-
"""Check that in-dataframe addition works with ignore_units"""
411+
"""Check that in-dataframe division works with ignore_units"""
374412

375413
# change one unit to make ignore_units strictly necessary
376414
test_df_year.rename(
@@ -399,6 +437,7 @@ def test_divide_variable_ignore_units(test_df_year, arg, df_func, fillna, append
399437

400438
@pytest.mark.parametrize("append", (False, True))
401439
def test_divide_variable_non_si_unit_unit(test_df_year, append):
440+
"""Check that in-dataframe addition works with non-SI units"""
402441
df = test_df_year.rename(unit={"EJ/yr": "foo"})
403442

404443
exp = df_ops_variable(operator.truediv, "Ratio", unit="", meta=test_df_year.meta)
@@ -415,7 +454,7 @@ def test_divide_variable_non_si_unit_unit(test_df_year, append):
415454

416455
@pytest.mark.parametrize("append", (False, True))
417456
def test_divide_scenario(test_df_year, append):
418-
"""Verify that in-dataframe addition works on a custom axis (`scenario`)"""
457+
"""Check that in-dataframe division works on a custom axis (`scenario`)"""
419458

420459
v = ("scen_a", "scen_b", "scen_ratio")
421460
exp = IamDataFrame(
@@ -438,7 +477,7 @@ def test_divide_scenario(test_df_year, append):
438477

439478
@pytest.mark.parametrize("append", (False, True))
440479
def test_apply_variable(test_df_year, append):
441-
"""Verify that in-dataframe apply works on the default `variable` axis"""
480+
"""Check that in-dataframe `apply` works on the default `variable` axis"""
442481

443482
def custom_func(a, b, c, d):
444483
return a * b + c * d
@@ -471,13 +510,13 @@ def custom_func(a, b, c, d):
471510
def test_ops_unknown_axis(test_df_year):
472511
"""Using an unknown axis raises an error"""
473512
with pytest.raises(ValueError, match="Unknown axis: foo"):
474-
_op_data(test_df_year, "_", "_", "foo")
513+
apply_ops(test_df_year, "_", "_", "foo")
475514

476515

477516
def test_ops_unknown_method(test_df_year):
478517
"""Using an unknown method raises an error"""
479518
with pytest.raises(ValueError, match="Unknown method: foo"):
480-
_op_data(test_df_year, "_", "foo", "variable")
519+
apply_ops(test_df_year, "_", "foo", "variable")
481520

482521

483522
@pytest.mark.parametrize("periods, year", (({}, 2010), ({"periods": -1}, 2005)))

0 commit comments

Comments
 (0)