Completed
Pull Request — master (#377)
by Ryan
01:32
created

_check_argument_units()   B

Complexity

Conditions 6

Size

Total Lines 20

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 6
c 1
b 0
f 0
dl 0
loc 20
rs 8
1
# Copyright (c) 2008-2016 MetPy Developers.
2
# Distributed under the terms of the BSD 3-Clause License.
3
# SPDX-License-Identifier: BSD-3-Clause
4
r"""Module to provide unit support.
5
6
This makes use of the :mod:`pint` library and sets up the default settings
7
for good temperature support.
8
9
Attributes
10
----------
11
units : :class:`pint.UnitRegistry`
12
    The unit registry used throughout the package. Any use of units in MetPy should
13
    import this registry and use it to grab units.
14
"""
15
16
from __future__ import division
17
18
import functools
19
20
import matplotlib.units as munits
21
import numpy as np
22
import pint
23
import pint.unit
24
25
from .cbook import iterable
26
27
UndefinedUnitError = pint.UndefinedUnitError
28
DimensionalityError = pint.DimensionalityError
29
30
units = pint.UnitRegistry(autoconvert_offset_to_baseunit=True)
31
32
# For pint 0.6, this is the best way to define a dimensionless unit. See pint #185
33
units.define(pint.unit.UnitDefinition('percent', '%', (),
34
             pint.converters.ScaleConverter(0.01)))
35
36
37
def concatenate(arrs, axis=0):
38
    r"""Concatenate multiple values into a new unitized object.
39
40
    This is essentially a unit-aware version of `numpy.concatenate`. All items
41
    must be able to be converted to the same units. If an item has no units, it will be given
42
    those of the rest of the collection, without conversion. The first units found in the
43
    arguments is used as the final output units.
44
45
    Parameters
46
    ----------
47
    arrs : Sequence of arrays
48
        The items to be joined together
49
50
    axis : integer, optional
51
        The array axis along which to join the arrays. Defaults to 0 (the first dimension)
52
53
    Returns
54
    -------
55
    `pint.Quantity`
56
        New container with the value passed in and units corresponding to the first item.
57
    """
58
    dest = 'dimensionless'
59
    for a in arrs:
60
        if hasattr(a, 'units'):
61
            dest = a.units
62
            break
63
64
    data = []
65
    for a in arrs:
66
        if hasattr(a, 'to'):
67
            a = a.to(dest).magnitude
68
        data.append(np.atleast_1d(a))
69
70
    return units.Quantity(np.concatenate(data, axis=axis), dest)
71
72
73
def atleast_1d(*arrs):
74
    r"""Convert inputs to arrays with at least one dimension.
75
76
    Scalars are converted to 1-dimensional arrays, whilst other
77
    higher-dimensional inputs are preserved. This is a thin wrapper
78
    around `numpy.atleast_1d` to preserve units.
79
80
    Parameters
81
    ----------
82
    arrs : arbitrary positional arguments
83
        Input arrays to be converted if necessary
84
85
    Returns
86
    -------
87
    `pint.Quantity`
88
        A single quantity or a list of quantities, matching the number of inputs.
89
    """
90
    mags = [a.magnitude for a in arrs]
91
    orig_units = [a.units for a in arrs]
92
    ret = np.atleast_1d(*mags)
93
    if len(mags) == 1:
94
        return units.Quantity(ret, orig_units[0])
95
    return [units.Quantity(m, u) for m, u in zip(ret, orig_units)]
96
97
98
def atleast_2d(*arrs):
99
    r"""Convert inputs to arrays with at least two dimensions.
100
101
    Scalars and 1-dimensional arrays are converted to 2-dimensional arrays,
102
    whilst other higher-dimensional inputs are preserved. This is a thin wrapper
103
    around `numpy.atleast_2d` to preserve units.
104
105
    Parameters
106
    ----------
107
    arrs : arbitrary positional arguments
108
        Input arrays to be converted if necessary
109
110
    Returns
111
    -------
112
    `pint.Quantity`
113
        A single quantity or a list of quantities, matching the number of inputs.
114
    """
115
    mags = [a.magnitude for a in arrs]
116
    orig_units = [a.units for a in arrs]
117
    ret = np.atleast_2d(*mags)
118
    if len(mags) == 1:
119
        return units.Quantity(ret, orig_units[0])
120
    return [units.Quantity(m, u) for m, u in zip(ret, orig_units)]
121
122
123
def masked_array(data, data_units=None, **kwargs):
124
    """Create a :class:`numpy.ma.MaskedArray` with units attached.
125
126
    This is a thin wrapper around :func:`numpy.ma.masked_array` that ensures that
127
    units are properly attached to the result (otherwise units are silently lost). Units
128
    are taken from the ``units`` argument, or if this is ``None``, the units on ``data``
129
    are used.
130
131
    Parameters
132
    ----------
133
    data : array_like
134
        The source data. If ``units`` is `None`, this should be a `pint.Quantity` with
135
        the desired units.
136
    data_units : str or `pint.Unit`
137
        The units for the resulting `pint.Quantity`
138
    **kwargs : Arbitrary keyword arguments passed to `numpy.ma.masked_array`
139
140
    Returns
141
    -------
142
    `pint.Quantity`
143
    """
144
    if data_units is None:
145
        data_units = data.units
146
    return units.Quantity(np.ma.masked_array(data, **kwargs), data_units)
147
148
149
def _check_argument_units(args, dimensionality):
150
    """Yield arguments with improper dimensionality."""
151
    for arg, val in args.items():
152
        # Get the needed dimensionality (for printing) as well as cached, parsed version
153
        # for this argument.
154
        try:
155
            need, parsed = dimensionality[arg]
156
        except KeyError:
157
            # Argument did not have units specified in decorator
158
            continue
159
160
        # See if the value passed in is appropriate
161
        try:
162
            if val.dimensionality != parsed:
163
                yield arg, val.units, need
164
        # No dimensionality
165
        except AttributeError:
166
            # If this argument is dimensionless, don't worry
167
            if parsed != '':
168
                yield arg, 'none', need
169
170
171
def check_units(*units_by_pos, **units_by_name):
172
    """Create a decorator to check units of function arguments."""
173
    try:
174
        from inspect import signature
175
176
        def dec(func):
177
            # Match the signature of the function to the arguments given to the decorator
178
            sig = signature(func)
179
            bound_units = sig.bind_partial(*units_by_pos, **units_by_name)
180
181
            # Convert our specified dimensionality (e.g. "[pressure]") to one used by
182
            # pint directly (e.g. "[mass] / [length] / [time]**2). This is for both efficiency
183
            # reasons and to ensure that problems with the decorator are caught at import,
184
            # rather than runtime.
185
            dims = {name: (orig, units.get_dimensionality(orig.replace('dimensionless', '')))
186
                    for name, orig in bound_units.arguments.items()}
187
188
            @functools.wraps(func)
189
            def wrapper(*args, **kwargs):
190
                # Match all passed in value to their proper arguments so we can check units
191
                bound_args = sig.bind(*args, **kwargs)
192
                bad = list(_check_argument_units(bound_args.arguments, dims))
193
194
                # If there are any bad units, emit a proper error message making it clear
195
                # what went wrong.
196
                if bad:
197
                    msg = '`{0}` given arguments with incorrect units: {1}.'.format(
198
                        func.__name__,
199
                        ', '.join('`{}` requires "{}" but given "{}"'.format(arg, req, given)
200
                                  for arg, given, req in bad))
201
                    if 'none' in msg:
202
                        msg += ('\nAny variable `x` can be assigned a unit as follows:\n'
203
                                '    from metpy.units import units\n'
204
                                '    x = x * units.meter / units.second')
205
                    raise ValueError(msg)
206
                return func(*args, **kwargs)
207
208
            return wrapper
209
210
    # signature() only available on Python >= 3.3, so for 2.7 we just do nothing.
211
    except ImportError:
212
        def dec(func):
213
            return func
214
215
    return dec
216
217
218
class PintConverter(munits.ConversionInterface):
219
    """Implement support for pint within matplotlib's unit conversion framework."""
220
221
    @staticmethod
222
    def convert(value, unit, axis):
223
        """Convert pint :`Quantity` instances for matplotlib to use.
224
225
        Currently only strips off the units to avoid matplotlib errors since we can't reliably
226
        have pint.Quantity instances not decay to numpy arrays.
227
        """
228
        if hasattr(value, 'magnitude'):
229
            return value.magnitude
230
        elif iterable(value):
231
            try:
232
                return [v.magnitude for v in value]
233
            except AttributeError:
234
                return value
235
        else:
236
            return value
237
238
    # TODO: Once we get things properly squared away between pint and everything else
239
    # these will need to be functional.
240
    # @staticmethod
241
    # def axisinfo(unit, axis):
242
    #     return None
243
    #
244
    # @staticmethod
245
    # def default_units(x, axis):
246
    #     return x.to_base_units()
247
248
249
# Register the class
250
munits.registry[units.Quantity] = PintConverter()
251
252
del munits, pint
253