Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

Commit c887ecb

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

File tree

Expand file treeCollapse file tree

7 files changed

+167
-63
lines changed
Filter options
Expand file treeCollapse file tree

7 files changed

+167
-63
lines changed

‎doc/users/next_whats_new/logticks.rst

Copy file name to clipboard
+11Lines changed: 11 additions & 0 deletions
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

Copy file name to clipboardExpand all lines: lib/matplotlib/tests/test_axes.py
+3-2Lines changed: 3 additions & 2 deletions
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

Copy file name to clipboardExpand all lines: lib/matplotlib/tests/test_colorbar.py
+7-6Lines changed: 7 additions & 6 deletions
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

Copy file name to clipboardExpand all lines: lib/matplotlib/tests/test_contour.py
+4-1Lines changed: 4 additions & 1 deletion
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

Copy file name to clipboardExpand all lines: lib/matplotlib/tests/test_scale.py
+3-1Lines changed: 3 additions & 1 deletion
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

Copy file name to clipboardExpand all lines: lib/matplotlib/tests/test_ticker.py
+51-14Lines changed: 51 additions & 14 deletions
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

Copy file name to clipboardExpand all lines: lib/matplotlib/ticker.py
+88-39Lines changed: 88 additions & 39 deletions
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 (0)
Morty Proxy This is a proxified and sanitized view of the page, visit original site.