Skip to content

Commit 34fa95b

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), and 2) the ticks are spaced as much as possible (when two ticks are drawn, put them on the lowest and highest decade, not somewhere in the middle). 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: - 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_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). to be handled: test_colorbar::test_colorbar_autotickslog: test is weird, values change after triggering a draw anyways... test_contour::test_contourf_log_extension: edge bins can now be wider to respect stride. test_axes, test_scale: expected image changes?
1 parent 0b7a88a commit 34fa95b

File tree

4 files changed

+105
-52
lines changed

4 files changed

+105
-52
lines changed

lib/matplotlib/tests/test_axes.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -7132,8 +7132,8 @@ def test_auto_numticks_log():
71327132
fig, ax = plt.subplots()
71337133
mpl.rcParams['axes.autolimit_mode'] = 'round_numbers'
71347134
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()
7135+
assert_array_equal(np.log10(ax.get_xticks()), np.arange(-26, 11, 4))
7136+
assert_array_equal(np.log10(ax.get_yticks()), np.arange(-20, 5, 3))
71377137

71387138

71397139
def test_broken_barh_empty():

lib/matplotlib/tests/test_colorbar.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -597,7 +597,7 @@ def test_colorbar_renorm():
597597
norm = LogNorm(z.min(), z.max())
598598
im.set_norm(norm)
599599
np.testing.assert_allclose(cbar.ax.yaxis.get_majorticklocs(),
600-
np.logspace(-10, 7, 18))
600+
np.logspace(-9, 6, 16))
601601
# note that set_norm removes the FixedLocator...
602602
assert np.isclose(cbar.vmin, z.min())
603603
cbar.set_ticks([1, 2, 3])

lib/matplotlib/tests/test_ticker.py

+25-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,28 @@ 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)
19241935

19251936

19261937
def test_NullFormatter():

lib/matplotlib/ticker.py

+77-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,16 @@ 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.
2435+
efmin, efmax = self._log_b([vmin, vmax])
2436+
emin = math.ceil(efmin)
2437+
emax = math.floor(efmax)
2438+
nd = emax - emin + 1 # Total number of decade ticks available.
24322439

24332440
if isinstance(self._subs, str):
2434-
if numdec > 10 or b < 3:
2441+
if nd >= 10 or b < 3:
24352442
if self._subs == 'auto':
24362443
return np.array([]) # no minor or major ticks
24372444
else:
@@ -2442,35 +2449,70 @@ def tick_values(self, vmin, vmax):
24422449
else:
24432450
subs = self._subs
24442451

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])
2452+
# Get decades between major ticks. Always return an extra tick outside
2453+
# the lower and the upper limit: QuadContourSet._autolev relies on
2454+
# 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) - stride)
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+
# In the second case, an offset must be added to that only nt
2491+
# ticks are drawn (e.g. with 19 decades (0 to 18 incl.) and 6
2492+
# ticks, they should must be drawn at exponents 1, 4, 7, 10,
2493+
# 13, and 16).
2494+
stride = (nd - 1) // (nt - 1)
2495+
if stride < nd / nt: # fallback to second case
2496+
stride = nd // nt
2497+
assert stride > nd / (nt + 1)
2498+
offset = nd % stride
2499+
else:
2500+
offset = 0
2501+
decades = range(emin + offset - stride, emax + stride + 1, stride)
2502+
2503+
# Guess whether we're a minor locator, based on whether subs include
2504+
# anything other than 1.
2505+
is_minor = len(subs) > 1 or (len(subs) == 1 and subs[0] != 1.0)
2506+
if is_minor:
2507+
if stride == 1 or nd <= 1:
2508+
# Minor ticks start in the decade preceding the first major tick.
2509+
ticklocs = np.concatenate([
2510+
subs * b**decade for decade in range(emin - 1, emax + 1)])
24682511
else:
24692512
ticklocs = np.array([])
24702513
else:
2471-
ticklocs = b ** decades
2514+
ticklocs = b ** np.array(decades)
24722515

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

0 commit comments

Comments
 (0)