Skip to content
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

Allowed special case for unit conversion of precipitation (kg m-2 s-1 <--> mm day-1) #1574

Merged
merged 8 commits into from
Jun 20, 2022
1 change: 1 addition & 0 deletions doc/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,7 @@

# Configuration for intersphinx
intersphinx_mapping = {
'cf_units': ('https://cf-units.readthedocs.io/en/latest/', None),
'esmvalcore':
(f'https://docs.esmvaltool.org/projects/esmvalcore/en/{rtd_version}/',
None),
Expand Down
38 changes: 27 additions & 11 deletions doc/recipe/preprocessor.rst
Original file line number Diff line number Diff line change
Expand Up @@ -1921,20 +1921,36 @@ See also :func:`esmvalcore.preprocessor.detrend`.
Unit conversion
===============

Converting units is also supported. This is particularly useful in
cases where different datasets might have different units, for example
when comparing CMIP5 and CMIP6 variables where the units have changed
or in case of observational datasets that are delivered in different
units.

Converting units is also supported.
This is particularly useful in cases where different datasets might have
different units, for example when comparing CMIP5 and CMIP6 variables where the
units have changed or in case of observational datasets that are delivered in
different units.
In these cases, having a unit conversion at the end of the processing
will guarantee homogeneous input for the diagnostics.

.. note::
Conversion is only supported between compatible units! In other
words, converting temperature units from ``degC`` to ``Kelvin`` works
fine, changing precipitation units from a rate based unit to an
amount based unit is not supported at the moment.
Conversion is only supported between compatible units!
In other words, converting temperature units from ``degC`` to ``Kelvin`` works
fine, while changing units from ``kg`` to ``m`` will not work.

However, there are some well-defined exceptions from this rule for specific
variables (i.e., the ``short_name`` of the corresponding data needs to match
the ones given in the table below or the pattern given in brackets needs to be
present in the data's ``standard_name`` or ``long_name``):

==================== ==================== ================= =================
Variable Expected Expected source Expected target
``short_name`` units units
(or pattern)
==================== ==================== ================= =================
Precipitation flux ``pr`` ``kg m-2 s-1`` ``mm day-1``
(``precipitation``)
==================== ==================== ================= =================


.. hint::
Source or target units convertible to the ones given in the table above are
also supported.

See also :func:`esmvalcore.preprocessor.convert_units`.

Expand Down
62 changes: 60 additions & 2 deletions esmvalcore/preprocessor/_units.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
"""
import logging

from cf_units import Unit

logger = logging.getLogger(__name__)


Expand All @@ -14,18 +16,74 @@ def convert_units(cube, units):

This converts units of a cube.

Note
----
Allows some special unit conversions which are usually not allowed by
:mod:`cf_units` for certain variables (based on matching ``var_name`` or
patterns in ``standard_name`` or ``long_name``, see brackets below). Note
that any other units convertible to the ones given below also work, e.g.,
for precipitation ``[kg m-2 yr-1]`` --> ``[m s-1]`` would also work:

- Variable ``pr`` (``precipitation``): ``[kg m-2 s-1]`` --> ``[mm
day-1]``

For precipitation variables, a water density of 1000 kg m-3 is assumed.

Arguments
---------
cube: iris.cube.Cube
input cube

units: str
new units in udunits form

Returns
-------
iris.cube.Cube
converted cube.

"""
cube.convert_units(units)
# Dictionary containing special cases
# Dictionary keys: (expected var_name, expected pattern which needs to
# appear in standard_name or long_name)
# Dictionary values: (special source units, special target units) --> both
# need to be "identical", e.g., 1 kg m-2 s-1 "equals" 1
# mm s-1 for precipitation
special_cases = {
('pr', 'precipitation'): ('kg m-2 s-1', 'mm s-1'),
}
try:
cube.convert_units(units)
except ValueError:
var_name = '' if cube.var_name is None else cube.var_name
std_name = '' if cube.standard_name is None else cube.standard_name
long_name = '' if cube.long_name is None else cube.long_name
for (special_names, special_units) in special_cases.items():
# Special unit conversion only works if all of the following
# criteria are met:
# - the units in the cubes are convertible to the special source
# units
# - the target units given by the user are convertible to the
# special target units
# - the cube has the correct names, i.e., either the cube's
# var_name matches the expected var_name or the expected pattern
# appears in the cube's standard_name or long_name
is_special_case = (
cube.units.is_convertible(special_units[0]) and
Unit(units).is_convertible(special_units[1]) and
any([
var_name == special_names[0],
special_names[1] in std_name.lower(),
special_names[1] in long_name.lower(),
])
)
if is_special_case:
cube.convert_units(special_units[0])
cube.units = special_units[1]
cube.convert_units(units)
break

# If no special case has been detected, raise the original error
else:
raise

return cube
50 changes: 50 additions & 0 deletions tests/unit/preprocessor/_units/test_convert_units.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import cf_units
import iris
import iris.fileformats
import numpy as np

import tests
Expand Down Expand Up @@ -43,6 +44,55 @@ def test_convert_compatible_units(self):
self.assertEqual(result.units, expected_units)
self.assert_array_equal(result.data, expected_data)

def test_convert_special_pr_from_var_name(self):
"""Test special conversion of pr."""
self.arr.var_name = 'pr'
self.arr.units = 'kg m-2 s-1'
result = convert_units(self.arr, 'mm day-1')
self.assertEqual(result.units, 'mm day-1')
np.testing.assert_allclose(
result.data,
[[0.0, 86400.0], [172800.0, 259200.0]],
)

def test_convert_special_pr_from_standard_name(self):
"""Test special conversion of pr."""
self.arr.standard_name = 'precipitation_flux'
self.arr.units = 'g m-2 s-1'
result = convert_units(self.arr, 'mm day-1')
self.assertEqual(result.units, 'mm day-1')
np.testing.assert_allclose(
result.data,
[[0.0, 86.4], [172.8, 259.200]],
)

def test_convert_special_pr_from_long_name(self):
"""Test special conversion of pr."""
self.arr.long_name = 'Convective Precipitation Flux'
self.arr.units = 'g m-2 yr-1'
result = convert_units(self.arr, 'm yr-1')
self.assertEqual(result.units, 'm yr-1')
np.testing.assert_allclose(
result.data,
[[0.0, 1.0e-6], [2.0e-6, 3.0e-6]],
)

def test_convert_special_pr_fail_invalid_name(self):
"""Test special conversion of pr."""
self.arr.units = 'kg m-2 s-1'
self.assertRaises(ValueError, convert_units, self.arr, 'mm day-1')

def test_convert_special_pr_fail_invalid_source_units(self):
"""Test special conversion of pr."""
self.arr.var_name = 'pr'
self.assertRaises(ValueError, convert_units, self.arr, 'mm day-1')

def test_convert_special_pr_fail_invalid_target_units(self):
"""Test special conversion of pr."""
self.arr.var_name = 'pr'
self.arr.units = 'kg m-2 s-1'
self.assertRaises(ValueError, convert_units, self.arr, 'K')


if __name__ == '__main__':
unittest.main()