Source code for ultraplot.ticker
#!/usr/bin/env python3
"""
Various `~matplotlib.ticker.Locator` and `~matplotlib.ticker.Formatter` classes.
"""
import locale
import re
from fractions import Fraction
import matplotlib.axis as maxis
import matplotlib.dates as mdates
import matplotlib.ticker as mticker
import matplotlib.transforms as mtransforms
import matplotlib.units as munits
from datetime import datetime, timedelta
import numpy as np
try:
import cftime
except ModuleNotFoundError:
cftime = None
from .config import rc
from .internals import ic # noqa: F401
from .internals import _not_none, context, docstring
try:
import cartopy.crs as ccrs
from cartopy.mpl.ticker import (
LatitudeFormatter,
LongitudeFormatter,
_PlateCarreeFormatter,
)
except ModuleNotFoundError:
ccrs = None
LatitudeFormatter = LongitudeFormatter = _PlateCarreeFormatter = object
__all__ = [
"IndexLocator",
"DiscreteLocator",
"DegreeLocator",
"LongitudeLocator",
"LatitudeLocator",
"AutoFormatter",
"SimpleFormatter",
"IndexFormatter",
"SciFormatter",
"SigFigFormatter",
"FracFormatter",
"CFDatetimeFormatter",
"AutoCFDatetimeFormatter",
"AutoCFDatetimeLocator",
"DegreeFormatter",
"LongitudeFormatter",
"LatitudeFormatter",
]
REGEX_ZERO = re.compile("\\A[-\N{MINUS SIGN}]?0(.0*)?\\Z")
REGEX_MINUS = re.compile("\\A[-\N{MINUS SIGN}]\\Z")
REGEX_MINUS_ZERO = re.compile("\\A[-\N{MINUS SIGN}]0(.0*)?\\Z")
_precision_docstring = """
precision : int, default: {6, 2}
The maximum number of digits after the decimal point. Default is ``6``
when `zerotrim` is ``True`` and ``2`` otherwise.
"""
_zerotrim_docstring = """
zerotrim : bool, default: :rc:`formatter.zerotrim`
Whether to trim trailing decimal zeros.
"""
_auto_docstring = """
tickrange : 2-tuple of float, optional
Range within which major tick marks are labeled.
All ticks are labeled by default.
wraprange : 2-tuple of float, optional
Range outside of which tick values are wrapped. For example,
``(-180, 180)`` will format a value of ``200`` as ``-160``.
prefix, suffix : str, optional
Prefix and suffix for all tick strings. The suffix is added before
the optional `negpos` suffix.
negpos : str, optional
Length-2 string indicating the suffix for "negative" and "positive"
numbers, meant to replace the minus sign.
"""
_formatter_call = """
Convert number to a string.
Parameters
----------
x : float
The value.
pos : float, optional
The position.
"""
docstring._snippet_manager["ticker.precision"] = _precision_docstring
docstring._snippet_manager["ticker.zerotrim"] = _zerotrim_docstring
docstring._snippet_manager["ticker.auto"] = _auto_docstring
docstring._snippet_manager["ticker.call"] = _formatter_call
_dms_docstring = """
Parameters
----------
dms : bool, default: False
Locate the ticks on clean degree-minute-second intervals and format the
ticks with minutes and seconds instead of decimals.
"""
docstring._snippet_manager["ticker.dms"] = _dms_docstring
def _default_precision_zerotrim(precision=None, zerotrim=None):
"""
Return the default zerotrim and precision. Shared by several formatters.
"""
zerotrim = _not_none(zerotrim, rc["formatter.zerotrim"])
if precision is None:
precision = 6 if zerotrim else 2
return precision, zerotrim
[docs]
class IndexLocator(mticker.Locator):
"""
Format numbers by assigning fixed strings to non-negative indices. The ticks
are restricted to the extent of plotted content when content is present.
"""
def __init__(self, base=1, offset=0):
self._base = base
self._offset = offset
[docs]
def set_params(self, base=None, offset=None):
if base is not None:
self._base = base
if offset is not None:
self._offset = offset
[docs]
def __call__(self):
# NOTE: We adapt matplotlib IndexLocator to support case where
# the data interval is empty. Only restrict after data is plotted.
dmin, dmax = self.axis.get_data_interval()
vmin, vmax = self.axis.get_view_interval()
min_ = max(dmin, vmin)
max_ = min(dmax, vmax)
return self.tick_values(min_, max_)
[docs]
def tick_values(self, vmin, vmax):
base, offset = self._base, self._offset
vmin = max(base * np.ceil(vmin / base), offset)
vmax = max(base * np.floor(vmax / base), offset)
locs = np.arange(vmin, vmax + 0.5 * base, base)
return self.raise_if_exceeds(locs)
[docs]
class DiscreteLocator(mticker.Locator):
"""
A tick locator suitable for discretized colorbars. Adds ticks to some
subset of the location list depending on the available space determined from
`~matplotlib.axis.Axis.get_tick_space`. Zero will be used if it appears in the
location list, and step sizes along the location list are restricted to "nice"
intervals by default.
"""
default_params = {
"nbins": None,
"minor": False,
"steps": np.array([1, 2, 3, 4, 5, 6, 8, 10]),
"min_n_ticks": 2,
}
@docstring._snippet_manager
def __init__(self, locs, **kwargs):
"""
Parameters
----------
locs : array-like
The tick location list.
nbins : int, optional
Maximum number of ticks to select. By default this is automatically
determined based on the the axis length and tick label font size.
minor : bool, default: False
Whether this is for "minor" ticks. Setting to ``True`` will select more
ticks with an index step that divides the index step used for "major" ticks.
steps : array-like of int, default: ``[1 2 3 4 5 6 8]``
Valid integer index steps when selecting from the tick list. Must fall
between 1 and 9. Powers of 10 of these step sizes will also be permitted.
min_n_ticks : int, default: 1
The minimum number of ticks to select. See also `nbins`.
"""
self.locs = np.array(locs)
self._nbins = None # otherwise unset
self.set_params(**{**self.default_params, **kwargs})
[docs]
def __call__(self):
"""
Return the locations of the ticks.
"""
return self.tick_values(None, None)
[docs]
def set_params(self, steps=None, nbins=None, minor=None, min_n_ticks=None):
"""
Set the parameters for this locator. See `DiscreteLocator` for details.
"""
if steps is not None:
steps = np.unique(np.array(steps, dtype=int)) # also sorts, makes 1D
if np.any(steps < 1) or np.any(steps > 10):
raise ValueError("Steps must fall between one and ten (inclusive).")
if steps[0] != 1:
steps = np.concatenate([[1], steps])
if steps[-1] != 10:
steps = np.concatenate([steps, [10]])
self._steps = steps
if nbins is not None:
self._nbins = nbins
if minor is not None:
self._minor = bool(minor) # needed to scale tick space
if min_n_ticks is not None:
self._min_n_ticks = int(min_n_ticks) # compare to MaxNLocator
[docs]
def tick_values(self, vmin, vmax): # noqa: U100
"""
Return the locations of the ticks.
"""
# NOTE: Critical that minor tick interval evenly divides major tick
# interval. Otherwise get misaligned major and minor tick steps.
# NOTE: This tries to select ticks that are integer steps away from zero (like
# AutoLocator). The list minimum is used if this fails (like FixedLocator)
# NOTE: This avoids awkward steps like '7' or '13' that produce awkward
# jumps and have no integer divisors (and therefore eliminate minor ticks)
# NOTE: We virtually always want to subsample the level list rather than
# using continuous minor locators (e.g. LogLocator or SymLogLocator) because
# _parse_autolev interpolates evenly in the norm-space (e.g. 1, 3.16, 10, 31.6
# for a LogNorm) rather than in linear-space (e.g. 1, 5, 10, 15, 20).
locs = self.locs
axis = self.axis
if axis is None:
return locs
nbins = self._nbins
steps = self._steps
if nbins is None:
nbins = axis.get_tick_space()
nbins = max((1, self._min_n_ticks - 1, nbins))
step = max(1, int(np.ceil(locs.size / nbins)))
fact = 10 ** max(0, -AutoFormatter._decimal_place(step)) # e.g. 2 for 100
idx = min(len(steps) - 1, np.searchsorted(steps, step / fact))
step = int(np.round(steps[idx] * fact))
if self._minor: # tick every half font size
if isinstance(axis, maxis.XAxis):
fact = 6 # unscale heuristic scaling of 3 em-widths
elif isinstance(axis, maxis.YAxis):
fact = 4 # unscale standard scaling of 2 em-widths
else:
fact = 2 # fall back to just one em-width
for i in range(fact, 0, -1):
if step % i == 0:
step = step // i
break
diff = np.abs(np.diff(locs[: step + 1 : step]))
if diff.size:
matches = np.where(np.isclose(locs % diff, 0.0))[0]
offset = matches[0] if len(matches) else np.argmin(np.abs(locs))
else:
offset = np.argmin(np.abs(locs))
return locs[
int(offset) % step :: step
] # even multiples from zero or zero-close
[docs]
class DegreeLocator(mticker.MaxNLocator):
"""
Locate geographic gridlines with degree-minute-second support.
Adapted from cartopy.
"""
# NOTE: This is identical to cartopy except they only define LongitutdeLocator
# for common methods whereas we use DegreeLocator. More intuitive this way in
# case users need degree-minute-seconds for non-specific degree axis.
# NOTE: Locator implementation is weird AF. __init__ just calls set_params with all
# keyword args and fills in missing params with default_params class attribute.
# Unknown params result in warning instead of error.
default_params = mticker.MaxNLocator.default_params.copy()
default_params.update(nbins=8, dms=False)
@docstring._snippet_manager
def __init__(self, *args, **kwargs):
"""
%(ticker.dms)s
"""
super().__init__(*args, **kwargs)
[docs]
def set_params(self, **kwargs):
if "dms" in kwargs:
self._dms = kwargs.pop("dms")
super().set_params(**kwargs)
def _guess_steps(self, vmin, vmax):
dv = abs(vmax - vmin)
if dv > 180:
dv -= 180
if dv > 50:
steps = np.array([1, 2, 3, 6, 10])
elif not self._dms or dv > 3.0:
steps = np.array([1, 1.5, 2, 2.5, 3, 5, 10])
else:
steps = np.array([1, 10 / 6.0, 15 / 6.0, 20 / 6.0, 30 / 6.0, 10])
self.set_params(steps=np.array(steps))
def _raw_ticks(self, vmin, vmax):
self._guess_steps(vmin, vmax)
return super()._raw_ticks(vmin, vmax)
[docs]
def bin_boundaries(self, vmin, vmax): # matplotlib < 2.2.0
return self._raw_ticks(vmin, vmax) # may call Latitude/Longitude Locator copies
[docs]
class LongitudeLocator(DegreeLocator):
"""
Locate longitude gridlines with degree-minute-second support.
Adapted from cartopy.
"""
@docstring._snippet_manager
def __init__(self, lon0=0, *args, **kwargs):
"""
%(ticker.dms)s
Parameters
----------
lon0 : float, default=0
The central longitude around which the longitude labels are centered.
This parameter adjusts the alignment of the longitude gridlines and
labels, ensuring they are centered relative to the specified value.
"""
super().__init__(*args, **kwargs)
[docs]
class LatitudeLocator(DegreeLocator):
"""
Locate latitude gridlines with degree-minute-second support.
Adapted from cartopy.
"""
@docstring._snippet_manager
def __init__(self, *args, **kwargs):
"""
%(ticker.dms)s
"""
super().__init__(*args, **kwargs)
[docs]
def tick_values(self, vmin, vmax):
vmin = max(vmin, -90)
vmax = min(vmax, 90)
return super().tick_values(vmin, vmax)
def _guess_steps(self, vmin, vmax):
vmin = max(vmin, -90)
vmax = min(vmax, 90)
super()._guess_steps(vmin, vmax)
def _raw_ticks(self, vmin, vmax):
ticks = super()._raw_ticks(vmin, vmax)
return [t for t in ticks if -90 <= t <= 90]
[docs]
class AutoFormatter(mticker.ScalarFormatter):
"""
The default formatter used for ultraplot tick labels.
Replaces `~matplotlib.ticker.ScalarFormatter`.
"""
@docstring._snippet_manager
def __init__(
self,
zerotrim=None,
tickrange=None,
wraprange=None,
prefix=None,
suffix=None,
negpos=None,
**kwargs,
):
"""
Parameters
----------
%(ticker.zerotrim)s
%(ticker.auto)s
Other parameters
----------------
**kwargs
Passed to `matplotlib.ticker.ScalarFormatter`.
See also
--------
ultraplot.constructor.Formatter
ultraplot.ticker.SimpleFormatter
Note
----
`matplotlib.ticker.ScalarFormatter` determines the number of
significant digits based on the axis limits, and therefore may
truncate digits while formatting ticks on highly non-linear axis
scales like `~ultraplot.scale.LogScale`. `AutoFormatter` corrects
this behavior, making it suitable for arbitrary axis scales. We
therefore use `AutoFormatter` with every axis scale by default.
"""
tickrange = tickrange or (-np.inf, np.inf)
super().__init__(**kwargs)
zerotrim = _not_none(zerotrim, rc["formatter.zerotrim"])
self._zerotrim = zerotrim
self._tickrange = tickrange
self._wraprange = wraprange
self._prefix = prefix or ""
self._suffix = suffix or ""
self._negpos = negpos or ""
[docs]
@docstring._snippet_manager
def __call__(self, x, pos=None):
"""
%(ticker.call)s
"""
# Tick range limitation
x = self._wrap_tick_range(x, self._wraprange)
if self._outside_tick_range(x, self._tickrange):
return ""
# Negative positive handling
x, tail = self._neg_pos_format(x, self._negpos, wraprange=self._wraprange)
# Default string formatting
string = super().__call__(x, pos)
# Fix issue where non-zero string is formatted as zero
string = self._fix_small_number(x, string)
# Custom string formatting
string = self._minus_format(string)
if self._zerotrim:
string = self._trim_trailing_zeros(string, self._get_decimal_point())
# Prefix and suffix
string = self._add_prefix_suffix(string, self._prefix, self._suffix)
string = string + tail # add negative-positive indicator
return string
[docs]
def get_offset(self):
"""
Get the offset but *always* use math text.
"""
with context._state_context(self, _useMathText=True):
return super().get_offset()
@staticmethod
def _add_prefix_suffix(string, prefix=None, suffix=None):
"""
Add prefix and suffix to string.
"""
sign = ""
prefix = prefix or ""
suffix = suffix or ""
if string and REGEX_MINUS.match(string[0]):
sign, string = string[0], string[1:]
return sign + prefix + string + suffix
def _fix_small_number(self, x, string, precision_offset=2):
"""
Fix formatting for non-zero formatted as zero. The `offset` controls the offset
from true floating point precision at which we want to limit string precision.
"""
# Add just enough precision for small numbers. Default formatter is
# only meant to be used for linear scales and cannot handle the wide
# range of magnitudes in e.g. log scales. To correct this, we only
# truncate if value is within `offset` order of magnitude of the float
# precision. Common issue is e.g. levels=uplt.arange(-1, 1, 0.1).
# This choice satisfies even 1000 additions of 0.1 to -100.
m = REGEX_ZERO.match(string)
decimal_point = self._get_decimal_point()
if m and x != 0:
# Get initial precision spit out by algorithm
(decimals,) = m.groups()
precision_init = len(decimals.lstrip(decimal_point)) if decimals else 0
# Format with precision below floating point error
x -= getattr(self, "offset", 0) # guard against API change
x /= 10 ** getattr(self, "orderOfMagnitude", 0) # guard against API change
precision_true = max(0, self._decimal_place(x))
precision_max = max(0, np.finfo(type(x)).precision - precision_offset)
precision = min(precision_true, precision_max)
string = ("{:.%df}" % precision).format(x)
# If zero ignoring floating point error then match original precision
if REGEX_ZERO.match(string):
string = ("{:.%df}" % precision_init).format(0)
# Fix decimal point
string = string.replace(".", decimal_point)
return string
def _get_decimal_point(self, use_locale=None):
"""
Get decimal point symbol for current locale (e.g. in Europe will be comma).
"""
use_locale = _not_none(use_locale, self.get_useLocale())
return self._get_default_decimal_point(use_locale)
@staticmethod
def _get_default_decimal_point(use_locale=None):
"""
Get decimal point symbol for current locale. Called externally.
"""
use_locale = _not_none(use_locale, rc["formatter.use_locale"])
return locale.localeconv()["decimal_point"] if use_locale else "."
@staticmethod
def _decimal_place(x):
"""
Return the decimal place of the number (e.g., 100 is -2 and 0.01 is 2).
"""
if x == 0:
digits = 0
else:
digits = -int(np.log10(abs(x)) // 1)
return digits
@staticmethod
def _minus_format(string):
"""
Format the minus sign and avoid "negative zero," e.g. ``-0.000``.
"""
if rc["axes.unicode_minus"] and not rc["text.usetex"]:
string = string.replace("-", "\N{MINUS SIGN}")
if REGEX_MINUS_ZERO.match(string):
string = string[1:]
return string
@staticmethod
def _neg_pos_format(x, negpos, wraprange=None):
"""
Permit suffixes indicators for "negative" and "positive" numbers.
"""
# NOTE: If input is a symmetric wraprange, the value conceptually has
# no "sign", so trim tail and format as absolute value.
if not negpos or x == 0:
tail = ""
elif (
wraprange is not None
and np.isclose(-wraprange[0], wraprange[1])
and np.any(np.isclose(x, wraprange))
):
x = abs(x)
tail = ""
elif x > 0:
tail = negpos[1]
else:
x *= -1
tail = negpos[0]
return x, tail
@staticmethod
def _outside_tick_range(x, tickrange):
"""
Return whether point is outside tick range up to some precision.
"""
eps = abs(x) / 1000
return (x + eps) < tickrange[0] or (x - eps) > tickrange[1]
@staticmethod
def _trim_trailing_zeros(string, decimal_point="."):
"""
Sanitize tick label strings.
"""
if decimal_point in string:
string = string.rstrip("0").rstrip(decimal_point)
return string
@staticmethod
def _wrap_tick_range(x, wraprange):
"""
Wrap the tick range to within these values.
"""
if wraprange is None:
return x
base = wraprange[0]
modulus = wraprange[1] - wraprange[0]
return (x - base) % modulus + base
[docs]
class SimpleFormatter(mticker.Formatter):
"""
A general purpose number formatter. This is similar to `AutoFormatter`
but suitable for arbitrary formatting not necessarily associated with
an `~matplotlib.axis.Axis` instance.
"""
@docstring._snippet_manager
def __init__(
self,
precision=None,
zerotrim=None,
tickrange=None,
wraprange=None,
prefix=None,
suffix=None,
negpos=None,
):
"""
Parameters
----------
%(ticker.precision)s
%(ticker.zerotrim)s
%(ticker.auto)s
See also
--------
ultraplot.constructor.Formatter
ultraplot.ticker.AutoFormatter
"""
precision, zerotrim = _default_precision_zerotrim(precision, zerotrim)
self._precision = precision
self._prefix = prefix or ""
self._suffix = suffix or ""
self._negpos = negpos or ""
self._tickrange = tickrange or (-np.inf, np.inf)
self._wraprange = wraprange
self._zerotrim = zerotrim
[docs]
@docstring._snippet_manager
def __call__(self, x, pos=None): # noqa: U100
"""
%(ticker.call)s
"""
# Tick range limitation
x = AutoFormatter._wrap_tick_range(x, self._wraprange)
if AutoFormatter._outside_tick_range(x, self._tickrange):
return ""
# Negative positive handling
x, tail = AutoFormatter._neg_pos_format(
x, self._negpos, wraprange=self._wraprange
)
# Default string formatting
decimal_point = AutoFormatter._get_default_decimal_point()
string = ("{:.%df}" % self._precision).format(x)
string = string.replace(".", decimal_point)
# Custom string formatting
string = AutoFormatter._minus_format(string)
if self._zerotrim:
string = AutoFormatter._trim_trailing_zeros(string, decimal_point)
# Prefix and suffix
string = AutoFormatter._add_prefix_suffix(string, self._prefix, self._suffix)
string = string + tail # add negative-positive indicator
return string
[docs]
class IndexFormatter(mticker.Formatter):
"""
Format numbers by assigning fixed strings to non-negative indices. Generally
paired with `IndexLocator` or `~matplotlib.ticker.FixedLocator`.
"""
# NOTE: This was deprecated in matplotlib 3.3. For details check out
# https://github.com/matplotlib/matplotlib/issues/16631 and bring some popcorn.
def __init__(self, labels):
self.labels = labels
self.n = len(labels)
[docs]
def __call__(self, x, pos=None): # noqa: U100
i = int(round(x))
if i < 0 or i >= self.n:
return ""
else:
return self.labels[i]
[docs]
class SciFormatter(mticker.Formatter):
"""
Format numbers with scientific notation.
"""
@docstring._snippet_manager
def __init__(self, precision=None, zerotrim=None):
"""
Parameters
----------
%(ticker.precision)s
%(ticker.zerotrim)s
See also
--------
ultraplot.constructor.Formatter
ultraplot.ticker.AutoFormatter
"""
precision, zerotrim = _default_precision_zerotrim(precision, zerotrim)
self._precision = precision
self._zerotrim = zerotrim
[docs]
@docstring._snippet_manager
def __call__(self, x, pos=None): # noqa: U100
"""
%(ticker.call)s
"""
# Get string
decimal_point = AutoFormatter._get_default_decimal_point()
string = ("{:.%de}" % self._precision).format(x)
parts = string.split("e")
# Trim trailing zeros
significand = parts[0].rstrip(decimal_point)
if self._zerotrim:
significand = AutoFormatter._trim_trailing_zeros(significand, decimal_point)
# Get sign and exponent
sign = parts[1][0].replace("+", "")
exponent = parts[1][1:].lstrip("0")
if exponent:
exponent = f"10^{{{sign}{exponent}}}"
if significand and exponent:
string = rf"{significand}{{\times}}{exponent}"
else:
string = rf"{significand}{exponent}"
# Ensure unicode minus sign
string = AutoFormatter._minus_format(string)
# Return TeX string
return f"${string}$"
[docs]
class SigFigFormatter(mticker.Formatter):
"""
Format numbers by retaining the specified number of significant digits.
"""
@docstring._snippet_manager
def __init__(self, sigfig=None, zerotrim=None, base=None):
"""
Parameters
----------
sigfig : float, default: 3
The number of significant digits.
%(ticker.zerotrim)s
base : float, default: 1
The base unit for rounding. For example ``SigFigFormatter(2, base=5)``
rounds to the nearest 5 with up to 2 digits (e.g., 87 --> 85, 8.7 --> 8.5).
See also
--------
ultraplot.constructor.Formatter
ultraplot.ticker.AutoFormatter
"""
self._sigfig = _not_none(sigfig, 3)
self._zerotrim = _not_none(zerotrim, rc["formatter.zerotrim"])
self._base = _not_none(base, 1)
[docs]
@docstring._snippet_manager
def __call__(self, x, pos=None): # noqa: U100
"""
%(ticker.call)s
"""
# Limit to significant figures
digits = AutoFormatter._decimal_place(x) + self._sigfig - 1
scale = self._base * 10**-digits
x = scale * round(x / scale)
# Create the string
decimal_point = AutoFormatter._get_default_decimal_point()
precision = max(0, digits) + max(0, AutoFormatter._decimal_place(self._base))
string = ("{:.%df}" % precision).format(x)
string = string.replace(".", decimal_point)
# Custom string formatting
string = AutoFormatter._minus_format(string)
if self._zerotrim:
string = AutoFormatter._trim_trailing_zeros(string, decimal_point)
return string
[docs]
class FracFormatter(mticker.Formatter):
r"""
Format numbers as integers or integer fractions. Optionally express the
values relative to some constant like `numpy.pi`.
"""
def __init__(self, symbol="", number=1):
r"""
Parameters
----------
symbol : str, default: ''
The constant symbol, e.g. ``r'$\pi$'``.
number : float, default: 1
The constant value, e.g. `numpy.pi`.
Note
----
The fractions shown by this formatter are resolved using the builtin
`fractions.Fraction` class and `fractions.Fraction.limit_denominator`.
See also
--------
ultraplot.constructor.Formatter
ultraplot.ticker.AutoFormatter
"""
self._symbol = symbol
self._number = number
super().__init__()
[docs]
@docstring._snippet_manager
def __call__(self, x, pos=None): # noqa: U100
"""
%(ticker.call)s
"""
frac = Fraction(x / self._number).limit_denominator()
symbol = self._symbol
if x == 0:
string = "0"
elif frac.denominator == 1: # denominator is one
if frac.numerator == 1 and symbol:
string = f"{symbol:s}"
elif frac.numerator == -1 and symbol:
string = f"-{symbol:s}"
else:
string = f"{frac.numerator:d}{symbol:s}"
else:
if frac.numerator == 1 and symbol: # numerator is +/-1
string = f"{symbol:s}/{frac.denominator:d}"
elif frac.numerator == -1 and symbol:
string = f"-{symbol:s}/{frac.denominator:d}"
else: # and again make sure we use unicode minus!
string = f"{frac.numerator:d}{symbol:s}/{frac.denominator:d}"
string = AutoFormatter._minus_format(string)
return string
[docs]
class CFDatetimeFormatter(mticker.Formatter):
"""
Format dates using `cftime.datetime.strftime` format strings.
"""
def __init__(self, fmt, calendar="standard", units="days since 2000-01-01"):
"""
Parameters
----------
fmt : str
The `strftime` format string.
calendar : str, default: 'standard'
The calendar for interpreting numeric tick values.
units : str, default: 'days since 2000-01-01'
The time units for interpreting numeric tick values.
"""
if cftime is None:
raise ModuleNotFoundError("cftime is required for CFDatetimeFormatter.")
self._format = fmt
self._calendar = calendar
self._units = units
[docs]
def __call__(self, x, pos=None):
if isinstance(x, cftime.datetime):
dt = x
else:
dt = cftime.num2date(x, self._units, calendar=self._calendar)
return dt.strftime(self._format)
[docs]
class AutoCFDatetimeFormatter(mticker.Formatter):
"""Automatic formatter for `cftime.datetime` data."""
def __init__(self, locator, calendar, time_units=None):
self.locator = locator
self.calendar = calendar
self.time_units = time_units or rc["cftime.time_unit"]
[docs]
def __call__(self, x, pos=0):
format_string = self.pick_format(self.locator.resolution)
if isinstance(x, cftime.datetime):
dt = x
else:
dt = cftime.num2date(x, self.time_units, calendar=self.calendar)
return dt.strftime(format_string)
[docs]
class AutoCFDatetimeLocator(mticker.Locator):
"""Determines tick locations when plotting `cftime.datetime` data."""
if cftime:
real_world_calendars = cftime._cftime._calendars
else:
real_world_calendars = ()
def __init__(self, maxticks=None, calendar="standard", date_unit=None, minticks=3):
super().__init__()
self.minticks = minticks
# These are thresholds for *determining resolution*, NOT directly for MaxNLocator tick count
self.maxticks = {
"YEARLY": 1,
"MONTHLY": 12,
"DAILY": 8,
"HOURLY": 11,
"MINUTELY": 1,
"SECONDLY": 11, # Added for completeness, though not a resolution threshold
}
if maxticks is not None:
if isinstance(maxticks, dict):
self.maxticks.update(maxticks)
else:
# If a single value is provided for maxticks, apply it to all resolution *thresholds*
self.maxticks = dict.fromkeys(self.maxticks.keys(), maxticks)
self.calendar = calendar
self.date_unit = date_unit or rc["cftime.time_unit"]
if not self.date_unit.lower().startswith("days since"):
emsg = "The date unit must be days since for a NetCDF time locator."
raise ValueError(emsg)
self.resolution = rc["cftime.resolution"]
self._cached_resolution = {}
self._max_display_ticks = rc["cftime.max_display_ticks"]
[docs]
def set_params(self, maxticks=None, minticks=None, max_display_ticks=None):
"""Set the parameters for the locator."""
if maxticks is not None:
if isinstance(maxticks, dict):
self.maxticks.update(maxticks)
else:
self.maxticks = dict.fromkeys(self.maxticks.keys(), maxticks)
if minticks is not None:
self.minticks = minticks
if max_display_ticks is not None:
self._max_display_ticks = max_display_ticks
[docs]
def compute_resolution(self, num1, num2, date1, date2):
"""Returns the resolution of the dates.
Also updates self.calendar from date1 for consistency.
"""
if isinstance(date1, cftime.datetime):
self.calendar = date1.calendar
# Assuming self.date_unit (e.g., "days since 0001-01-01") is a universal epoch.
num_days = float(np.abs(num1 - num2))
num_years = num_days / 365.0
num_months = num_days / 30.0
num_hours = num_days * 24.0
num_minutes = num_days * 60.0 * 24.0
if num_years > self.maxticks["YEARLY"]:
resolution = "YEARLY"
n = abs(date1.year - date2.year)
elif num_months > self.maxticks["MONTHLY"]:
resolution = "MONTHLY"
n = abs((date2.year - date1.year) * 12 + (date2.month - date1.month))
elif num_days > self.maxticks["DAILY"]:
resolution = "DAILY"
n = num_days
elif num_hours > self.maxticks["HOURLY"]:
resolution = "HOURLY"
n = num_hours
elif num_minutes > self.maxticks["MINUTELY"]:
resolution = "MINUTELY"
n = num_minutes
else:
resolution = "SECONDLY"
n = num_days * 24 * 3600
self.resolution = resolution
return resolution, n
[docs]
def __call__(self):
vmin, vmax = self.axis.get_view_interval()
return self.tick_values(vmin, vmax)
[docs]
def tick_values(self, vmin, vmax):
vmin, vmax = mtransforms.nonsingular(vmin, vmax, expander=1e-7, tiny=1e-13)
result = self._safe_num2date(vmin, vmax)
lower, upper = result if result else (None, None)
if lower is None or upper is None:
return np.array([]) # Return empty array if conversion fails
resolution, _ = self.compute_resolution(vmin, vmax, lower, upper)
ticks_cftime = []
if resolution == "YEARLY":
years_start = lower.year
years_end = upper.year
# Use MaxNLocator to find "nice" years to tick
year_candidates = mticker.MaxNLocator(
self._max_display_ticks, integer=True, prune="both"
).tick_values(years_start, years_end)
for year in year_candidates:
if (
year == 0 and self.calendar in self.real_world_calendars
): # Skip year 0 for real-world calendars
continue
try:
dt = cftime.datetime(int(year), 1, 1, calendar=self.calendar)
ticks_cftime.append(dt)
except (
ValueError
): # Catch potential errors for invalid dates in specific calendars
pass
elif resolution == "MONTHLY":
# Generate all first-of-month dates between lower and upper bounds
all_months_cftime = []
current_date = self._safe_create_datetime(lower.year, lower.month, 1)
end_date_limit = self._safe_create_datetime(upper.year, upper.month, 1)
if current_date is None or end_date_limit is None:
return np.array([]) # Return empty array if date creation fails
while current_date <= end_date_limit:
all_months_cftime.append(current_date)
# Increment month
if current_date.month == 12:
current_date = cftime.datetime(
current_date.year + 1, 1, 1, calendar=self.calendar
)
else:
current_date = cftime.datetime(
current_date.year,
current_date.month + 1,
1,
calendar=self.calendar,
)
# Select a reasonable number of these using a stride
num_all_months = len(all_months_cftime)
if num_all_months == 0:
pass
elif num_all_months <= self._max_display_ticks:
ticks_cftime = all_months_cftime
else:
stride = max(1, num_all_months // self._max_display_ticks)
ticks_cftime = all_months_cftime[::stride]
# Ensure first and last are included if not already
if (
all_months_cftime
and ticks_cftime
and ticks_cftime[0] != all_months_cftime[0]
):
ticks_cftime.insert(0, all_months_cftime[0])
if (
all_months_cftime
and ticks_cftime
and ticks_cftime[-1] != all_months_cftime[-1]
):
ticks_cftime.append(all_months_cftime[-1])
elif resolution == "DAILY":
# MaxNLocator_days works directly on the numeric values (days since epoch)
try:
days_numeric = self._safe_daily_locator(vmin, vmax)
if days_numeric is None:
return np.array([]) # Return empty array if locator fails
for dt_num in days_numeric:
tick = self._safe_num2date(dt_num)
if tick is not None:
ticks_cftime.append(tick)
except ValueError:
return np.array([]) # Return empty array if locator fails
elif resolution == "HOURLY":
current_dt_cftime = self._safe_create_datetime(
lower.year, lower.month, lower.day, lower.hour
)
if current_dt_cftime is None:
return np.array([]) # Return empty array if date creation fails
total_hours = (vmax - vmin) * 24.0
hour_step_candidates = [1, 2, 3, 4, 6, 8, 12]
hour_step = 1
if total_hours > 0:
hour_step = hour_step_candidates[
np.argmin(
np.abs(
np.array(hour_step_candidates)
- total_hours / self._max_display_ticks
)
)
]
if hour_step == 0:
hour_step = 1
while (
cftime.date2num(current_dt_cftime, self.date_unit, self.calendar) > vmin
):
current_dt_cftime += timedelta(hours=-hour_step)
if (
cftime.date2num(current_dt_cftime, self.date_unit, self.calendar)
< vmin - (vmax - vmin) * 2
):
current_dt_cftime = cftime.datetime(
lower.year,
lower.month,
lower.day,
lower.hour,
0,
0,
calendar=self.calendar,
) # Reset
break # Break if we overshot significantly
while (
cftime.date2num(current_dt_cftime, self.date_unit, self.calendar)
<= vmax + (vmax - vmin) * 0.01
): # Small buffer to include last tick
ticks_cftime.append(current_dt_cftime)
current_dt_cftime += timedelta(hours=hour_step)
if (
len(ticks_cftime) > 2 * self._max_display_ticks and hour_step != 0
): # Safety break to prevent too many ticks
break
elif resolution == "MINUTELY":
try:
current_dt_cftime = cftime.datetime(
lower.year,
lower.month,
lower.day,
lower.hour,
lower.minute,
0,
calendar=self.calendar,
)
except ValueError:
return np.array([]) # Return empty array if date creation fails
total_minutes = (vmax - vmin) * 24.0 * 60.0
minute_step_candidates = [1, 2, 5, 10, 15, 20, 30]
minute_step = 1
if total_minutes > 0:
minute_step = minute_step_candidates[
np.argmin(
np.abs(
np.array(minute_step_candidates)
- total_minutes / self._max_display_ticks
)
)
]
if minute_step == 0:
minute_step = 1
while (
cftime.date2num(current_dt_cftime, self.date_unit, self.calendar) > vmin
):
current_dt_cftime += timedelta(minutes=-minute_step)
if (
cftime.date2num(current_dt_cftime, self.date_unit, self.calendar)
< vmin - (vmax - vmin) * 2
):
try:
current_dt_cftime = cftime.datetime(
lower.year,
lower.month,
lower.day,
lower.hour,
lower.minute,
0,
calendar=self.calendar,
)
except ValueError:
return np.array([]) # Return empty array if date creation fails
break
while (
cftime.date2num(current_dt_cftime, self.date_unit, self.calendar)
<= vmax + (vmax - vmin) * 0.01
):
ticks_cftime.append(current_dt_cftime)
current_dt_cftime += timedelta(minutes=minute_step)
if len(ticks_cftime) > 2 * self._max_display_ticks and minute_step != 0:
break
elif resolution == "SECONDLY":
current_dt_cftime = self._safe_create_datetime(
lower.year,
lower.month,
lower.day,
lower.hour,
lower.minute,
lower.second,
)
if current_dt_cftime is None:
return np.array([]) # Return empty array if date creation fails
total_seconds = (vmax - vmin) * 24.0 * 60.0 * 60.0
second_step_candidates = [1, 2, 5, 10, 15, 20, 30]
second_step = 1
if total_seconds > 0:
second_step = second_step_candidates[
np.argmin(
np.abs(
np.array(second_step_candidates)
- total_seconds / self._max_display_ticks
)
)
]
if second_step == 0:
second_step = 1
while (
cftime.date2num(current_dt_cftime, self.date_unit, self.calendar) > vmin
):
current_dt_cftime += timedelta(seconds=-second_step)
if (
cftime.date2num(current_dt_cftime, self.date_unit, self.calendar)
< vmin - (vmax - vmin) * 2
):
current_dt_cftime = cftime.datetime(
lower.year,
lower.month,
lower.day,
lower.hour,
lower.minute,
lower.second,
calendar=self.calendar,
)
break
while (
cftime.date2num(current_dt_cftime, self.date_unit, self.calendar)
<= vmax + (vmax - vmin) * 0.01
):
ticks_cftime.append(current_dt_cftime)
current_dt_cftime += timedelta(
seconds=second_step
) # Uses standard library timedelta
if len(ticks_cftime) > 2 * self._max_display_ticks and second_step != 0:
break
else:
emsg = f"Resolution {resolution} not implemented yet."
raise ValueError(emsg)
if self.calendar in self.real_world_calendars:
# Filters out year 0 for calendars where it's not applicable
ticks_cftime = [t for t in ticks_cftime if t.year != 0]
# Convert the generated cftime.datetime objects back to numeric values
ticks_numeric = []
for t in ticks_cftime:
try:
num_val = cftime.date2num(t, self.date_unit, calendar=self.calendar)
if vmin <= num_val <= vmax: # Final filter for actual view interval
ticks_numeric.append(num_val)
except ValueError: # Handle potential issues with invalid cftime dates
pass
# Fallback: if no ticks are found (e.g., very short range or specific calendar issues),
# ensure at least two end points if vmin <= vmax
if not ticks_numeric and vmin <= vmax:
return np.array([vmin, vmax])
elif not ticks_numeric: # If vmin > vmax or other edge cases
return np.array([])
return np.unique(np.array(ticks_numeric))
def _safe_num2date(self, value, vmax=None):
"""
Safely converts numeric values to cftime.datetime objects.
If a single value is provided, it converts and returns a single datetime object.
If both value (vmin) and vmax are provided, it converts and returns a tuple of
datetime objects (lower, upper).
This helper is used to handle cases where the conversion might fail
due to invalid inputs or calendar-specific constraints. If the conversion
fails, it returns None or a tuple of Nones.
"""
try:
if vmax is not None:
lower = cftime.num2date(value, self.date_unit, calendar=self.calendar)
upper = cftime.num2date(vmax, self.date_unit, calendar=self.calendar)
return lower, upper
else:
return cftime.num2date(value, self.date_unit, calendar=self.calendar)
except ValueError:
return (None, None) if vmax is not None else None
def _safe_create_datetime(self, year, month=1, day=1, hour=0, minute=0, second=0):
"""
Safely creates a cftime.datetime object with the given date and time components.
This helper is used to handle cases where creating a datetime object might fail
due to invalid inputs (e.g., invalid dates in specific calendars). If the creation
fails, it returns None.
"""
try:
return cftime.datetime(
year, month, day, hour, minute, second, calendar=self.calendar
)
except ValueError:
return None
def _safe_daily_locator(self, vmin, vmax):
"""
Safely generates daily tick values using MaxNLocator.
This helper is used to handle cases where the locator might fail
due to invalid input ranges or other issues. If the locator fails,
it returns None.
"""
try:
return mticker.MaxNLocator(
self._max_display_ticks,
integer=True,
steps=[1, 2, 4, 7, 10],
prune="both",
).tick_values(vmin, vmax)
except ValueError:
return None
class _CartopyFormatter(object):
"""
Mixin class for cartopy formatters.
"""
# NOTE: Cartopy formatters pre 0.18 required axis, and *always* translated
# input values from map projection coordinates to Plate Carrée coordinates.
# After 0.18 you can avoid this behavior by not setting axis but really
# dislike that inconsistency. Solution is temporarily assign PlateCarre().
def __init__(self, *args, **kwargs):
import cartopy # noqa: F401 (ensure available)
super().__init__(*args, **kwargs)
def __call__(self, value, pos=None):
ctx = context._empty_context()
if self.axis is not None:
ctx = context._state_context(self.axis.axes, projection=ccrs.PlateCarree())
with ctx:
return super().__call__(value, pos)
[docs]
class DegreeFormatter(_CartopyFormatter, _PlateCarreeFormatter):
"""
Formatter for longitude and latitude gridline labels.
Adapted from cartopy.
"""
@docstring._snippet_manager
def __init__(self, *args, **kwargs):
"""
%(ticker.dms)s
"""
super().__init__(*args, **kwargs)
def _apply_transform(self, value, *args, **kwargs): # noqa: U100
return value
def _hemisphere(self, value, *args, **kwargs): # noqa: U100
return ""
[docs]
class LongitudeFormatter(_CartopyFormatter, LongitudeFormatter):
"""
Format longitude gridline labels. Adapted from
`cartopy.mpl.ticker.LongitudeFormatter` with support for
proper centering based on lon0.
"""
@docstring._snippet_manager
def __init__(self, lon0=0, *args, **kwargs):
"""
Parameters
----------
lon0 : float, optional
Central longitude value to use for centering the map.
Labels will be adjusted relative to this value.
%(ticker.dms)s
"""
self.lon0 = lon0
super().__init__(*args, **kwargs)
[docs]
class LatitudeFormatter(_CartopyFormatter, LatitudeFormatter):
"""
Format latitude gridline labels. Adapted from
`cartopy.mpl.ticker.LatitudeFormatter`.
"""
@docstring._snippet_manager
def __init__(self, *args, **kwargs):
"""
%(ticker.dms)s
"""
super().__init__(*args, **kwargs)
class CFTimeConverter(mdates.DateConverter):
"""
Converter for cftime.datetime data.
"""
@staticmethod
def axisinfo(unit, axis):
"""Returns the :class:`~matplotlib.units.AxisInfo` for *unit*."""
if cftime is None:
raise ModuleNotFoundError("cftime is required for CFTimeConverter.")
if unit is None:
calendar, date_unit, date_type = (
"standard",
rc["cftime.time_unit"],
getattr(cftime, "DatetimeProlepticGregorian", None),
)
else:
calendar, date_unit, date_type = unit
majloc = AutoCFDatetimeLocator(calendar=calendar, date_unit=date_unit)
majfmt = AutoCFDatetimeFormatter(
majloc, calendar=calendar, time_units=date_unit
)
year = datetime.now().year
if date_type is not None:
datemin = date_type(year - 1, 1, 1)
datemax = date_type(year, 1, 1)
else:
# Fallback if date_type is None
datemin = cftime.datetime(year - 1, 1, 1, calendar="standard")
datemax = cftime.datetime(year, 1, 1, calendar="standard")
return munits.AxisInfo(
majloc=majloc,
majfmt=majfmt,
label="",
default_limits=(datemin, datemax),
)
@classmethod
def default_units(cls, x, axis):
"""Computes some units for the given data point."""
if isinstance(x, np.ndarray) and x.dtype != object:
return None # It's already numeric
if hasattr(x, "__iter__") and not isinstance(x, str):
if isinstance(x, np.ndarray):
x = x.reshape(-1)
first_value = next(
(v for v in x if v is not None and v is not np.ma.masked), None
)
if first_value is None:
return None
if not isinstance(first_value, cftime.datetime):
return None
# Check all calendars are the same
calendar = first_value.calendar
if any(
getattr(v, "calendar", None) != calendar
for v in x
if v is not None and v is not np.ma.masked
):
raise ValueError("Calendar units are not all equal.")
date_type = type(first_value)
else:
# scalar
if not isinstance(x, cftime.datetime):
return None
calendar = x.calendar
date_type = type(x)
if not calendar:
raise ValueError(
"A calendar must be defined to plot dates using a cftime axis."
)
return calendar, rc["cftime.time_unit"], date_type
@classmethod
def convert(cls, value, unit, axis):
"""Converts value with :py:func:`cftime.date2num`."""
if cftime is None:
raise ModuleNotFoundError("cftime is required for CFTimeConverter.")
if isinstance(value, (int, float, np.number)):
return value
if isinstance(value, np.ndarray) and value.dtype != object:
return value
# Get calendar from unit, or from data
if unit:
calendar, date_unit, _ = unit
else:
if hasattr(value, "__iter__") and not isinstance(value, str):
first_value = next(
(v for v in value if v is not None and v is not np.ma.masked), None
)
else:
first_value = value
if first_value is None or not isinstance(first_value, cftime.datetime):
return value # Cannot convert
calendar = first_value.calendar
date_unit = rc["cftime.time_unit"]
result = cftime.date2num(value, date_unit, calendar=calendar)
return result
if cftime is not None:
if cftime.datetime not in munits.registry:
munits.registry[cftime.datetime] = CFTimeConverter()