Repository: matplotlib/matplotlib

Issue:
[Bug]: pcolormesh's default x/y range might break `set_scale('log')`
### Bug summary

While using `pcolormesh`, setting the x- or y-scale to logarithmic sometimes breaks. It turned out this happens when the default `x/ylim` contain negative values. A fix could be to ensure that limits are all positive before calling the `set_x/yscale('log')`. 

This likely happens with similar commands as well. 

### Code for reproduction

```Python
import numpy as np
import matplotlib.pyplot as plt

x = np.arange(4, dtype=float)
y = np.linspace(1e1, 1e5, 10) # all positive
z = np.arange(len(x)*len(y)).reshape(len(x), len(y))

fig, axs = plt.subplots(1,3, figsize=(9,3))

# works fine
axs[0].pcolormesh(x,y, z.T)

# doesn't work fine
axs[1].pcolormesh(x,y, z.T)
axs[1].set_yscale('log') # because the limits set automatically include negative values   

# works fine
axs[2].pcolormesh(x,y, z.T)
axs[2].set_ylim(1e-3, y.max())  # Fix: we can explicitly set limits to the positive range 
axs[2].set_yscale('log')
```

### Actual outcome

![Image](https://github.com/user-attachments/assets/2ead51f2-67c6-4f09-9e5e-5caa12708e74)

### Expected outcome

The rightmost plot is the expected outcome, whereas the middle panel is what will be output by default.

### Additional information

_No response_

### Operating system

Ubuntu 22.04.5 LTS

### Matplotlib Version

3.8.4

### Matplotlib Backend

module://matplotlib_inline.backend_inline

### Python version

3.12.3

### Jupyter version

4.2.5

### Installation

conda


Discussion:
You have specified the cell centres with this call to pcolormesh so Matplotlib has to infer the cell edges. In this case the first y cell edge is negative.  

If you want finer control you can specify the cell edges instead.  
At first, I'd have said this is just how the log scale operates on negative values. However, when using the same coordinates (including the mesh edges) in `plot` and `bar` instead of `pcolormesh`, the scale is reasonable and just clips the negative values. This issue seems to be specific to QuadMesh.

<details>
<summary>Code</summary>

```
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.patches import Rectangle

x = np.arange(4, dtype=float)
y = np.linspace(1e1, 1e5, 10) # all positive
z = np.arange(len(x)*len(y)).reshape(len(x), len(y))

fig, axs = plt.subplots(2, 2, figsize=(6, 6), layout='constrained')

# linear
mesh = axs[0, 0].pcolormesh(x,y, z.T)

axs[0, 1].plot([0]*10, y, 'o', label='center')
axs[0, 1].plot([0]*11, mesh.get_coordinates()[:, 0, 1], '_', label='edge')
axs[0, 1].legend()

# log
mesh = axs[1, 0].pcolormesh(x,y, z.T)
axs[1, 0].set_yscale('log')

y_edges = mesh.get_coordinates()[:, 0, 1]
axs[1, 1].plot([0]*10, y, 'o')
axs[1, 1].plot([0]*11, y_edges, '_')
for y0, y1, color in zip(y_edges[:-1], y_edges[1:], plt.cm.jet(np.linspace(0, 1, 10))):
    axs[1, 1].bar(0.2, y1-y0, bottom=y0, width=0.2, color=color)
axs[1, 1].set_yscale('log')
```

</details>

![Image](https://github.com/user-attachments/assets/6ae9f711-517e-433e-bb65-fc70c2b4c15b)
To fix this issue, I believe we need to ensure that the y-axis limits are strictly positive before setting the logarithmic scale. The problem occurs because ``pcolormesh`` sets the axis limits automatically, which may include negative values or zero, causing ``set_yscale('log')`` to fail.

We need to explicitly set the limits before applying the logarithmic scale to avoid any negative values.


```
import numpy as np
import matplotlib.pyplot as plt

x = np.arange(4, dtype=float)
y = np.linspace(1e1, 1e5, 10)  # all positive
z = np.arange(len(x) * len(y)).reshape(len(x), len(y))

fig, axs = plt.subplots(1, 3, figsize=(9, 3))

# works fine
axs[0].pcolormesh(x, y, z.T)

# potential fix
axs[1].pcolormesh(x, y, z.T)
axs[1].set_ylim(y.min(), y.max())  # Ensure limits are positive
axs[1].set_yscale('log')  

# works fine
axs[2].pcolormesh(x, y, z.T)
axs[2].set_ylim(1e-3, y.max())  # Ensure strictly positive range
axs[2].set_yscale('log')

plt.show()
```

This should work as we explicitly set ``ylim`` before calling ``set_yscale('log')axs[1].set_ylim(y.min(), y.max())`` so only valid values are considered, preventing Matplotlib from including negative values in the auto-set limits.
> To fix this issue, I believe we need to ensure that the y-axis limits are strictly positive before setting the logarithmic scale.

No, we can at least partially handle negative data, see the `axs[1, 1]` example above, or to make it short and explicit:

```
import matplotlib.pyplot as plt
y = [-5545,  5565, 16675, 27785, 38895, 50005, 61115, 72225, 83335, 94445]

fig, ax = plt.subplots()
ax.plot(y, 'o-')
ax.set_yscale('log')
```

![Image](https://github.com/user-attachments/assets/c83fd539-d569-471e-9b47-6a5bd68fdaca)

So, to fix this issue, we must find out what is different with `QuadMesh` and possible improve the handling there, or maybe decide that's not possible and issue a reasonable warning instead.
The following patch fixes the issue:
```patch
diff --git i/lib/matplotlib/axes/_axes.py w/lib/matplotlib/axes/_axes.py
index 43572b9f1d..3c3605b32d 100644
--- i/lib/matplotlib/axes/_axes.py
+++ w/lib/matplotlib/axes/_axes.py
@@ -6549,8 +6549,7 @@ class Axes(_AxesBase):
         maxx, maxy = np.max(coords, axis=0)
         collection.sticky_edges.x[:] = [minx, maxx]
         collection.sticky_edges.y[:] = [miny, maxy]
-        corners = (minx, miny), (maxx, maxy)
-        self.update_datalim(corners)
+        self.update_datalim(np.column_stack([X.compressed(), Y.compressed()]))
         self._request_autoscale_view()
         return collection
```
This PR registers all sub-patch corners to update_datalim so that in log-scale, we can know what is the lowest *positive* edge (minpos) and autoscale based on that.

From a quick check, imshow() and pcolor() also need a similar fix, run e.g.
```python
from pylab import *

z = np.arange(16).reshape(4, 4)
fig, axs = subplots(1, 3)
for ax in axs: ax.set(yscale="log")
axs[0].imshow(z, extent=(-.5, 3.5, -.5, 3.5), aspect="auto")
axs[1].pcolormesh(range(4), range(4), z)
axs[2].pcolor(range(4), range(4), z)

show()
```
@anntzer thanks, this is definitively an improvement. I've two questions/remarks:

- does we expect a measureable performance impact by passing all values to `update_datalim`? - After all this is a niche case, which can be worked around using explicit limits (or setting the datalimits explicitly `ax.update_datalim(mesh.get_coordinates().reshape(-1, 2))`). I'd be reluctant to sacrifice performance for meshes on linear scales.
- Should we also also set the sticky_edges to all borders (`collection.sticky_edges.x[:] = X.compressed()`)?


re: performance: I haven't measured but I doubt this has any significant cost compared to the actual rendering -- it's just iterating over the coordinates to get the extremal and minimal positive values.
re: sticky edges at border: I don't think so (although it's a bit a design question); if e.g. the zero-crossing quad goes from -0.5 to +0.5 then in log scale I'd rather still show part of that quad (from, say, +0.4 or whatever the autoscaling margin gives us) to +0.5 rather than completely drop out that quad and have a lower limit at +0.5?
re: performance: Fair point. Rendering is iterate + draw each rectangle whereas this adds iterate + compare a number. And draw rectangle >> compare a number. T.b.d. we can possibly even leave out `compressed()` - if we only iterate and compare a number down the road, compressing is likely more expensive than just comparing all the coordinates.

re: sticky edges. Agreed. When I first looked at the result, it looked a bit funny because the rectangles are all the same data size, so were increasing on the log scale twoards 0, but then there was a small (cut-off) rectangle at the bottom. But that's probably just fair as we have to arbitrarily cut at some point > 0 and it's worse to completely hide the rect for the first data point.



Patch:
diff --git a/lib/matplotlib/axes/_axes.py b/lib/matplotlib/axes/_axes.py
index c72e52ca41f1..b46cbce39c58 100644
--- a/lib/matplotlib/axes/_axes.py
+++ b/lib/matplotlib/axes/_axes.py
@@ -6231,30 +6231,8 @@ def pcolor(self, *args, shading=None, alpha=None, norm=None, cmap=None,
         collection._check_exclusionary_keywords(colorizer, vmin=vmin, vmax=vmax)
         collection._scale_norm(norm, vmin, vmax)
 
-        # Transform from native to data coordinates?
-        t = collection._transform
-        if (not isinstance(t, mtransforms.Transform) and
-                hasattr(t, '_as_mpl_transform')):
-            t = t._as_mpl_transform(self.axes)
-
-        if t and any(t.contains_branch_seperately(self.transData)):
-            trans_to_data = t - self.transData
-            pts = np.vstack([x, y]).T.astype(float)
-            transformed_pts = trans_to_data.transform(pts)
-            x = transformed_pts[..., 0]
-            y = transformed_pts[..., 1]
-
-        self.add_collection(collection, autolim=False)
-
-        minx = np.min(x)
-        maxx = np.max(x)
-        miny = np.min(y)
-        maxy = np.max(y)
-        collection.sticky_edges.x[:] = [minx, maxx]
-        collection.sticky_edges.y[:] = [miny, maxy]
-        corners = (minx, miny), (maxx, maxy)
-        self.update_datalim(corners)
-        self._request_autoscale_view()
+        coords = coords.reshape(-1, 2)  # flatten the grid structure; keep x, y
+        self._update_pcolor_lims(collection, coords)
         return collection
 
     @_preprocess_data()
@@ -6464,7 +6442,13 @@ def pcolormesh(self, *args, alpha=None, norm=None, cmap=None, vmin=None,
         collection._scale_norm(norm, vmin, vmax)
 
         coords = coords.reshape(-1, 2)  # flatten the grid structure; keep x, y
+        self._update_pcolor_lims(collection, coords)
+        return collection
 
+    def _update_pcolor_lims(self, collection, coords):
+        """
+        Common code for updating lims in pcolor() and pcolormesh() methods.
+        """
         # Transform from native to data coordinates?
         t = collection._transform
         if (not isinstance(t, mtransforms.Transform) and
@@ -6481,10 +6465,8 @@ def pcolormesh(self, *args, alpha=None, norm=None, cmap=None, vmin=None,
         maxx, maxy = np.max(coords, axis=0)
         collection.sticky_edges.x[:] = [minx, maxx]
         collection.sticky_edges.y[:] = [miny, maxy]
-        corners = (minx, miny), (maxx, maxy)
-        self.update_datalim(corners)
+        self.update_datalim(coords)
         self._request_autoscale_view()
-        return collection
 
     @_preprocess_data()
     @_docstring.interpd


Test patch:
diff --git a/lib/matplotlib/tests/test_axes.py b/lib/matplotlib/tests/test_axes.py
index d7002be675c6..6f6f34d43f78 100644
--- a/lib/matplotlib/tests/test_axes.py
+++ b/lib/matplotlib/tests/test_axes.py
@@ -1497,6 +1497,27 @@ def test_pcolormesh_nearest_noargs(fig_test, fig_ref):
     ax.pcolormesh(x, y, C, shading="nearest")
 
 
+@check_figures_equal(extensions=["png"])
+def test_pcolormesh_log_scale(fig_test, fig_ref):
+    """
+    Check that setting a log scale sets good default axis limits
+    when using pcolormesh.
+    """
+    x = np.linspace(0, 1, 11)
+    y = np.linspace(1, 2, 5)
+    X, Y = np.meshgrid(x, y)
+    C = X + Y
+
+    ax = fig_test.subplots()
+    ax.pcolormesh(X, Y, C)
+    ax.set_xscale('log')
+
+    ax = fig_ref.subplots()
+    ax.pcolormesh(X, Y, C)
+    ax.set_xlim(1e-2, 1e1)
+    ax.set_xscale('log')
+
+
 @image_comparison(['pcolormesh_datetime_axis.png'], style='mpl20')
 def test_pcolormesh_datetime_axis():
     # Remove this line when this test image is regenerated.
@@ -1550,6 +1571,27 @@ def test_pcolor_datetime_axis():
             label.set_rotation(30)
 
 
+@check_figures_equal(extensions=["png"])
+def test_pcolor_log_scale(fig_test, fig_ref):
+    """
+    Check that setting a log scale sets good default axis limits
+    when using pcolor.
+    """
+    x = np.linspace(0, 1, 11)
+    y = np.linspace(1, 2, 5)
+    X, Y = np.meshgrid(x, y)
+    C = X[:-1, :-1] + Y[:-1, :-1]
+
+    ax = fig_test.subplots()
+    ax.pcolor(X, Y, C)
+    ax.set_xscale('log')
+
+    ax = fig_ref.subplots()
+    ax.pcolor(X, Y, C)
+    ax.set_xlim(1e-1, 1e0)
+    ax.set_xscale('log')
+
+
 def test_pcolorargs():
     n = 12
     x = np.linspace(-1.5, 1.5, n)