Skip to content

Commit 44ef351

Browse files
authored
custom unit formatter (#278)
1 parent f139fb8 commit 44ef351

File tree

5 files changed

+106
-28
lines changed

5 files changed

+106
-28
lines changed

cf_xarray/tests/test_units.py

Lines changed: 35 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -8,53 +8,68 @@
88

99
pytest.importorskip("pint")
1010

11-
from ..units import units
11+
from ..units import units as ureg
1212

1313

1414
def test_added_degrees_units():
1515
"""Test that our added degrees units are present in the registry."""
1616
# Test equivalence of abbreviations/aliases to our defined names
17-
assert str(units("degrees_N").units) == "degrees_north"
18-
assert str(units("degreesN").units) == "degrees_north"
19-
assert str(units("degree_north").units) == "degrees_north"
20-
assert str(units("degree_N").units) == "degrees_north"
21-
assert str(units("degreeN").units) == "degrees_north"
22-
assert str(units("degrees_E").units) == "degrees_east"
23-
assert str(units("degreesE").units) == "degrees_east"
24-
assert str(units("degree_east").units) == "degrees_east"
25-
assert str(units("degree_E").units) == "degrees_east"
26-
assert str(units("degreeE").units) == "degrees_east"
17+
assert str(ureg("degrees_N").units) == "degrees_north"
18+
assert str(ureg("degreesN").units) == "degrees_north"
19+
assert str(ureg("degree_north").units) == "degrees_north"
20+
assert str(ureg("degree_N").units) == "degrees_north"
21+
assert str(ureg("degreeN").units) == "degrees_north"
22+
assert str(ureg("degrees_E").units) == "degrees_east"
23+
assert str(ureg("degreesE").units) == "degrees_east"
24+
assert str(ureg("degree_east").units) == "degrees_east"
25+
assert str(ureg("degree_E").units) == "degrees_east"
26+
assert str(ureg("degreeE").units) == "degrees_east"
2727

2828
# Test equivalence of our defined units to base units
29-
assert units("degrees_north") == units("degrees")
30-
assert units("degrees_north").to_base_units().units == units.radian
31-
assert units("degrees_east") == units("degrees")
32-
assert units("degrees_east").to_base_units().units == units.radian
29+
assert ureg("degrees_north") == ureg("degrees")
30+
assert ureg("degrees_north").to_base_units().units == ureg.radian
31+
assert ureg("degrees_east") == ureg("degrees")
32+
assert ureg("degrees_east").to_base_units().units == ureg.radian
3333

3434

3535
def test_gpm_unit():
3636
"""Test that the gpm unit does alias to meters."""
37-
x = 1 * units("gpm")
37+
x = 1 * ureg("gpm")
3838
assert str(x.units) == "meter"
3939

4040

4141
def test_psu_unit():
4242
"""Test that the psu unit are present in the registry."""
43-
x = 1 * units("psu")
43+
x = 1 * ureg("psu")
4444
assert str(x.units) == "practical_salinity_unit"
4545

4646

4747
def test_percent_units():
4848
"""Test that percent sign units are properly parsed and interpreted."""
49-
assert str(units("%").units) == "percent"
49+
assert str(ureg("%").units) == "percent"
5050

5151

5252
@pytest.mark.xfail(reason="not supported by pint, yet: hgrecco/pint#1295")
5353
def test_udunits_power_syntax():
5454
"""Test that UDUNITS style powers are properly parsed and interpreted."""
55-
assert units("m2 s-2").units == units.m ** 2 / units.s ** 2
55+
assert ureg("m2 s-2").units == ureg.m ** 2 / ureg.s ** 2
5656

5757

5858
def test_udunits_power_syntax_parse_units():
5959
"""Test that UDUNITS style powers are properly parsed and interpreted."""
60-
assert units.parse_units("m2 s-2") == units.m ** 2 / units.s ** 2
60+
assert ureg.parse_units("m2 s-2") == ureg.m ** 2 / ureg.s ** 2
61+
62+
63+
@pytest.mark.parametrize(
64+
["units", "expected"],
65+
(
66+
("kg ** 2", "kg2"),
67+
("m ** -1", "m-1"),
68+
("m ** 2 / s ** 2", "m2 s-2"),
69+
("m ** 3 / (kg * s ** 2)", "m3 kg-1 s-2"),
70+
),
71+
)
72+
def test_udunits_format(units, expected):
73+
u = ureg.parse_units(units)
74+
75+
assert f"{u:cf}" == expected

cf_xarray/units.py

Lines changed: 55 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,4 @@
1-
r"""Module to provide unit support via pint approximating UDUNITS/CF.
2-
3-
Reused with modification from MetPy under the terms of the BSD 3-Clause License.
4-
Copyright (c) 2015,2017,2019 MetPy Developers.
5-
"""
1+
"""Module to provide unit support via pint approximating UDUNITS/CF."""
62
import functools
73
import re
84
import warnings
@@ -14,6 +10,59 @@
1410
UnitStrippedWarning,
1511
)
1612

13+
# from `xclim`'s unit support module with permission of the maintainers
14+
try:
15+
16+
@pint.register_unit_format("cf")
17+
def short_formatter(unit, registry, **options):
18+
"""Return a CF-compliant unit string from a `pint` unit.
19+
20+
Parameters
21+
----------
22+
unit : pint.UnitContainer
23+
Input unit.
24+
registry : pint.UnitRegistry
25+
the associated registry
26+
**options
27+
Additional options (may be ignored)
28+
29+
Returns
30+
-------
31+
out : str
32+
Units following CF-Convention, using symbols.
33+
"""
34+
import re
35+
36+
# convert UnitContainer back to Unit
37+
unit = registry.Unit(unit)
38+
# Print units using abbreviations (millimeter -> mm)
39+
s = f"{unit:~D}"
40+
41+
# Search and replace patterns
42+
pat = r"(?P<inverse>(?:1 )?/ )?(?P<unit>\w+)(?: \*\* (?P<pow>\d))?"
43+
44+
def repl(m):
45+
i, u, p = m.groups()
46+
p = p or (1 if i else "")
47+
neg = "-" if i else ""
48+
49+
return f"{u}{neg}{p}"
50+
51+
out, n = re.subn(pat, repl, s)
52+
53+
# Remove multiplications
54+
out = out.replace(" * ", " ")
55+
# Delta degrees:
56+
out = out.replace("Δ°", "delta_deg")
57+
return out.replace("percent", "%")
58+
59+
60+
except ImportError:
61+
pass
62+
63+
64+
# Reused with modification from MetPy under the terms of the BSD 3-Clause License.
65+
# Copyright (c) 2015,2017,2019 MetPy Developers.
1766
# Create registry, with preprocessors for UDUNITS-style powers (m2 s-2) and percent signs
1867
units = pint.UnitRegistry(
1968
autoconvert_offset_to_baseunit=True,
@@ -51,8 +100,7 @@
51100
"Import(s) unavailable to set up matplotlib support...skipping this portion "
52101
"of the setup."
53102
)
103+
# end of vendored code from MetPy
54104

55105
# Set as application registry
56106
pint.set_application_registry(units)
57-
58-
del pint

ci/doc.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ dependencies:
1717
- ipywidgets
1818
- pandas
1919
- pooch
20+
- pint
2021
- pip:
2122
- git+https://github.com/xarray-contrib/cf-xarray
2223
- myst_nb

doc/units.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,15 @@ kernelspec:
1111

1212
The xarray ecosystem supports unit-aware arrays using [pint](https://pint.readthedocs.io) and [pint-xarray](https://pint-xarray.readthedocs.io). Some changes are required to make these packages work well with [UDUNITS format recommended by the CF conventions](http://cfconventions.org/Data/cf-conventions/cf-conventions-1.8/cf-conventions.html#units).
1313

14-
`cf_xarray` makes those recommended changes when you `import cf_xarray.units`. These changes allow pint to parse UDUNIT units strings, and add several custom units like `degrees_north`, `psu` etc.
14+
`cf_xarray` makes those recommended changes when you `import cf_xarray.units`. These changes allow pint to parse and format UDUNIT units strings, and add several custom units like `degrees_north`, `psu` etc.
15+
16+
## Formatting units
17+
18+
For now, only the short format using [symbols](https://www.unidata.ucar.edu/software/udunits/udunits-2.2.28/udunits2lib.html#Syntax) is supported:
19+
```{code-cell}
20+
from pint import application_registry as ureg
21+
import cf_xarray.units
22+
23+
u = ureg.Unit("m ** 3 / s ** 2")
24+
f"{u:cf}"
25+
```

doc/whats-new.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
What's New
44
----------
5+
v0.6.4 (unreleased)
6+
===================
7+
- integrate ``xclim``'s CF-compliant unit formatter. :pr:`278`. By `Justus Magin`_.
58

69
v0.6.3 (December 16, 2021)
710
==========================

0 commit comments

Comments
 (0)