Repository: matplotlib/matplotlib
Pull request: Fix BoundaryNorm for multiple colors and one region 
Fixes #17579. I don't understand why the scaling is `scalefac = (self.Ncmap - 1) / (self._n_regions - 1)`, and the logic when the number of colours is > the number of regions is not well defined in the dosctring so I don't know what it should be. This prevents the case with 1 region and > 1 colours from crashing though, and gives a sensible result.

I also changed a variable name from `_N` to make the code more readable.

Problem statement:
`BoundaryNorm` yield a `ZeroDivisionError: division by zero` 
<!--To help us understand and resolve your issue, please fill out the form to the best of your ability.-->
<!--You can feel free to delete the sections that do not apply.-->

### Bug report

**Bug summary**

```
import matplotlib as mpl
import matplotlib.pyplot as plt
cmap = plt.cm.jet
bounds = [0, 1]
norm = mpl.colors.BoundaryNorm(bounds, cmap.N)
norm(0.5)
```

Gives:

```
---------------------------------------------------------------------------
ZeroDivisionError                         Traceback (most recent call last)
<ipython-input-160-8a8135eaeb3c> in <module>
      2 bounds = [0, 1]
      3 norm = mpl.colors.BoundaryNorm(bounds, cmap.N)
----> 4 norm(0.5)

/usr/local/lib/python3.6/dist-packages/matplotlib/colors.py in __call__(self, value, clip)
   1442             iret[xx >= b] = i
   1443         if self._interp:
-> 1444             scalefac = (self.Ncmap - 1) / (self.N - 2)
   1445             iret = (iret * scalefac).astype(np.int16)
   1446         iret[xx < self.vmin] = -1

ZeroDivisionError: division by zero
``` 

**Expected outcome**

not an error: just an integer.

**Matplotlib version**

```
mpl.__version__: 3.2.1
os.name: posix
mpl.get_backend(): module://ipykernel.pylab.backend_inline
sys.version: 3.6.9 (default, Apr 18 2020, 01:56:04) 
[GCC 8.4.0]
$ jupyter --version
jupyter core     : 4.6.3
jupyter-notebook : 6.0.3
qtconsole        : 4.7.1
ipython          : 7.13.0
ipykernel        : 5.1.1
jupyter client   : 6.0.0
jupyter lab      : 1.2.14
nbconvert        : 5.6.1
ipywidgets       : 7.5.1
nbformat         : 4.4.0
traitlets        : 4.3.3
```

Hints from issue discussion:
This code path was changed a bin on master branchhttps://github.com/matplotlib/matplotlib/pull/17534 but does not fix this issue.

We need to add logic to https://github.com/matplotlib/matplotlib/blob/af7beaa35cec464547c212699b2843117c411d22/lib/matplotlib/colors.py#L1488-L1490 to handle the case were we only have 1 bin.

Sorry for the milestone thrashing.

Commit message: DOC: add inline explanation of the re-scaling logic in BoundrayNorm

Patch to review:
diff --git a/lib/matplotlib/colors.py b/lib/matplotlib/colors.py
index b1e76e27175b..dd8718e31b7d 100644
--- a/lib/matplotlib/colors.py
+++ b/lib/matplotlib/colors.py
@@ -1429,9 +1429,9 @@ def __init__(self, boundaries, ncolors, clip=False, *, extend='neither'):
         Parameters
         ----------
         boundaries : array-like
-            Monotonically increasing sequence of boundaries
+            Monotonically increasing sequence of boundaries.
         ncolors : int
-            Number of colors in the colormap to be used
+            Number of colors in the colormap to be used.
         clip : bool, optional
             If clip is ``True``, out of range values are mapped to 0 if they
             are below ``boundaries[0]`` or mapped to ``ncolors - 1`` if they
@@ -1473,18 +1473,18 @@ def __init__(self, boundaries, ncolors, clip=False, *, extend='neither'):
         self.Ncmap = ncolors
         self.extend = extend
 
-        self._N = self.N - 1  # number of colors needed
+        self._n_regions = self.N - 1  # number of colors needed
         self._offset = 0
         if extend in ('min', 'both'):
-            self._N += 1
+            self._n_regions += 1
             self._offset = 1
         if extend in ('max', 'both'):
-            self._N += 1
-        if self._N > self.Ncmap:
-            raise ValueError(f"There are {self._N} color bins including "
-                             f"extensions, but ncolors = {ncolors}; "
-                             "ncolors must equal or exceed the number of "
-                             "bins")
+            self._n_regions += 1
+        if self._n_regions > self.Ncmap:
+            raise ValueError(f"There are {self._n_regions} color bins "
+                             "including extensions, but ncolors = "
+                             f"{ncolors}; ncolors must equal or exceed the "
+                             "number of bins")
 
     def __call__(self, value, clip=None):
         if clip is None:
@@ -1492,15 +1492,34 @@ def __call__(self, value, clip=None):
 
         xx, is_scalar = self.process_value(value)
         mask = np.ma.getmaskarray(xx)
+        # Fill masked values a value above the upper boundary
         xx = np.atleast_1d(xx.filled(self.vmax + 1))
         if clip:
             np.clip(xx, self.vmin, self.vmax, out=xx)
             max_col = self.Ncmap - 1
         else:
             max_col = self.Ncmap
+        # this gives us the bins in the lookup table in the range
+        # [0, _n_regions - 1]  (the offset is baked in in the init)
         iret = np.digitize(xx, self.boundaries) - 1 + self._offset
-        if self.Ncmap > self._N:
-            scalefac = (self.Ncmap - 1) / (self._N - 1)
+        # if we have more colors than regions, stretch the region index
+        # computed above to full range of the color bins.  This will
+        # make use skip some of the colors in the middle of the
+        # colormap but will use the full range
+        if self.Ncmap > self._n_regions:
+            # the maximum possible value in iret
+            divsor = self._n_regions - 1
+            if divsor == 0:
+                # special case the 1 region case.  What we should do here
+                # is a bit undefined (as _any_ color in the colormap is
+                # justifiable) we go with not adjusting the value (which will
+                # either be 0 or 1 depending on if we are extending down)
+                scalefac = 1
+            else:
+                # otherwise linearly remap the values from the region index
+                # to the color index spaces
+                scalefac = (self.Ncmap - 1) / divsor
+            # do the scaling and re-cast to integers
             iret = (iret * scalefac).astype(np.int16)
         iret[xx < self.vmin] = -1
         iret[xx >= self.vmax] = max_col
diff --git a/lib/matplotlib/tests/test_colors.py b/lib/matplotlib/tests/test_colors.py
index ba1192d16ac3..5ca954fed320 100644
--- a/lib/matplotlib/tests/test_colors.py
+++ b/lib/matplotlib/tests/test_colors.py
@@ -207,6 +207,11 @@ def test_BoundaryNorm():
     bn = mcolors.BoundaryNorm(boundaries, ncolors)
     assert_array_equal(bn(vals), expected)
 
+    # with a single region and interpolation
+    expected = [-1, 0, 0, 0, 3, 3]
+    bn = mcolors.BoundaryNorm([0, 2.2], ncolors)
+    assert_array_equal(bn(vals), expected)
+
     # more boundaries for a third color
     boundaries = [0, 1, 2, 3]
     vals = [-1, 0.1, 1.1, 2.2, 4]


Code review comments:
diff_hunk: @@ -1473,34 +1473,53 @@ def __init__(self, boundaries, ncolors, clip=False, *, extend='neither'):
         self.Ncmap = ncolors
         self.extend = extend
 
-        self._N = self.N - 1  # number of colors needed
+        self._n_regions = self.N - 1  # number of colors needed
         self._offset = 0
         if extend in ('min', 'both'):
-            self._N += 1
+            self._n_regions += 1
             self._offset = 1
         if extend in ('max', 'both'):
-            self._N += 1
-        if self._N > self.Ncmap:
-            raise ValueError(f"There are {self._N} color bins including "
-                             f"extensions, but ncolors = {ncolors}; "
-                             "ncolors must equal or exceed the number of "
-                             "bins")
+            self._n_regions += 1
+        if self._n_regions > self.Ncmap:
+            raise ValueError(f"There are {self._n_regions} color bins "
+                             "including extensions, but ncolors = "
+                             f"{ncolors}; ncolors must equal or exceed the "
+                             "number of bins")
 
     def __call__(self, value, clip=None):
         if clip is None:
             clip = self.clip
 
         xx, is_scalar = self.process_value(value)
         mask = np.ma.getmaskarray(xx)
+        # Fill masked values a value above the upper boundary
         xx = np.atleast_1d(xx.filled(self.vmax + 1))
         if clip:
             np.clip(xx, self.vmin, self.vmax, out=xx)
             max_col = self.Ncmap - 1
         else:
             max_col = self.Ncmap
+        # this gives us the bins in the lookup table in the range
+        # [0, _n_regions - 1]  (the offset is baked in in the init)
         iret = np.digitize(xx, self.boundaries) - 1 + self._offset
-        if self.Ncmap > self._N:
-            scalefac = (self.Ncmap - 1) / (self._N - 1)
+        # if we have more colors than regions, stretch the region index
+        # computed above to full range of the color bins.  This will
+        # make use skip some of the colors in the middle of the
+        # colormap but will use the full range
+        if self.Ncmap > self._n_regions:
+            # the maximum possible value in iret
+            divsor = self._n_regions - 1
+            if divsor == 0:
+                # special case the 1 region case.  What we should do here
+                # is a bit undefined (as _any_ color in the colormap is
+                # justifiable) we go with not adjusting the value (which will
+                # either be 0 or 1 depending on if we are extending down)
path: lib/matplotlib/colors.py
text: @user1:
I think this comment is also confusing.  The divisor will be 0 only if there are no extensions and 2 boundaries, or one extension and only 1 boundary.  So we have to pick one color out of the map.  BoundaryNorm is supposed to work like contourf, so we should do what contourf does, which is pick the middle of the colormap.  But we can't do that with scalefac, because iret is filled with zeros.  It has to be directly filled with (self.Ncmap -1) // 2.

diff_hunk: @@ -1473,34 +1473,53 @@ def __init__(self, boundaries, ncolors, clip=False, *, extend='neither'):
         self.Ncmap = ncolors
         self.extend = extend
 
-        self._N = self.N - 1  # number of colors needed
+        self._n_regions = self.N - 1  # number of colors needed
         self._offset = 0
         if extend in ('min', 'both'):
-            self._N += 1
+            self._n_regions += 1
             self._offset = 1
         if extend in ('max', 'both'):
-            self._N += 1
-        if self._N > self.Ncmap:
-            raise ValueError(f"There are {self._N} color bins including "
-                             f"extensions, but ncolors = {ncolors}; "
-                             "ncolors must equal or exceed the number of "
-                             "bins")
+            self._n_regions += 1
+        if self._n_regions > self.Ncmap:
+            raise ValueError(f"There are {self._n_regions} color bins "
+                             "including extensions, but ncolors = "
+                             f"{ncolors}; ncolors must equal or exceed the "
+                             "number of bins")
 
     def __call__(self, value, clip=None):
         if clip is None:
             clip = self.clip
 
         xx, is_scalar = self.process_value(value)
         mask = np.ma.getmaskarray(xx)
+        # Fill masked values a value above the upper boundary
         xx = np.atleast_1d(xx.filled(self.vmax + 1))
         if clip:
             np.clip(xx, self.vmin, self.vmax, out=xx)
             max_col = self.Ncmap - 1
         else:
             max_col = self.Ncmap
+        # this gives us the bins in the lookup table in the range
+        # [0, _n_regions - 1]  (the offset is baked in in the init)
         iret = np.digitize(xx, self.boundaries) - 1 + self._offset
-        if self.Ncmap > self._N:
-            scalefac = (self.Ncmap - 1) / (self._N - 1)
+        # if we have more colors than regions, stretch the region index
+        # computed above to full range of the color bins.  This will
+        # make use skip some of the colors in the middle of the
path: lib/matplotlib/colors.py
text: @user1:
Maybe change "This will...full range" to "The first region is mapped to the first color in the colormap, and the last region to the last color."  Or leave the sentence out entirely.