Skip to content

Commit c887ecb

Browse files
authored
Merge pull request matplotlib#29698 from anntzer/logticks
Improve tick subsampling in LogLocator.
2 parents 1c02efb + bc94215 commit c887ecb

File tree

7 files changed

+167
-63
lines changed

7 files changed

+167
-63
lines changed

doc/users/next_whats_new/logticks.rst

+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
Improved selection of log-scale ticks
2+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
3+
4+
The algorithm for selecting log-scale ticks (on powers of ten) has been
5+
improved. In particular, it will now always draw as many ticks as possible
6+
(e.g., it will not draw a single tick if it was possible to fit two ticks); if
7+
subsampling ticks, it will prefer putting ticks on integer multiples of the
8+
subsampling stride (e.g., it prefers putting ticks at 10\ :sup:`0`, 10\ :sup:`3`,
9+
10\ :sup:`6` rather than 10\ :sup:`1`, 10\ :sup:`4`, 10\ :sup:`7`) if this
10+
results in the same number of ticks at the end; and it is now more robust
11+
against floating-point calculation errors.

lib/matplotlib/tests/test_axes.py

+3-2
Original file line numberDiff line numberDiff line change
@@ -7008,6 +7008,7 @@ def test_loglog_nonpos():
70087008
ax.set_xscale("log", nonpositive=mcx)
70097009
if mcy:
70107010
ax.set_yscale("log", nonpositive=mcy)
7011+
ax.set_yticks([1e3, 1e7]) # Backcompat tick selection.
70117012

70127013

70137014
@mpl.style.context('default')
@@ -7137,8 +7138,8 @@ def test_auto_numticks_log():
71377138
fig, ax = plt.subplots()
71387139
mpl.rcParams['axes.autolimit_mode'] = 'round_numbers'
71397140
ax.loglog([1e-20, 1e5], [1e-16, 10])
7140-
assert (np.log10(ax.get_xticks()) == np.arange(-26, 18, 4)).all()
7141-
assert (np.log10(ax.get_yticks()) == np.arange(-20, 10, 3)).all()
7141+
assert_array_equal(np.log10(ax.get_xticks()), np.arange(-26, 11, 4))
7142+
assert_array_equal(np.log10(ax.get_yticks()), np.arange(-20, 5, 3))
71427143

71437144

71447145
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

+51-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,54 @@ 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+
# Test that LogLocator returns ticks satisfying basic desirable properties
1940+
# for a wide range of inputs.
1941+
max_numticks = 8
1942+
pow_end = 20
1943+
for numticks, (lo, hi) in itertools.product(
1944+
range(1, max_numticks + 1), itertools.combinations(range(pow_end), 2)):
1945+
ll = mticker.LogLocator(numticks=numticks)
1946+
decades = np.log10(ll.tick_values(10**lo, 10**hi)).round().astype(int)
1947+
# There are no more ticks than the requested number, plus exactly one
1948+
# tick below and one tick above the limits.
1949+
assert len(decades) <= numticks + 2
1950+
assert decades[0] < lo <= decades[1]
1951+
assert decades[-2] <= hi < decades[-1]
1952+
stride, = {*np.diff(decades)} # Extract the (constant) stride.
1953+
# Either the ticks are on integer multiples of the stride...
1954+
if not (decades % stride == 0).all():
1955+
# ... or (for this given stride) no offset would be acceptable,
1956+
# i.e. they would either result in fewer ticks than the selected
1957+
# solution, or more than the requested number of ticks.
1958+
for offset in range(0, stride):
1959+
alt_decades = range(lo + offset, hi + 1, stride)
1960+
assert len(alt_decades) < len(decades) or len(alt_decades) > numticks
19241961

19251962

19261963
def test_NullFormatter():

lib/matplotlib/ticker.py

+88-39
Original file line numberDiff line numberDiff line change
@@ -2403,14 +2403,19 @@ 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):
2407-
if self.numticks == 'auto':
2408-
if self.axis is not None:
2409-
numticks = np.clip(self.axis.get_tick_space(), 2, 9)
2410-
else:
2411-
numticks = 9
2412-
else:
2413-
numticks = self.numticks
2415+
n_request = (
2416+
self.numticks if self.numticks != "auto" else
2417+
np.clip(self.axis.get_tick_space(), 2, 9) if self.axis is not None else
2418+
9)
24142419

24152420
b = self._base
24162421
if vmin <= 0.0:
@@ -2421,17 +2426,17 @@ def tick_values(self, vmin, vmax):
24212426
raise ValueError(
24222427
"Data has no positive values, and therefore cannot be log-scaled.")
24232428

2424-
_log.debug('vmin %s vmax %s', vmin, vmax)
2425-
24262429
if vmax < vmin:
24272430
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)
2431+
# Min and max exponents, float and int versions; e.g., if vmin=10^0.3,
2432+
# vmax=10^6.9, then efmin=0.3, emin=1, emax=6, efmax=6.9, n_avail=6.
2433+
efmin, efmax = self._log_b([vmin, vmax])
2434+
emin = math.ceil(efmin)
2435+
emax = math.floor(efmax)
2436+
n_avail = emax - emin + 1 # Total number of decade ticks available.
24322437

24332438
if isinstance(self._subs, str):
2434-
if numdec > 10 or b < 3:
2439+
if n_avail >= 10 or b < 3:
24352440
if self._subs == 'auto':
24362441
return np.array([]) # no minor or major ticks
24372442
else:
@@ -2442,35 +2447,79 @@ def tick_values(self, vmin, vmax):
24422447
else:
24432448
subs = self._subs
24442449

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

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

0 commit comments

Comments
 (0)