Skip to content

[WIP] Add the ability for unit converters to convert back to data with units #12270

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 5 commits into from
Closed
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
7 changes: 7 additions & 0 deletions lib/matplotlib/axis.py
Original file line number Diff line number Diff line change
Expand Up @@ -1576,6 +1576,13 @@ def convert_units(self, x):
f'units: {x!r}') from e
return ret

# Uncomment this in 3.5 when Converters are enforced to have an
# un_convert() method
"""
def unconvert_units(self, x):
return self.converter.un_convert(x, self.units, self)
"""

def set_units(self, u):
"""
Set the units for axis.
Expand Down
39 changes: 32 additions & 7 deletions lib/matplotlib/dates.py
Original file line number Diff line number Diff line change
Expand Up @@ -1873,15 +1873,13 @@ def weeks(w):
return w * DAYS_PER_WEEK


class DateConverter(units.ConversionInterface):
class BaseDateConverter(units.ConversionInterface):
"""
Converter for datetime.date and datetime.datetime data,
or for date/time data represented as it would be converted
by :func:`date2num`.
A base converter for datetime.date and datetime.datetime data, or for
date/time data represented as it would be converted by :func:`date2num`.

The 'unit' tag for such data is None or a tzinfo instance.
"""

@staticmethod
def axisinfo(unit, axis):
"""
Expand Down Expand Up @@ -1930,6 +1928,33 @@ def default_units(x, axis):
return None


class DateConverter(BaseDateConverter):
"""
A converter for `datetime.date` data.
"""
@staticmethod
def un_convert(value, unit, axis):
return num2date(value)


class Datetime64Converter(BaseDateConverter):
"""
A converter for `numpy.datetime64` data.
"""
@staticmethod
def un_convert(value, unit, axis):
return np.datetime64(num2date(value).replace(tzinfo=None))


class DatetimeConverter(BaseDateConverter):
"""
A converter for `datetime.datetime` data.
"""
@staticmethod
def un_convert(value, unit, axis):
return num2date(value)


class ConciseDateConverter(DateConverter):
"""
Converter for datetime.date and datetime.datetime data,
Expand Down Expand Up @@ -1968,6 +1993,6 @@ def axisinfo(self, unit, axis):
default_limits=(datemin, datemax))


units.registry[np.datetime64] = DateConverter()
units.registry[np.datetime64] = Datetime64Converter()
units.registry[datetime.date] = DateConverter()
units.registry[datetime.datetime] = DateConverter()
units.registry[datetime.datetime] = DatetimeConverter()
30 changes: 24 additions & 6 deletions lib/matplotlib/tests/test_axes.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,10 @@ def test_matshow():
])
def test_formatter_ticker():
import matplotlib.testing.jpl_units as units
units.register()
# Catch warnings thrown whilst jpl unit converters don't have an
# un_convert() method
with pytest.warns(Warning, match='does not define an un_convert'):
units.register()

# This should affect the tick size. (Tests issue #543)
matplotlib.rcParams['lines.markeredgewidth'] = 30
Expand Down Expand Up @@ -486,7 +489,10 @@ def test_polar_alignment():
def test_fill_units():
from datetime import datetime
import matplotlib.testing.jpl_units as units
units.register()
# Catch warnings thrown whilst jpl unit converters don't have an
# un_convert() method
with pytest.warns(Warning, match='does not define an un_convert'):
units.register()

# generate some data
t = units.Epoch("ET", dt=datetime(2009, 4, 27))
Expand Down Expand Up @@ -654,7 +660,10 @@ def test_polar_wrap(fig_test, fig_ref):
@check_figures_equal()
def test_polar_units_1(fig_test, fig_ref):
import matplotlib.testing.jpl_units as units
units.register()
# Catch warnings thrown whilst jpl unit converters don't have an
# un_convert() method
with pytest.warns(Warning, match='does not define an un_convert'):
units.register()
xs = [30.0, 45.0, 60.0, 90.0]
ys = [1.0, 2.0, 3.0, 4.0]

Expand All @@ -669,7 +678,10 @@ def test_polar_units_1(fig_test, fig_ref):
@check_figures_equal()
def test_polar_units_2(fig_test, fig_ref):
import matplotlib.testing.jpl_units as units
units.register()
# Catch warnings thrown whilst jpl unit converters don't have an
# un_convert() method
with pytest.warns(Warning, match='does not define an un_convert'):
units.register()
xs = [30.0, 45.0, 60.0, 90.0]
xs_deg = [x * units.deg for x in xs]
ys = [1.0, 2.0, 3.0, 4.0]
Expand Down Expand Up @@ -838,7 +850,10 @@ def test_aitoff_proj():
def test_axvspan_epoch():
from datetime import datetime
import matplotlib.testing.jpl_units as units
units.register()
# Catch warnings thrown whilst jpl unit converters don't have an
# un_convert() method
with pytest.warns(Warning, match='does not define an un_convert'):
units.register()

# generate some data
t0 = units.Epoch("ET", dt=datetime(2009, 1, 20))
Expand All @@ -854,7 +869,10 @@ def test_axvspan_epoch():
def test_axhspan_epoch():
from datetime import datetime
import matplotlib.testing.jpl_units as units
units.register()
# Catch warnings thrown whilst jpl unit converters don't have an
# un_convert() method
with pytest.warns(Warning, match='does not define an un_convert'):
units.register()

# generate some data
t0 = units.Epoch("ET", dt=datetime(2009, 1, 20))
Expand Down
10 changes: 8 additions & 2 deletions lib/matplotlib/tests/test_dates.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,10 @@ def test_too_many_date_ticks(caplog):
@image_comparison(['RRuleLocator_bounds.png'])
def test_RRuleLocator():
import matplotlib.testing.jpl_units as units
units.register()
# Catch warnings thrown whilst jpl unit converters don't have an
# un_convert() method
with pytest.warns(Warning, match='does not define an un_convert'):
units.register()

# This will cause the RRuleLocator to go out of bounds when it tries
# to add padding to the limits, so we make sure it caps at the correct
Expand Down Expand Up @@ -198,7 +201,10 @@ def test_RRuleLocator_dayrange():
@image_comparison(['DateFormatter_fractionalSeconds.png'])
def test_DateFormatter():
import matplotlib.testing.jpl_units as units
units.register()
# Catch warnings thrown whilst jpl unit converters don't have an
# un_convert() method
with pytest.warns(Warning, match='does not define an un_convert'):
units.register()

# Lets make sure that DateFormatter will allow us to have tick marks
# at intervals of fractional seconds.
Expand Down
5 changes: 4 additions & 1 deletion lib/matplotlib/tests/test_patches.py
Original file line number Diff line number Diff line change
Expand Up @@ -367,7 +367,10 @@ def test_multi_color_hatch():
@image_comparison(['units_rectangle.png'])
def test_units_rectangle():
import matplotlib.testing.jpl_units as U
U.register()
# Catch warnings thrown whilst jpl unit converters don't have an
# un_convert() method
with pytest.warns(Warning, match='does not define an un_convert'):
U.register()

p = mpatches.Rectangle((5*U.km, 6*U.km), 1*U.km, 2*U.km)

Expand Down
29 changes: 27 additions & 2 deletions lib/matplotlib/tests/test_units.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from unittest.mock import MagicMock

import matplotlib.pyplot as plt
from matplotlib import cbook
from matplotlib.testing.decorators import check_figures_equal, image_comparison
import matplotlib.units as munits
import numpy as np
Expand Down Expand Up @@ -56,6 +57,9 @@ def convert(value, unit, axis):
else:
return Quantity(value, axis.get_units()).to(unit).magnitude

def un_convert(value, unit, axis):
return Quantity(value, unit)

def default_units(value, axis):
if hasattr(value, 'units'):
return value.units
Expand All @@ -68,6 +72,7 @@ def default_units(value, axis):
qc.convert = MagicMock(side_effect=convert)
qc.axisinfo = MagicMock(side_effect=lambda u, a: munits.AxisInfo(label=u))
qc.default_units = MagicMock(side_effect=default_units)
qc.un_convert = MagicMock(side_effect=un_convert)
return qc


Expand Down Expand Up @@ -124,7 +129,10 @@ def test_empty_set_limits_with_units(quantity_converter):
savefig_kwarg={'dpi': 120}, style='mpl20')
def test_jpl_bar_units():
import matplotlib.testing.jpl_units as units
units.register()
# Catch warnings thrown whilst jpl unit converters don't have an
# un_convert() method
with pytest.warns(Warning, match='does not define an un_convert'):
units.register()

day = units.Duration("ET", 24.0 * 60.0 * 60.0)
x = [0*units.km, 1*units.km, 2*units.km]
Expand All @@ -140,7 +148,10 @@ def test_jpl_bar_units():
savefig_kwarg={'dpi': 120}, style='mpl20')
def test_jpl_barh_units():
import matplotlib.testing.jpl_units as units
units.register()
# Catch warnings thrown whilst jpl unit converters don't have an
# un_convert() method
with pytest.warns(Warning, match='does not define an un_convert'):
units.register()

day = units.Duration("ET", 24.0 * 60.0 * 60.0)
x = [0*units.km, 1*units.km, 2*units.km]
Expand Down Expand Up @@ -175,3 +186,17 @@ class subdate(datetime):

fig_test.subplots().plot(subdate(2000, 1, 1), 0, "o")
fig_ref.subplots().plot(datetime(2000, 1, 1), 0, "o")


def test_no_conveter_warnings():
class Converter(munits.ConversionInterface):
pass

# Check that a converter without a manuallly defined convert() method
# warns
with pytest.warns(cbook.deprecation.MatplotlibDeprecationWarning):
Converter.convert(0, 0, 0)

# Check that manually defining a conveter doesn't warn
Converter.convert = lambda obj, unit, axis: obj
Converter.convert(0, 0, 0)
40 changes: 40 additions & 0 deletions lib/matplotlib/units.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ def convert(value, unit, axis):
'Convert a datetime value to a scalar or array'
return dates.date2num(value)

@staticmethod
def un_convert(value, unit, axis):
'Convert a float back to a datetime value'
return dates.num2date(value)

@staticmethod
def axisinfo(unit, axis):
'Return major and minor tick locators and formatters'
Expand All @@ -44,6 +49,7 @@ def default_units(x, axis):

from decimal import Decimal
from numbers import Number
import warnings

import numpy as np
from numpy import ma
Expand Down Expand Up @@ -127,6 +133,7 @@ def default_units(x, axis):
"""
return None

# Make this an abstractmethod in 3.5
@staticmethod
def convert(obj, unit, axis):
"""
Expand All @@ -135,8 +142,26 @@ def convert(obj, unit, axis):
If *obj* is a sequence, return the converted sequence. The output must
be a sequence of scalars that can be used by the numpy array layer.
"""
cbook.warn_deprecated(
'3.3',
message=('Using the default "does nothing" convert() method for '
'Matplotlib ConversionInterface converters is deprecated '
'and will raise an error in version 3.5. '
'Please manually override convert().'))
return obj

# Uncomment this in version 3.5 to enforce an un_convert() method
'''
@staticmethod
@abc.abstractmethod
def un_convert(data, unit, axis):
"""
Convert data that has already been converted back to its original
value.
"""
pass
'''

@staticmethod
def is_numlike(x):
"""
Expand Down Expand Up @@ -180,6 +205,13 @@ def convert(value, unit, axis):
converter = ma.asarray
return converter(value, dtype=np.float)

@staticmethod
def un_convert(value, unit, axis):
"""
Un-convert from floats to Decimals.
"""
return Decimal(value)

@staticmethod
def axisinfo(unit, axis):
# Since Decimal is a kind of Number, don't need specific axisinfo.
Expand All @@ -194,6 +226,14 @@ def default_units(x, axis):
class Registry(dict):
"""Register types with conversion interface."""

def __setitem__(self, cls, converter):
if not hasattr(converter, 'un_convert'):
warnings.warn(
f'{converter.__class__.__name__} does not define an '
'un_convert() method. From Matplotlib 3.5 this will be '
'required, and if not present will raise an error.')
super().__setitem__(cls, converter)

def get_converter(self, x):
"""Get the converter interface instance for *x*, or None."""
if hasattr(x, "values"):
Expand Down