Repository: enthought/chaco

Issue:
Plotting data with nans causes numpy warnings
**Problem Description**

There is a (quite secondary) method in `DataRange1D` that will cause `numpy` to emit a warning for every scatter plot creation if `nan`s are present. I'm not sure if it's a design principle explicitly claimed in writing, but Chaco seems to be built to be tolerant to `nan`s in data, so it feels like `nan`s should not cause warnings.

**Reproduction Steps:**

Running the following:

```python
from numpy import sort
from numpy.random import random
from enable.api import Component, ComponentEditor
from traits.api import HasTraits, Instance
from traitsui.api import UItem, View
from chaco.api import ArrayPlotData, Plot

class Demo(HasTraits):
    plot = Instance(Component)
    traits_view = View(
        UItem("plot", editor=ComponentEditor(size=(650, 650))),
        resizable=True)

    def _plot_default(self):
        x, y = sort(random(50)), random(50)
        x[5] = float("nan")
        y[5] = float("nan")

        pd = ArrayPlotData()
        pd.set_data("index", x)
        pd.set_data("value", y)
        plot = Plot(pd)
        plot.plot(("index", "value"), type="scatter")
        return plot

Demo().configure_traits()
```

works well, but emits:
```
/home/ndemitri/.edm/envs/ds-msrl-dev/lib/python3.6/site-packages/chaco/data_range_1d.py:126: RuntimeWarning: invalid value encountered in greater_equal
  return (data.view(ndarray) >= self._low_value) & (
/home/ndemitri/.edm/envs/ds-msrl-dev/lib/python3.6/site-packages/chaco/data_range_1d.py:127: RuntimeWarning: invalid value encountered in less_equal
  data.view(ndarray) <= self._high_value
```

(Note: the same happens when running the [nans_plot](https://github.com/enthought/chaco/blob/main/chaco/examples/demo/basic/nans_plot.py) example verbatim)

**Expected behavior:**

No warning should be emitted.

---

Note: the warning is emitted here:

https://github.com/enthought/chaco/blob/8a8162f465ce7c4b6a17eef36a2017a38c1a4201/chaco/data_range_1d.py#L126-L127

and is the result of comparing (twice) a `nan`-containing array with a float. The warning makes sense in general `numpy` usage, but the thing it is warning us against seems to be fine for DataRange1D use case: `nan`s value get a `False` mask, i.e. they are not within the plotted range.


Discussion:

Resolving patch:
diff --git a/chaco/data_range_1d.py b/chaco/data_range_1d.py
--- a/chaco/data_range_1d.py
+++ b/chaco/data_range_1d.py
@@ -16,7 +16,7 @@
 # Major library imports
 from math import ceil, floor, log
 
-from numpy import compress, inf, isinf, isnan, ndarray
+from numpy import compress, errstate, inf, isinf, isnan, ndarray
 
 # Enthought library imports
 from traits.api import Bool, CFloat, Enum, Float, Property, Trait, Callable
@@ -123,9 +123,15 @@ def mask_data(self, data):
 
         Implements AbstractDataRange.
         """
-        return (data.view(ndarray) >= self._low_value) & (
-            data.view(ndarray) <= self._high_value
-        )
+        with errstate(invalid="ignore"):
+            # Running under context because the data array may contain NaNs.
+            # These are strictly invalid for comparison and Numpy would emit
+            # a warning. Since we are happy with the default behavior (NaNs
+            # become "False" in the mask), we silence the warning.
+            mask = (data.view(ndarray) >= self._low_value) & (
+                data.view(ndarray) <= self._high_value
+            )
+        return mask
 
     def bound_data(self, data):
         """Returns a tuple of indices for the start and end of the first run
diff --git a/chaco/data_range_2d.py b/chaco/data_range_2d.py
--- a/chaco/data_range_2d.py
+++ b/chaco/data_range_2d.py
@@ -12,16 +12,14 @@
 Defines the DataRange2D class.
 """
 
-from numpy import compress, inf, transpose
+from numpy import compress, errstate, transpose
 
 # Enthought library imports
 from traits.api import (
-    Any,
     Bool,
     CFloat,
     Instance,
     Property,
-    Trait,
     Tuple,
     observe,
 )
@@ -96,8 +94,13 @@ def mask_data(self, data):
         Implements AbstractDataRange.
         """
         x_points, y_points = transpose(data)
-        x_mask = (x_points >= self.low[0]) & (x_points <= self.high[0])
-        y_mask = (y_points >= self.low[1]) & (y_points <= self.high[1])
+        with errstate(invalid="ignore"):
+            # Running under context because the data array may contain NaNs.
+            # These are strictly invalid for comparison and Numpy would emit
+            # a warning. Since we are happy with the default behavior (NaNs
+            # become "False" in the mask), we silence the warning.
+            x_mask = (x_points >= self.low[0]) & (x_points <= self.high[0])
+            y_mask = (y_points >= self.low[1]) & (y_points <= self.high[1])
         return x_mask & y_mask
 
     def bound_data(self, data):


Test patch:
diff --git a/chaco/tests/test_datarange_1d.py b/chaco/tests/test_datarange_1d.py
--- a/chaco/tests/test_datarange_1d.py
+++ b/chaco/tests/test_datarange_1d.py
@@ -9,6 +9,7 @@
 # Thanks for using Enthought open source!
 
 import unittest
+import warnings
 
 from numpy import arange, array, zeros, inf
 from numpy.testing import assert_equal
@@ -17,6 +18,8 @@
 
 from chaco.api import DataRange1D, ArrayDataSource
 
+NAN = float("nan")
+
 
 class Foo(HasTraits):
     """
@@ -232,6 +235,15 @@ def test_clip_data(self):
         r = DataRange1D(low=2.0, high=2.5)
         assert_equal(len(r.clip_data(ary)), 0)
 
+        # Test the case with nans. Additionally require that no warnings are
+        # emitted.
+        with warnings.catch_warnings(record=True) as w:
+            warnings.simplefilter("always")
+            r = DataRange1D(low=2.0, high=10.0)
+            ary = array([1, 3, NAN, 9.8, 10.2, 12])
+            assert_equal(r.clip_data(ary), array([3.0, 9.8]))
+        self.assertEqual(len(w), 0)
+
     def test_mask_data(self):
         r = DataRange1D(low=2.0, high=10.0)
         ary = array([1, 3, 4, 9.8, 10.2, 12])
@@ -246,6 +258,30 @@ def test_mask_data(self):
         r = DataRange1D(low=2.0, high=2.5)
         assert_equal(r.mask_data(ary), zeros(len(ary)))
 
+    def test_mask_data_containing_nans(self):
+        # Given
+        r = DataRange1D(low=2.0, high=10.0)
+        with warnings.catch_warnings(record=True) as w:
+            warnings.simplefilter("always")
+
+            # When
+            has_nans = array([1, 3, 9.8, NAN, 12])
+            # Then
+            assert_equal(r.mask_data(has_nans), array([0, 1, 1, 0, 0], "b"))
+
+            # When
+            all_nans = array([NAN, NAN, NAN])
+            # Then
+            assert_equal(r.mask_data(all_nans), array([0, 0, 0], "b"))
+
+        # Then (treating nans should come with no warnings)
+        # NOTE: This assertion may pass because the warning has been correctly
+        # silenced by us (useful test), but it may also pass because the
+        # warning has been inactivated by the "only warn once" Python rule
+        # (test ineffective, false negative). Clearing the registry only for
+        # test purposes is not feasible: https://bugs.python.org/issue21724
+        self.assertEqual(len(w), 0)
+
     def test_bound_data(self):
         r = DataRange1D(low=2.9, high=6.1)
         ary = arange(10)
@@ -256,6 +292,14 @@ def test_bound_data(self):
         bounds = r.bound_data(ary)
         assert_equal(bounds, (7, 11))
 
+        # test data with nan (expected: nan breaks a run)
+        with warnings.catch_warnings(record=True) as w:
+            warnings.simplefilter("always")
+            ary = array([1, 2, 3, 4, NAN, 6, 7])
+            bounds = r.bound_data(ary)
+            assert_equal(bounds, (2, 3))
+        self.assertEqual(len(w), 0)
+
     def test_custom_bounds_func(self):
         def custom_func(low, high, margin, tight_bounds):
             assert_equal(low, 0.0)
diff --git a/chaco/tests/test_datarange_2d.py b/chaco/tests/test_datarange_2d.py
--- a/chaco/tests/test_datarange_2d.py
+++ b/chaco/tests/test_datarange_2d.py
@@ -9,12 +9,15 @@
 # Thanks for using Enthought open source!
 
 import unittest
+import warnings
 
 from numpy import alltrue, arange, array, ravel, transpose, zeros, inf, isinf
 from numpy.testing import assert_equal, assert_
 
 from chaco.api import DataRange2D, GridDataSource, PointDataSource
 
+NAN = float("nan")
+
 
 class DataRange2DTestCase(unittest.TestCase):
     def test_empty_range(self):
@@ -205,6 +208,41 @@ def test_mask_data(self):
         r = DataRange2D(low=[2.0, 5.0], high=[2.5, 9.0])
         assert_equal(r.mask_data(ary), zeros(len(ary)))
 
+    def test_mask_data_containing_nans(self):
+        # Given
+        r = DataRange2D(low=[2.0, 3.0], high=[12.0, 13.0])
+        ary_1 = array(
+            [
+                [NAN, 1.0],
+                [NAN, 4.0],
+                [NAN, NAN],
+                [25.1, NAN],
+                [5.0, 6.0],
+                [12.5, 6.0]
+            ]
+        )
+        expected_mask_1 = array([0, 0, 0, 0, 1, 0], "b")
+        ary_2 = array([[NAN, NAN], [NAN, NAN]])
+        expected_mask_2 = array([0, 0], "b")
+
+        with warnings.catch_warnings(record=True) as w:
+            warnings.simplefilter("always")
+
+            # When
+            mask_1 = r.mask_data(ary_1)
+            mask_2 = r.mask_data(ary_2)
+
+        # Then
+        assert_equal(mask_1, expected_mask_1)
+        assert_equal(mask_2, expected_mask_2)
+
+        # This assertion may pass because the warning has been correctly
+        # silenced by us (useful test), but it may also pass because the
+        # warning has been inactivated by the "only warn once" Python rule
+        # (test ineffective, false negative). Clearing the registry only for
+        # test purposes is not feasible: https://bugs.python.org/issue21724
+        self.assertEqual(len(w), 0)
+
 
 def assert_close_(desired, actual):
     diff_allowed = 1e-5