Skip to content

Commit 8a80e1b

Browse files
committed
Improve tick subsampling in LogLocator.
Rewrite the logic for subsampling log-scale ticks, so that 1) there's as many ticks as possible without going over numticks (don't draw a single tick when two would fit); 2) the ticks are spaced as much as possible (when two ticks are drawn, put them on the lowest and highest decade, not somewhere midway); 3) if possible, draw ticks on integer multiples of the stride (0, 3, 9 is better than 1, 4, 7). See comments in implementation for details. For the backcompat-oriented "classic-mode", remove the "stride >= numdec" check (which is a much later addition). Test changes: - test_small_range_loglocator was likely previously wrong (the assert did not cover the values of ticks, and all iterations of the loop ended up testing the same condition). Rewrite it fully (in particular to cover the new implementation). - changes because tick_values() now returns exactly one major ticks outside of the (vmin, vmax) range on both sides: TestLogLocator, test_axes::test_auto_numticks_log, test_colorbar::test_colorbar_renorm. - test_colorbar::test_colorbar_autotickslog's tested tick values didn't match what actually got drawn, because rendering the colorbar (here simulated via draw_without_rendering) would make the (first) colorbar slightly smaller (via _ColorbarAxesLocator) and change the available tick space. Furthermore, the second colorbar was wrongly drawn with 3 ticks at [-12, 0, +12] when there's only space for 2; this arose because np.log(1e±12)/np.log(10) = ±11.999... which led to a wrong stride calculation; the new code explicitly uses log10, which is more accurate, when possible, and correctly yields ticks at [-12, 12]. - test_contour::test_contourf_log_extension: Log-normed contour extension remains a bit iffy, because it is implemented by asking a LogLocator for ticks within data range, and then including an extra tick below and above (see also the comment regarding _autolev in LogLocator.tick_values()). This is not really optimal, e.g. that extra tick can be quite far below or above the actual data limits (depending on the stride). Perhaps a better solution would be to just extend the range to the nearest decade, or even just accept that colorbar ticks (like axes ticks) don't necessarily reach the very end of the data range but can stop slightly below. In the meantime, I chose to simply force the contour levels so that the test results matches the old output.
1 parent 0b7a88a commit 8a80e1b

File tree

6 files changed

+142
-59
lines changed

6 files changed

+142
-59
lines changed

lib/matplotlib/tests/test_axes.py

+3-2
Original file line numberDiff line numberDiff line change
@@ -7003,6 +7003,7 @@ def test_loglog_nonpos():
70037003
ax.set_xscale("log", nonpositive=mcx)
70047004
if mcy:
70057005
ax.set_yscale("log", nonpositive=mcy)
7006+
ax.set_yticks([1e3, 1e7]) # Backcompat tick selection.
70067007

70077008

70087009
@mpl.style.context('default')
@@ -7132,8 +7133,8 @@ def test_auto_numticks_log():
71327133
fig, ax = plt.subplots()
71337134
mpl.rcParams['axes.autolimit_mode'] = 'round_numbers'
71347135
ax.loglog([1e-20, 1e5], [1e-16, 10])
7135-
assert (np.log10(ax.get_xticks()) == np.arange(-26, 18, 4)).all()
7136-
assert (np.log10(ax.get_yticks()) == np.arange(-20, 10, 3)).all()
7136+
assert_array_equal(np.log10(ax.get_xticks()), np.arange(-26, 11, 4))
7137+
assert_array_equal(np.log10(ax.get_yticks()), np.arange(-20, 5, 3))
71377138

71387139

71397140
def test_broken_barh_empty():

lib/matplotlib/tests/test_colorbar.py

+7-6
Original file line numberDiff line numberDiff line change
@@ -491,12 +491,13 @@ def test_colorbar_autotickslog():
491491
pcm = ax[1].pcolormesh(X, Y, 10**Z, norm=LogNorm())
492492
cbar2 = fig.colorbar(pcm, ax=ax[1], extend='both',
493493
orientation='vertical', shrink=0.4)
494+
495+
fig.draw_without_rendering()
494496
# note only -12 to +12 are visible
495-
np.testing.assert_almost_equal(cbar.ax.yaxis.get_ticklocs(),
496-
10**np.arange(-16., 16.2, 4.))
497-
# note only -24 to +24 are visible
498-
np.testing.assert_almost_equal(cbar2.ax.yaxis.get_ticklocs(),
499-
10**np.arange(-24., 25., 12.))
497+
np.testing.assert_equal(np.log10(cbar.ax.yaxis.get_ticklocs()),
498+
[-18, -12, -6, 0, +6, +12, +18])
499+
np.testing.assert_equal(np.log10(cbar2.ax.yaxis.get_ticklocs()),
500+
[-36, -12, 12, +36])
500501

501502

502503
def test_colorbar_get_ticks():
@@ -597,7 +598,7 @@ def test_colorbar_renorm():
597598
norm = LogNorm(z.min(), z.max())
598599
im.set_norm(norm)
599600
np.testing.assert_allclose(cbar.ax.yaxis.get_majorticklocs(),
600-
np.logspace(-10, 7, 18))
601+
np.logspace(-9, 6, 16))
601602
# note that set_norm removes the FixedLocator...
602603
assert np.isclose(cbar.vmin, z.min())
603604
cbar.set_ticks([1, 2, 3])

lib/matplotlib/tests/test_contour.py

+4-1
Original file line numberDiff line numberDiff line change
@@ -399,8 +399,11 @@ def test_contourf_log_extension():
399399
levels = np.power(10., levels_exp)
400400

401401
# original data
402+
# FIXME: Force tick locations for now for backcompat with old test
403+
# (log-colorbar extension is not really optimal anyways).
402404
c1 = ax1.contourf(data,
403-
norm=LogNorm(vmin=data.min(), vmax=data.max()))
405+
norm=LogNorm(vmin=data.min(), vmax=data.max()),
406+
locator=mpl.ticker.FixedLocator(10.**np.arange(-8, 12, 2)))
404407
# just show data in levels
405408
c2 = ax2.contourf(data, levels=levels,
406409
norm=LogNorm(vmin=levels.min(), vmax=levels.max()),

lib/matplotlib/tests/test_scale.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,8 @@ def test_logscale_mask():
107107
fig, ax = plt.subplots()
108108
ax.plot(np.exp(-xs**2))
109109
fig.canvas.draw()
110-
ax.set(yscale="log")
110+
ax.set(yscale="log",
111+
yticks=10.**np.arange(-300, 0, 24)) # Backcompat tick selection.
111112

112113

113114
def test_extra_kwargs_raise():
@@ -162,6 +163,7 @@ def test_logscale_nonpos_values():
162163

163164
ax4.set_yscale('log')
164165
ax4.set_xscale('log')
166+
ax4.set_yticks([1e-2, 1, 1e+2]) # Backcompat tick selection.
165167

166168

167169
def test_invalid_log_lims():

lib/matplotlib/tests/test_ticker.py

+44-14
Original file line numberDiff line numberDiff line change
@@ -332,13 +332,11 @@ def test_basic(self):
332332
with pytest.raises(ValueError):
333333
loc.tick_values(0, 1000)
334334

335-
test_value = np.array([1.00000000e-05, 1.00000000e-03, 1.00000000e-01,
336-
1.00000000e+01, 1.00000000e+03, 1.00000000e+05,
337-
1.00000000e+07, 1.000000000e+09])
335+
test_value = np.array([1e-5, 1e-3, 1e-1, 1e+1, 1e+3, 1e+5, 1e+7])
338336
assert_almost_equal(loc.tick_values(0.001, 1.1e5), test_value)
339337

340338
loc = mticker.LogLocator(base=2)
341-
test_value = np.array([0.5, 1., 2., 4., 8., 16., 32., 64., 128., 256.])
339+
test_value = np.array([.5, 1., 2., 4., 8., 16., 32., 64., 128.])
342340
assert_almost_equal(loc.tick_values(1, 100), test_value)
343341

344342
def test_polar_axes(self):
@@ -377,7 +375,7 @@ def test_tick_values_correct(self):
377375
1.e+01, 2.e+01, 5.e+01, 1.e+02, 2.e+02, 5.e+02,
378376
1.e+03, 2.e+03, 5.e+03, 1.e+04, 2.e+04, 5.e+04,
379377
1.e+05, 2.e+05, 5.e+05, 1.e+06, 2.e+06, 5.e+06,
380-
1.e+07, 2.e+07, 5.e+07, 1.e+08, 2.e+08, 5.e+08])
378+
1.e+07, 2.e+07, 5.e+07])
381379
assert_almost_equal(ll.tick_values(1, 1e7), test_value)
382380

383381
def test_tick_values_not_empty(self):
@@ -387,8 +385,7 @@ def test_tick_values_not_empty(self):
387385
1.e+01, 2.e+01, 5.e+01, 1.e+02, 2.e+02, 5.e+02,
388386
1.e+03, 2.e+03, 5.e+03, 1.e+04, 2.e+04, 5.e+04,
389387
1.e+05, 2.e+05, 5.e+05, 1.e+06, 2.e+06, 5.e+06,
390-
1.e+07, 2.e+07, 5.e+07, 1.e+08, 2.e+08, 5.e+08,
391-
1.e+09, 2.e+09, 5.e+09])
388+
1.e+07, 2.e+07, 5.e+07, 1.e+08, 2.e+08, 5.e+08])
392389
assert_almost_equal(ll.tick_values(1, 1e8), test_value)
393390

394391
def test_multiple_shared_axes(self):
@@ -1913,14 +1910,47 @@ def test_bad_locator_subs(sub):
19131910
ll.set_params(subs=sub)
19141911

19151912

1916-
@pytest.mark.parametrize('numticks', [1, 2, 3, 9])
1913+
@pytest.mark.parametrize("numticks, lims, ticks", [
1914+
(1, (.5, 5), [.1, 1, 10]),
1915+
(2, (.5, 5), [.1, 1, 10]),
1916+
(3, (.5, 5), [.1, 1, 10]),
1917+
(9, (.5, 5), [.1, 1, 10]),
1918+
(1, (.5, 50), [.1, 10, 1_000]),
1919+
(2, (.5, 50), [.1, 1, 10, 100]),
1920+
(3, (.5, 50), [.1, 1, 10, 100]),
1921+
(9, (.5, 50), [.1, 1, 10, 100]),
1922+
(1, (.5, 500), [.1, 10, 1_000]),
1923+
(2, (.5, 500), [.01, 1, 100, 10_000]),
1924+
(3, (.5, 500), [.1, 1, 10, 100, 1_000]),
1925+
(9, (.5, 500), [.1, 1, 10, 100, 1_000]),
1926+
(1, (.5, 5000), [.1, 100, 100_000]),
1927+
(2, (.5, 5000), [.001, 1, 1_000, 1_000_000]),
1928+
(3, (.5, 5000), [.001, 1, 1_000, 1_000_000]),
1929+
(9, (.5, 5000), [.1, 1, 10, 100, 1_000, 10_000]),
1930+
])
19171931
@mpl.style.context('default')
1918-
def test_small_range_loglocator(numticks):
1919-
ll = mticker.LogLocator()
1920-
ll.set_params(numticks=numticks)
1921-
for top in [5, 7, 9, 11, 15, 50, 100, 1000]:
1922-
ticks = ll.tick_values(.5, top)
1923-
assert (np.diff(np.log10(ll.tick_values(6, 150))) == 1).all()
1932+
def test_small_range_loglocator(numticks, lims, ticks):
1933+
ll = mticker.LogLocator(numticks=numticks)
1934+
assert_array_equal(ll.tick_values(*lims), ticks)
1935+
1936+
1937+
@mpl.style.context('default')
1938+
def test_loglocator_properties():
1939+
max_numticks = 8
1940+
pow_end = 20
1941+
for numticks, (lo, hi) in itertools.product(
1942+
range(1, max_numticks + 1), itertools.combinations(range(pow_end), 2)):
1943+
ll = mticker.LogLocator(numticks=numticks)
1944+
decades = np.log10(ll.tick_values(10**lo, 10**hi)).round().astype(int)
1945+
assert len(decades) <= numticks + 2 # 1 extra tick on each side.
1946+
assert decades[0] < lo <= decades[1]
1947+
assert decades[-2] <= hi < decades[-1]
1948+
stride, = {*np.diff(decades)} # Constant stride.
1949+
# Either aligned on stride integer multiples, or no solution.
1950+
if not (decades % stride == 0).all():
1951+
for offset in range(0, stride):
1952+
alt_decades = range(lo + offset, hi + 1, stride)
1953+
assert len(alt_decades) < len(decades) or len(alt_decades) > numticks
19241954

19251955

19261956
def test_NullFormatter():

lib/matplotlib/ticker.py

+81-35
Original file line numberDiff line numberDiff line change
@@ -2403,14 +2403,22 @@ def __call__(self):
24032403
vmin, vmax = self.axis.get_view_interval()
24042404
return self.tick_values(vmin, vmax)
24052405

2406+
def _log_b(self, x):
2407+
# Use specialized logs if possible, as they can be more accurate; e.g.
2408+
# log(.001) / log(10) = -2.999... (whether math.log or np.log) due to
2409+
# floating point error.
2410+
return (np.log10(x) if self._base == 10 else
2411+
np.log2(x) if self._base == 2 else
2412+
np.log(x) / np.log(self._base))
2413+
24062414
def tick_values(self, vmin, vmax):
24072415
if self.numticks == 'auto':
24082416
if self.axis is not None:
2409-
numticks = np.clip(self.axis.get_tick_space(), 2, 9)
2417+
nt = np.clip(self.axis.get_tick_space(), 2, 9)
24102418
else:
2411-
numticks = 9
2419+
nt = 9
24122420
else:
2413-
numticks = self.numticks
2421+
nt = self.numticks
24142422

24152423
b = self._base
24162424
if vmin <= 0.0:
@@ -2421,17 +2429,17 @@ def tick_values(self, vmin, vmax):
24212429
raise ValueError(
24222430
"Data has no positive values, and therefore cannot be log-scaled.")
24232431

2424-
_log.debug('vmin %s vmax %s', vmin, vmax)
2425-
24262432
if vmax < vmin:
24272433
vmin, vmax = vmax, vmin
2428-
log_vmin = math.log(vmin) / math.log(b)
2429-
log_vmax = math.log(vmax) / math.log(b)
2430-
2431-
numdec = math.floor(log_vmax) - math.ceil(log_vmin)
2434+
# Min and max exponents, float and int versions; e.g., if vmin=10^0.3
2435+
# and vmax=10^6.9, then efmin=0.3, emin=1, emax=6, efmax=6.9, and nd=6.
2436+
efmin, efmax = self._log_b([vmin, vmax])
2437+
emin = math.ceil(efmin)
2438+
emax = math.floor(efmax)
2439+
nd = emax - emin + 1 # Total number of decade ticks available.
24322440

24332441
if isinstance(self._subs, str):
2434-
if numdec > 10 or b < 3:
2442+
if nd >= 10 or b < 3:
24352443
if self._subs == 'auto':
24362444
return np.array([]) # no minor or major ticks
24372445
else:
@@ -2442,35 +2450,73 @@ def tick_values(self, vmin, vmax):
24422450
else:
24432451
subs = self._subs
24442452

2445-
# Get decades between major ticks.
2446-
stride = (max(math.ceil(numdec / (numticks - 1)), 1)
2447-
if mpl.rcParams['_internal.classic_mode'] else
2448-
numdec // numticks + 1)
2449-
2450-
# if we have decided that the stride is as big or bigger than
2451-
# the range, clip the stride back to the available range - 1
2452-
# with a floor of 1. This prevents getting axis with only 1 tick
2453-
# visible.
2454-
if stride >= numdec:
2455-
stride = max(1, numdec - 1)
2456-
2457-
# Does subs include anything other than 1? Essentially a hack to know
2458-
# whether we're a major or a minor locator.
2459-
have_subs = len(subs) > 1 or (len(subs) == 1 and subs[0] != 1.0)
2460-
2461-
decades = np.arange(math.floor(log_vmin) - stride,
2462-
math.ceil(log_vmax) + 2 * stride, stride)
2463-
2464-
if have_subs:
2465-
if stride == 1:
2466-
ticklocs = np.concatenate(
2467-
[subs * decade_start for decade_start in b ** decades])
2453+
# Get decades between major ticks. Include an extra tick outside the
2454+
# lower and the upper limit: QuadContourSet._autolev relies on this.
2455+
if mpl.rcParams["_internal.classic_mode"]: # keep historic formulas
2456+
stride = max(math.ceil((nd - 1) / (nt - 1)), 1)
2457+
decades = np.arange(emin - stride, emax + stride + 1, stride)
2458+
else:
2459+
# Find the largest number of ticks, no more than the requested
2460+
# number, that can actually be drawn (e.g., with 9 decades ticks,
2461+
# no stride yields 7 ticks). For a given value of the stride *s*,
2462+
# there are either floor(nd/s) or ceil(nd/s) ticks depending on the
2463+
# offset. Pick the smallest stride such that floor(nd/s) < nt,
2464+
# i.e. nd/s < nt+1, then re-set nt to ceil(...) if acceptable, else
2465+
# to floor(...) (which must then equal the original nt, i.e. nt is
2466+
# kept unchanged).
2467+
stride = nd // (nt + 1) + 1
2468+
nt1 = math.ceil(nd / stride)
2469+
if nt1 <= nt:
2470+
nt = nt1
2471+
else:
2472+
assert nt1 == nt + 1
2473+
if nt == 0:
2474+
decades = [emin - 1, emax + 1]
2475+
stride = decades[1] - decades[0]
2476+
elif nt == 1: # Draw single tick close to center.
2477+
mid = round((efmin + efmax) / 2)
2478+
stride = max(mid - (emin - 1), (emax + 1) - mid)
2479+
decades = [mid - stride, mid, mid + stride]
2480+
else:
2481+
# Pick the largest s that yields this actual nt (e.g., with 15
2482+
# decades, strides of 5, 6, or 7 *can* yield 3 ticks; picking a
2483+
# larger stride minimizes unticked space at the ends). First
2484+
# try for
2485+
# ceil(nd/s) == nt, i.e. nd/nt <= s < nd/(nt-1),
2486+
# else fallback to
2487+
# floor(nd/s) == nt, i.e. nd/(nt+1) < s <= nd/nt.
2488+
# One of these cases must have an integer solution (given the
2489+
# choice of nt above).
2490+
stride = (nd - 1) // (nt - 1)
2491+
if stride < nd / nt: # fallback to second case
2492+
stride = nd // nt
2493+
# For a given s, once including an offset off (0 <= off < s),
2494+
# the actual number of ticks is ceil((nd - off) / s), which
2495+
# must be equal to nt. This leads to olo <= off < ohi, with
2496+
# the values defined below.
2497+
olo = max(nd - stride * nt, 0)
2498+
ohi = min(nd - stride * (nt - 1), stride)
2499+
# Try to see if we can pick an offset so that ticks are at
2500+
# integer multiples of the stride while satisfying the bounds
2501+
# above; if not, fallback to the smallest acceptable offset.
2502+
offset = (-emin) % stride
2503+
if not olo <= offset < ohi:
2504+
offset = olo
2505+
decades = range(emin + offset - stride, emax + stride + 1, stride)
2506+
2507+
# Guess whether we're a minor locator, based on whether subs include
2508+
# anything other than 1.
2509+
is_minor = len(subs) > 1 or (len(subs) == 1 and subs[0] != 1.0)
2510+
if is_minor:
2511+
if stride == 1 or nd <= 1:
2512+
# Minor ticks start in the decade preceding the first major tick.
2513+
ticklocs = np.concatenate([
2514+
subs * b**decade for decade in range(emin - 1, emax + 1)])
24682515
else:
24692516
ticklocs = np.array([])
24702517
else:
2471-
ticklocs = b ** decades
2518+
ticklocs = b ** np.array(decades)
24722519

2473-
_log.debug('ticklocs %r', ticklocs)
24742520
if (len(subs) > 1
24752521
and stride == 1
24762522
and ((vmin <= ticklocs) & (ticklocs <= vmax)).sum() <= 1):

0 commit comments

Comments
 (0)