Bug summary
A third-party scale whose limit_range_for_scale is written for scalars, which is how Matplotlib's own LogScale writes it, makes every 3D artist on that axis silently vanish. Ordinary finite data, no warning, no exception.
ScaleBase.val_in_range calls limit_range_for_scale(arr, arr, ...) with an array. A scalar-oriented implementation raises ValueError: truth value of an array is ambiguous, and the fallback treats that as nothing is in range:
try:
vmin, vmax = self.limit_range_for_scale(arr, arr, minpos=1e-300)
except (TypeError, ValueError):
result = np.zeros(arr.shape, dtype=bool)
_scale_invalid_mask then negates it, so every point is marked invalid and replaced with NaN.
The scalar form is not an unusual thing to write. It is what LogScale.limit_range_for_scale does:
return (minpos if vmin <= 0 else vmin,
minpos if vmax <= 0 else vmax)
LogScale is unaffected only because it overrides val_in_range. A third-party scale written before 3.11 cannot have overridden a method that did not exist yet.
Code for reproduction
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
from matplotlib.scale import ScaleBase, register_scale
from matplotlib.transforms import IdentityTransform
from matplotlib.ticker import AutoLocator, ScalarFormatter
class ScalarOnlyScale(ScaleBase):
name = "scalaronly"
def get_transform(self):
return IdentityTransform()
def set_default_locators_and_formatters(self, axis):
axis.set_major_locator(AutoLocator())
axis.set_major_formatter(ScalarFormatter())
def limit_range_for_scale(self, vmin, vmax, minpos):
# The same shape as LogScale.limit_range_for_scale.
return (minpos if vmin <= 0 else vmin,
minpos if vmax <= 0 else vmax)
register_scale(ScalarOnlyScale)
xs = np.array([0., 1., 2., 3.])
ys = np.array([0., 1., 2., 3.])
zs = np.array([1., 2., 3., 4.]) # all finite, all positive
fig = plt.figure()
ax = fig.add_subplot(projection="3d")
line, = ax.plot(xs, ys, zs)
ax.scatter(xs, ys, zs)
ax.set_zscale("scalaronly")
fig.canvas.draw()
print(np.asarray(line.get_data())) # all nan
fig.savefig("blank.png")
Actual outcome
The line and the points are gone. Nothing is raised.
[[nan nan nan nan]
[nan nan nan nan]]
Measured on the same figure with and without set_zscale("scalaronly"):
|
finite points after projection |
PNG size |
| linear scale |
line 4/4, scatter 4/4 |
18315 bytes |
scalaronly |
line 0/4, scatter 0/4 |
7414 bytes |
Directly:
>>> s = ScalarOnlyScale(None)
>>> s.val_in_range(5.0)
True
>>> s.val_in_range(np.array([1., 2., 3.]))
array([False, False, False])
The scalar answer is right and the array answer is the opposite of right.
Expected outcome
The data is drawn. Every value is finite and inside the scale's domain, and the scalar path agrees.
2D plots on the same scale are unaffected, because _scale_invalid_mask is only used by mpl_toolkits.mplot3d.
Additional information
val_in_range arrived in 3.11 (#31306) and _scale_invalid_mask began calling it over whole arrays in #31737. Before that, a scale only needed limit_range_for_scale to work on scalars, which is what the base class had always asked for.
The failure mode is what makes this worth reporting rather than the incompatibility itself. Falling back to "no value is in range" turns an unsupported call signature into deleted data. The safer direction is to fall back to the scalar semantics the implementation does support:
except (TypeError, ValueError):
result = np.array(
[self.val_in_range(v) for v in np.atleast_1d(arr).ravel()]
).reshape(arr.shape)
which returns [True, True, True] for the case above. Assuming everything is valid when the domain cannot be determined would also be safer than the current default, since an unknown domain is not evidence of invalid data.
Two smaller things noticed alongside:
scale.pyi still declares def val_in_range(self, val: float) -> bool, while the implementation documents and returns an array for array input.
- Related but separate, so not filed here:
Line3D.set_data_3d accepts two or four coordinate sequences because zip('xyz', args) truncates silently, and the failure then surfaces at draw time as TypeError: _scale_invalid_mask() missing 1 required positional argument, which names an internal function.
I have not opened a PR for this one, since changing the fallback is a decision about Scale compatibility rather than a mechanical fix, and I would rather have your view on the direction first. Happy to write it either way.
Found while investigating #32127, which comes from the same change chain but is a different failure.
Operating system
Linux (Debian 13)
Matplotlib Version
3.11.1
Matplotlib Backend
Agg
Python version
3.14
Jupyter version
N/A
Installation
pip
Bug summary
A third-party scale whose
limit_range_for_scaleis written for scalars, which is how Matplotlib's ownLogScalewrites it, makes every 3D artist on that axis silently vanish. Ordinary finite data, no warning, no exception.ScaleBase.val_in_rangecallslimit_range_for_scale(arr, arr, ...)with an array. A scalar-oriented implementation raisesValueError: truth value of an array is ambiguous, and the fallback treats that as nothing is in range:_scale_invalid_maskthen negates it, so every point is marked invalid and replaced with NaN.The scalar form is not an unusual thing to write. It is what
LogScale.limit_range_for_scaledoes:LogScaleis unaffected only because it overridesval_in_range. A third-party scale written before 3.11 cannot have overridden a method that did not exist yet.Code for reproduction
Actual outcome
The line and the points are gone. Nothing is raised.
Measured on the same figure with and without
set_zscale("scalaronly"):scalaronlyDirectly:
The scalar answer is right and the array answer is the opposite of right.
Expected outcome
The data is drawn. Every value is finite and inside the scale's domain, and the scalar path agrees.
2D plots on the same scale are unaffected, because
_scale_invalid_maskis only used bympl_toolkits.mplot3d.Additional information
val_in_rangearrived in 3.11 (#31306) and_scale_invalid_maskbegan calling it over whole arrays in #31737. Before that, a scale only neededlimit_range_for_scaleto work on scalars, which is what the base class had always asked for.The failure mode is what makes this worth reporting rather than the incompatibility itself. Falling back to "no value is in range" turns an unsupported call signature into deleted data. The safer direction is to fall back to the scalar semantics the implementation does support:
which returns
[True, True, True]for the case above. Assuming everything is valid when the domain cannot be determined would also be safer than the current default, since an unknown domain is not evidence of invalid data.Two smaller things noticed alongside:
scale.pyistill declaresdef val_in_range(self, val: float) -> bool, while the implementation documents and returns an array for array input.Line3D.set_data_3daccepts two or four coordinate sequences becausezip('xyz', args)truncates silently, and the failure then surfaces at draw time asTypeError: _scale_invalid_mask() missing 1 required positional argument, which names an internal function.I have not opened a PR for this one, since changing the fallback is a decision about
Scalecompatibility rather than a mechanical fix, and I would rather have your view on the direction first. Happy to write it either way.Found while investigating #32127, which comes from the same change chain but is a different failure.
Operating system
Linux (Debian 13)
Matplotlib Version
3.11.1
Matplotlib Backend
Agg
Python version
3.14
Jupyter version
N/A
Installation
pip