Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
188 changes: 188 additions & 0 deletions lib/matplotlib/tests/test_wilkinson_locator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import numpy as np
import pytest
from matplotlib.ticker import MaxNLocator
from matplotlib.ticker import WilkinsonLocator


def test_wilkinson_basic():
"""Ticks should cover the full data range."""
loc = WilkinsonLocator(nbins=5)
ticks = loc.tick_values(3, 97)

assert len(ticks) >= 2
assert ticks[0] <= 3
assert ticks[-1] >= 97


def test_wilkinson_vs_maxn():
"""WilkinsonLocator should not produce more ticks than MaxNLocator."""
w = WilkinsonLocator(nbins=5).tick_values(3, 97)
m = MaxNLocator(nbins=5).tick_values(3, 97)

assert len(w) <= len(m)


def test_wilkinson_reversed_input():
"""Should handle vmin > vmax gracefully."""
loc = WilkinsonLocator(nbins=5)
ticks = loc.tick_values(97, 3)

assert len(ticks) >= 2
assert ticks[0] <= 3
assert ticks[-1] >= 97


def test_wilkinson_equal_input():
"""Should handle vmin == vmax without crashing."""
loc = WilkinsonLocator(nbins=5)
ticks = loc.tick_values(50, 50)

assert len(ticks) >= 1


def test_wilkinson_negative_range():
"""Should work correctly for negative ranges."""
loc = WilkinsonLocator(nbins=5)
ticks = loc.tick_values(-100, -10)

assert ticks[0] <= -100
assert ticks[-1] >= -10


def test_wilkinson_cross_zero():
"""Should work correctly when range crosses zero."""
loc = WilkinsonLocator(nbins=5)
ticks = loc.tick_values(-50, 50)

assert ticks[0] <= -50
assert ticks[-1] >= 50


def test_wilkinson_small_range():
"""Should work for very small ranges."""
loc = WilkinsonLocator(nbins=5)
ticks = loc.tick_values(0.001, 0.009)

assert len(ticks) >= 2
assert ticks[0] <= 0.001
assert ticks[-1] >= 0.009


def test_wilkinson_large_range():
"""Should work for very large ranges."""
loc = WilkinsonLocator(nbins=5)
ticks = loc.tick_values(0, 1_000_000)

assert len(ticks) >= 2
assert ticks[0] <= 0
assert ticks[-1] >= 1_000_000


# ---------------- steps Parameter ---------------- #

def test_wilkinson_custom_steps():
"""Custom steps should be respected."""
loc = WilkinsonLocator(nbins=5, steps=[1, 2, 5, 10])
ticks = loc.tick_values(0, 100)

assert len(ticks) >= 2
assert ticks[0] <= 0
assert ticks[-1] >= 100


def test_wilkinson_custom_steps_stored():
"""Custom steps should be stored on the instance."""
custom = [1, 2, 5, 10]
loc = WilkinsonLocator(nbins=5, steps=custom)

assert loc.steps == custom


def test_wilkinson_default_steps():
"""Default steps should be [1, 2, 2.5, 5, 10]."""
loc = WilkinsonLocator(nbins=5)

assert loc.steps == [1, 2, 2.5, 5, 10]


def test_wilkinson_steps_empty_raises():
"""Empty steps list should raise ValueError."""
with pytest.raises(ValueError):
WilkinsonLocator(nbins=5, steps=[])


def test_wilkinson_single_step():
"""Single step value should still produce ticks."""
loc = WilkinsonLocator(nbins=5, steps=[1])
ticks = loc.tick_values(0, 100)

assert len(ticks) >= 2


# ---------------- Scoring Fairness ---------------- #

def test_wilkinson_coverage_dominates():
"""
Coverage should dominate simplicity.
Both [0,25,50,75,100] and [0,20,40,60,80,100] are valid for (0,100).
The choice should depend on coverage/density, not just q niceness.
The key assertion is that the result covers the range well.
"""
loc = WilkinsonLocator(nbins=5)
ticks = loc.tick_values(0, 100)

assert ticks[0] <= 0
assert ticks[-1] >= 100
assert len(ticks) >= 4


def test_wilkinson_does_not_always_prefer_25_steps():
"""
For a range like (0, 80), [0,20,40,60,80] (step=20) should score
higher than [0,25,50,75] (step=25) because it covers the range better.
"""
loc = WilkinsonLocator(nbins=5)
ticks = loc.tick_values(0, 80)

# [0, 20, 40, 60, 80] covers exactly; [0, 25, 50, 75] misses 80
assert ticks[-1] >= 80


def test_wilkinson_simplicity_not_sole_decider():
"""
q=1 is always most 'simple', but shouldn't always win.
For (0, 100) with nbins=5, a step of 25 or 20 is better than step=1.
"""
loc = WilkinsonLocator(nbins=5)
ticks = loc.tick_values(0, 100)

# If simplicity alone decided, we'd get 100 ticks of step=1
assert len(ticks) <= 10


# ---------------- Tick Quality ---------------- #

def test_wilkinson_ticks_are_sorted():
"""Ticks should always be in ascending order."""
loc = WilkinsonLocator(nbins=5)
ticks = loc.tick_values(3, 97)

assert np.all(np.diff(ticks) > 0)


def test_wilkinson_ticks_evenly_spaced():
"""Ticks should be evenly spaced (uniform step)."""
loc = WilkinsonLocator(nbins=5)
ticks = loc.tick_values(0, 100)

diffs = np.diff(ticks)
assert np.allclose(diffs, diffs[0], rtol=1e-5)


def test_wilkinson_nbins_respected():
"""Number of ticks should stay close to nbins."""
for nbins in [3, 5, 8, 10]:
loc = WilkinsonLocator(nbins=nbins)
ticks = loc.tick_values(0, 100)
# Allow some flexibility but shouldn't be wildly off
assert len(ticks) <= nbins * 2 + 1
176 changes: 175 additions & 1 deletion lib/matplotlib/ticker.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,8 @@
'Locator', 'IndexLocator', 'FixedLocator', 'NullLocator',
'LinearLocator', 'LogLocator', 'AutoLocator',
'MultipleLocator', 'MaxNLocator', 'AutoMinorLocator',
'SymmetricalLogLocator', 'AsinhLocator', 'LogitLocator')
'SymmetricalLogLocator', 'AsinhLocator', 'LogitLocator',
'WilkinsonLocator')


class _DummyAxis:
Expand Down Expand Up @@ -3045,3 +3046,176 @@ def __call__(self):
def tick_values(self, vmin, vmax):
raise NotImplementedError(
f"Cannot get tick locations for a {type(self).__name__}")


class WilkinsonLocator(Locator):
"""
Tick locator based on an Extended Wilkinson-style algorithm.
Balances simplicity, coverage, and density.

Parameters
----------
nbins : int, optional
Target number of tick intervals. Default is 10.
steps : list of float, optional
Sequence of "nice" step values (mantissas) to consider.
Each value should be in the range [1, 10].
Default is [1, 2, 2.5, 5, 10], matching Wilkinson's original Q set.
Example: steps=[1, 2, 5, 10] for a simpler set.
"""

def __init__(self, nbins=10, steps=None):
self._nbins = max(1, int(nbins))
if steps is None:
self.steps = [1, 2, 2.5, 5, 10]
else:
if not steps:
raise ValueError("steps must be a non-empty list")
self.steps = sorted([float(s) for s in steps])

def __call__(self):
vmin, vmax = self.axis.get_view_interval()
return self.tick_values(vmin, vmax)

def tick_values(self, vmin, vmax):
# Handle reversed inputs
if vmin > vmax:
vmin, vmax = vmax, vmin

# Handle singular case (vmin == vmax)
if vmin == vmax:
vmin -= 1
vmax += 1

locs = self._wilkinson(vmin, vmax)
return self.raise_if_exceeds(locs)

# ---------------- CORE ALGORITHM ---------------- #

def _wilkinson(self, vmin, vmax):
best_score = -np.inf
best_ticks = None

span = vmax - vmin
if span == 0:
return np.array([vmin])

k_min = int(np.floor(np.log10(span))) - 1
k_max = int(np.ceil(np.log10(span))) + 1

for q in self.steps:
for k in range(k_min, k_max + 1):
if abs(k) > 10:
continue

step = q * (10 ** k)
if step <= 0:
continue

lmin = np.floor(vmin / step) * step
lmax = np.ceil(vmax / step) * step

ticks = np.arange(lmin, lmax + 0.5 * step, step)
n = len(ticks)

# Reject too many or too few ticks
if n < 2 or n > self._nbins * 2:
continue

score = self._score(vmin, vmax, ticks, q)

if score > best_score:
best_score = score
best_ticks = ticks

# Fallback
if best_ticks is None:
step = span / self._nbins
best_ticks = np.arange(vmin, vmax + step, step)


# Clamp to data range with small tolerance
best_ticks = best_ticks[
(best_ticks >= vmin - 1e-9) &
(best_ticks <= vmax + 1e-9)
]

# Rebuild tick array using consistent step to avoid float drift
if len(best_ticks) >= 2:
step = best_ticks[1] - best_ticks[0]
start = np.floor(vmin / step) * step
end = np.ceil(vmax / step) * step
best_ticks = np.arange(start, end + 0.5 * step, step)

# Ensure at least 2 ticks
if len(best_ticks) < 2:
return np.array([vmin, vmax])

return best_ticks

# ---------------- SCORING ---------------- #

def _score(self, vmin, vmax, ticks, q):
"""
Score a candidate tick sequence.

Weights are chosen so that coverage (how well ticks span the data)
and density (how close n is to nbins) dominate over simplicity
(niceness of q). This avoids always preferring q=2.5 (25-steps)
over q=2 (20-steps) regardless of context — the better choice
depends on coverage and density for the specific data range.
"""
n = len(ticks)

simplicity = self._simplicity(q)
coverage = self._coverage(vmin, vmax, ticks)
density = self._density(n)

overflow = (
max(0, vmin - ticks[0]) +
max(0, ticks[-1] - vmax)
)

# Penalize exceeding target bin count
tick_penalty = max(0, n - self._nbins)

return (
0.15 * simplicity + # Low weight: niceness of q alone shouldn't decide
0.50 * coverage + # High weight: ticks must cover the data range well
0.25 * density - # Medium weight: stay close to nbins
0.50 * tick_penalty - # Penalize too many ticks
2.00 * overflow / (vmax - vmin) # Penalize ticks outside data range
)

def _simplicity(self, q):
"""
Score how 'nice' the step value q is.
Lower index in self.steps = simpler = higher score.
Returns a value in [0, 1].
"""
n = len(self.steps)
if n == 1:
return 1.0
return 1 - self.steps.index(q) / (n - 1)

def _coverage(self, vmin, vmax, ticks):
"""
Score how well the ticks cover [vmin, vmax].
Penalizes both under- and over-shooting the data range.
Returns a value close to 1 when ticks align tightly with data.
"""
span = vmax - vmin
if span == 0:
return 1.0

return 1 - (
((vmin - ticks[0]) ** 2 + (ticks[-1] - vmax) ** 2)
/ (span ** 2)
)

def _density(self, n):
"""
Score how close the number of ticks n is to the target nbins.
Returns 1 when n == nbins, decreasing as n moves away.
"""
return max(0, 1 - abs(n - self._nbins) / self._nbins)
Loading