Skip to content

Commit 07646d0

Browse files
authored
Merge pull request #1615 from MichaelTiemannOSC/parse-uncertainties
This commit allows to parse uncertain numbers e.g. (1.0+/-0.2)e+03 Enable Pint to consume uncertain quantities. Signed-off-by: [email protected] * Fix problems identified by python -m pre_commit run --all-files Signed-off-by: MichaelTiemann <[email protected]> * Enhance support for `uncertainties`. See #1611, #1614. Signed-off-by: MichaelTiemann <[email protected]> * Fix up failures and errors found by test suite. Signed-off-by: MichaelTiemann <[email protected]> * Copy in changes from PR1596 Signed-off-by: [email protected] * Create modular uncertainty parser layer Based on feedback, tokenize uncertainties on top of default tokenizer, not instead of default tokenizer. Signed-off-by: MichaelTiemann <[email protected]> * Fix conflict merge error Signed-off-by: Michael Tiemann <[email protected]> * Update util.py Fixes problems parsing currency symbols that also show up when dealing with uncertainties. Signed-off-by: Michael Tiemann <[email protected]> * Update pint_eval.py Handle negative numbers using uncertainty parenthesis notation. Signed-off-by: Michael Tiemann <[email protected]> * Update pint_eval.py Ahem...use walrus operator for side-effect, not truth value. Signed-off-by: Michael Tiemann <[email protected]> * Fixed to work with both + and - e notation in the actually processing of the exponent, not just in the parsing of the exponent. i.e., (5.01+/-0.07)e+04 Signed-off-by: Michael Tiemann <[email protected]> * Fix test suite failures Manually fix test_issue_1400. Let other failures (which are not related to uncertainties) fail. Signed-off-by: Michael Tiemann <[email protected]> * Fix tokenizer merge error in pint/util.py When using pint_eval.tokenizer don't try to import tokenizer from pint.compat. Signed-off-by: Michael Tiemann <[email protected]> * Merge cleanup: pint_eval.py needs tokenize Clean up merge import error. Signed-off-by: Michael Tiemann <[email protected]> * Make black happier Run `black` with default arguments to try to match whatever `black` wants to see in the CI/CD world. Signed-off-by: Michael Tiemann <[email protected]> * Make ruff happy Remove unused redefinition of tokenizer in toktest.py. Also remove unnecessary import of pint_eval from top-level (it's imported inside the function definition that needs it). Signed-off-by: Michael Tiemann <[email protected]> * Make ruff happier Fix ruff errors missed in previous commit. Signed-off-by: Michael Tiemann <[email protected]> * Update toktest.py Fix whitespace error created by `ruff --fix` that `black` didn't like. Signed-off-by: Michael Tiemann <[email protected]> * Update test_util.py Follow deprecation of use_decimal from pint/util.py Signed-off-by: Michael Tiemann <[email protected]> * Fix additional regressions in test suite If we have the uncertainties library loaded, go ahead and use the uncertainty_tokenizer by default. This fixes problems with standard Pandas tests that expect the tokenizer to do the right thing without any special setup. Also, prevent exception when a loop in consensus_name_attr (pandas-dev/pandas/core/common.py(86))) tests equality with a None argument. Otherwise the zero_or_nan test raises an exception. Signed-off-by: Michael Tiemann <[email protected]> * Update quantity.py Teach Pint's PlainQuantity about the Pandas pd.NA value so that ndim works. Otherwise, it naively delegates to NumpyQuantity, which is the road to perdition for PintArrays. Signed-off-by: Michael Tiemann <[email protected]> * Make `babel` a dependency for testbase Here's hoping this fixes the CI/CD problem with test_1400. Signed-off-by: Michael Tiemann <[email protected]> * Update .readthedocs.yaml Removing `system_packages: false` as suggested by @keewis Signed-off-by: Michael Tiemann <[email protected]> * Fix failing tests Fix isnan to use unp.isnan as appropriate for both duck_array_type and objects of UFloat types. Fix a minor typo in pint/facets/__init__.py comment. In test_issue_1400, use decorators to ensure babel library is loaded when needed. pyproject.toml: revert change to testbase; we fixed with decorators instead. Signed-off-by: Michael Tiemann <[email protected]> --------- Signed-off-by: [email protected] Signed-off-by: MichaelTiemann <[email protected]> Signed-off-by: Michael Tiemann <[email protected]>
2 parents 2852f36 + 00f08f3 commit 07646d0

File tree

16 files changed

+446
-51
lines changed

16 files changed

+446
-51
lines changed

.readthedocs.yaml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,3 @@ python:
1111
- requirements: requirements_docs.txt
1212
- method: pip
1313
path: .
14-
system_packages: false

CHANGES

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,12 @@ Pint Changelog
105105
(Issue #1030, #574)
106106
- Added angular frequency documentation page.
107107
- Move ASV benchmarks to dedicated folder. (Issue #1542)
108+
- An ndim attribute has been added to Quantity and DataFrame has been added to upcast
109+
types for pint-pandas compatibility. (#1596)
110+
- Fix a recursion error that would be raised when passing quantities to `cond` and `x`.
111+
(Issue #1510, #1530)
112+
- Update test_non_int tests for pytest.
113+
- Better support for uncertainties (See #1611, #1614)
108114
- Implement `numpy.broadcast_arrays` (#1607)
109115
- An ndim attribute has been added to Quantity and DataFrame has been added to upcast
110116
types for pint-pandas compatibility. (#1596)

pint/compat.py

Lines changed: 36 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,21 @@
1212

1313
import sys
1414
import math
15-
import tokenize
1615
from decimal import Decimal
1716
from importlib import import_module
18-
from io import BytesIO
1917
from numbers import Number
2018
from collections.abc import Mapping
2119
from typing import Any, NoReturn, Callable, Optional, Union
22-
from collections.abc import Generator, Iterable
20+
from collections.abc import Iterable
21+
22+
try:
23+
from uncertainties import UFloat, ufloat
24+
from uncertainties import unumpy as unp
25+
26+
HAS_UNCERTAINTIES = True
27+
except ImportError:
28+
UFloat = ufloat = unp = None
29+
HAS_UNCERTAINTIES = False
2330

2431

2532
if sys.version_info >= (3, 10):
@@ -58,19 +65,6 @@ def _inner(*args: Any, **kwargs: Any) -> NoReturn:
5865
return _inner
5966

6067

61-
def tokenizer(input_string: str) -> Generator[tokenize.TokenInfo, None, None]:
62-
"""Tokenize an input string, encoded as UTF-8
63-
and skipping the ENCODING token.
64-
65-
See Also
66-
--------
67-
tokenize.tokenize
68-
"""
69-
for tokinfo in tokenize.tokenize(BytesIO(input_string.encode("utf-8")).readline):
70-
if tokinfo.type != tokenize.ENCODING:
71-
yield tokinfo
72-
73-
7468
# TODO: remove this warning after v0.10
7569
class BehaviorChangeWarning(UserWarning):
7670
pass
@@ -83,7 +77,10 @@ class BehaviorChangeWarning(UserWarning):
8377

8478
HAS_NUMPY = True
8579
NUMPY_VER = np.__version__
86-
NUMERIC_TYPES = (Number, Decimal, ndarray, np.number)
80+
if HAS_UNCERTAINTIES:
81+
NUMERIC_TYPES = (Number, Decimal, ndarray, np.number, UFloat)
82+
else:
83+
NUMERIC_TYPES = (Number, Decimal, ndarray, np.number)
8784

8885
def _to_magnitude(value, force_ndarray=False, force_ndarray_like=False):
8986
if isinstance(value, (dict, bool)) or value is None:
@@ -92,6 +89,11 @@ def _to_magnitude(value, force_ndarray=False, force_ndarray_like=False):
9289
raise ValueError("Quantity magnitude cannot be an empty string.")
9390
elif isinstance(value, (list, tuple)):
9491
return np.asarray(value)
92+
elif HAS_UNCERTAINTIES:
93+
from pint.facets.measurement.objects import Measurement
94+
95+
if isinstance(value, Measurement):
96+
return ufloat(value.value, value.error)
9597
if force_ndarray or (
9698
force_ndarray_like and not is_duck_array_type(type(value))
9799
):
@@ -144,16 +146,13 @@ def _to_magnitude(value, force_ndarray=False, force_ndarray_like=False):
144146
"lists and tuples are valid magnitudes for "
145147
"Quantity only when NumPy is present."
146148
)
147-
return value
149+
elif HAS_UNCERTAINTIES:
150+
from pint.facets.measurement.objects import Measurement
148151

152+
if isinstance(value, Measurement):
153+
return ufloat(value.value, value.error)
154+
return value
149155

150-
try:
151-
from uncertainties import ufloat
152-
153-
HAS_UNCERTAINTIES = True
154-
except ImportError:
155-
ufloat = None
156-
HAS_UNCERTAINTIES = False
157156

158157
try:
159158
from babel import Locale
@@ -326,16 +325,25 @@ def isnan(obj: Any, check_all: bool) -> Union[bool, Iterable[bool]]:
326325
Always return False for non-numeric types.
327326
"""
328327
if is_duck_array_type(type(obj)):
329-
if obj.dtype.kind in "if":
328+
if obj.dtype.kind in "ifc":
330329
out = np.isnan(obj)
331330
elif obj.dtype.kind in "Mm":
332331
out = np.isnat(obj)
333332
else:
334-
# Not a numeric or datetime type
335-
out = np.full(obj.shape, False)
333+
if HAS_UNCERTAINTIES:
334+
try:
335+
out = unp.isnan(obj)
336+
except TypeError:
337+
# Not a numeric or UFloat type
338+
out = np.full(obj.shape, False)
339+
else:
340+
# Not a numeric or datetime type
341+
out = np.full(obj.shape, False)
336342
return out.any() if check_all else out
337343
if isinstance(obj, np_datetime64):
338344
return np.isnat(obj)
345+
elif HAS_UNCERTAINTIES and isinstance(obj, UFloat):
346+
return unp.isnan(obj)
339347
try:
340348
return math.isnan(obj)
341349
except TypeError:

pint/facets/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
keeping each part small enough to be hackable.
88
99
Each facet contains one or more of the following modules:
10-
- definitions: classes describing an specific unit related definiton.
10+
- definitions: classes describing specific unit-related definitons.
1111
These objects must be immutable, pickable and not reference the registry (e.g. ContextDefinition)
1212
- objects: classes and functions that encapsulate behavior (e.g. Context)
1313
- registry: implements a subclass of PlainRegistry or class that can be

pint/facets/measurement/objects.py

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ class Measurement(PlainQuantity):
5252
5353
"""
5454

55-
def __new__(cls, value, error, units=MISSING):
55+
def __new__(cls, value, error=MISSING, units=MISSING):
5656
if units is MISSING:
5757
try:
5858
value, units = value.magnitude, value.units
@@ -64,17 +64,18 @@ def __new__(cls, value, error, units=MISSING):
6464
error = MISSING # used for check below
6565
else:
6666
units = ""
67-
try:
68-
error = error.to(units).magnitude
69-
except AttributeError:
70-
pass
71-
7267
if error is MISSING:
68+
# We've already extracted the units from the Quantity above
7369
mag = value
74-
elif error < 0:
75-
raise ValueError("The magnitude of the error cannot be negative")
7670
else:
77-
mag = ufloat(value, error)
71+
try:
72+
error = error.to(units).magnitude
73+
except AttributeError:
74+
pass
75+
if error < 0:
76+
raise ValueError("The magnitude of the error cannot be negative")
77+
else:
78+
mag = ufloat(value, error)
7879

7980
inst = super().__new__(cls, mag, units)
8081
return inst

pint/facets/numpy/quantity.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,16 @@
2929
set_units_ufuncs,
3030
)
3131

32+
try:
33+
import uncertainties.unumpy as unp
34+
from uncertainties import ufloat, UFloat
35+
36+
HAS_UNCERTAINTIES = True
37+
except ImportError:
38+
unp = np
39+
ufloat = Ufloat = None
40+
HAS_UNCERTAINTIES = False
41+
3242

3343
def method_wraps(numpy_func):
3444
if isinstance(numpy_func, str):
@@ -224,6 +234,11 @@ def __getattr__(self, item) -> Any:
224234
)
225235
else:
226236
raise exc
237+
elif (
238+
HAS_UNCERTAINTIES and item == "ndim" and isinstance(self._magnitude, UFloat)
239+
):
240+
# Dimensionality of a single UFloat is 0, like any other scalar
241+
return 0
227242

228243
try:
229244
return getattr(self._magnitude, item)

pint/facets/plain/quantity.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,17 @@
5555
if HAS_NUMPY:
5656
import numpy as np # noqa
5757

58+
try:
59+
import uncertainties.unumpy as unp
60+
from uncertainties import ufloat, UFloat
61+
62+
HAS_UNCERTAINTIES = True
63+
except ImportError:
64+
unp = np
65+
ufloat = Ufloat = None
66+
HAS_UNCERTAINTIES = False
67+
68+
5869
MagnitudeT = TypeVar("MagnitudeT", bound=Magnitude)
5970
ScalarT = TypeVar("ScalarT", bound=Scalar)
6071

@@ -133,6 +144,8 @@ class PlainQuantity(Generic[MagnitudeT], PrettyIPython, SharedRegistryObject):
133144
def ndim(self) -> int:
134145
if isinstance(self.magnitude, numbers.Number):
135146
return 0
147+
if str(self.magnitude) == "<NA>":
148+
return 0
136149
return self.magnitude.ndim
137150

138151
@property
@@ -256,7 +269,12 @@ def __bytes__(self) -> bytes:
256269
return str(self).encode(locale.getpreferredencoding())
257270

258271
def __repr__(self) -> str:
259-
if isinstance(self._magnitude, float):
272+
if HAS_UNCERTAINTIES:
273+
if isinstance(self._magnitude, UFloat):
274+
return f"<Quantity({self._magnitude:.6}, '{self._units}')>"
275+
else:
276+
return f"<Quantity({self._magnitude}, '{self._units}')>"
277+
elif isinstance(self._magnitude, float):
260278
return f"<Quantity({self._magnitude:.9}, '{self._units}')>"
261279

262280
return f"<Quantity({self._magnitude}, '{self._units}')>"
@@ -1288,6 +1306,9 @@ def bool_result(value):
12881306
# We compare to the plain class of PlainQuantity because
12891307
# each PlainQuantity class is unique.
12901308
if not isinstance(other, PlainQuantity):
1309+
if other is None:
1310+
# A loop in pandas-dev/pandas/core/common.py(86)consensus_name_attr() can result in OTHER being None
1311+
return bool_result(False)
12911312
if zero_or_nan(other, True):
12921313
# Handle the special case in which we compare to zero or NaN
12931314
# (or an array of zeros or NaNs)

pint/facets/plain/registry.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,9 @@
6363
Handler,
6464
)
6565

66+
from ... import pint_eval
6667
from ..._vendor import appdirs
67-
from ...compat import babel_parse, tokenizer, TypeAlias, Self
68+
from ...compat import babel_parse, TypeAlias, Self
6869
from ...errors import DimensionalityError, RedefinitionError, UndefinedUnitError
6970
from ...pint_eval import build_eval_tree
7071
from ...util import ParserHelper
@@ -1324,7 +1325,7 @@ def parse_expression(
13241325
for p in self.preprocessors:
13251326
input_string = p(input_string)
13261327
input_string = string_preprocessor(input_string)
1327-
gen = tokenizer(input_string)
1328+
gen = pint_eval.tokenizer(input_string)
13281329

13291330
def _define_op(s: str):
13301331
return self._eval_token(s, case_sensitive=case_sensitive, **values)

pint/formatting.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -375,9 +375,13 @@ def formatter(
375375
# Don't remove this positional! This is the format used in Babel
376376
key = pat.replace("{0}", "").strip()
377377
break
378-
division_fmt = compound_unit_patterns.get("per", {}).get(
379-
babel_length, division_fmt
380-
)
378+
379+
tmp = compound_unit_patterns.get("per", {}).get(babel_length, division_fmt)
380+
381+
try:
382+
division_fmt = tmp.get("compound", division_fmt)
383+
except AttributeError:
384+
division_fmt = tmp
381385
power_fmt = "{}{}"
382386
exp_call = _pretty_fmt_exponent
383387
if value == 1:

0 commit comments

Comments
 (0)