From ee11725198a3d46df70136685f378dc9886ee289 Mon Sep 17 00:00:00 2001 From: Pascal Bourgault Date: Fri, 10 Jan 2025 15:02:15 -0500 Subject: [PATCH 1/4] Clean added units, publish registry to pint --- src/xclim/core/dataflags.py | 3 +-- src/xclim/core/units.py | 24 ++++++++++++------------ src/xclim/indices/_threshold.py | 6 +++--- src/xclim/indices/fire/_cffwis.py | 2 +- src/xclim/indices/fire/_ffdi.py | 2 +- tests/test_indices.py | 6 +++--- tests/test_units.py | 13 ++----------- 7 files changed, 23 insertions(+), 33 deletions(-) diff --git a/src/xclim/core/dataflags.py b/src/xclim/core/dataflags.py index eb574b91d..54bcba821 100644 --- a/src/xclim/core/dataflags.py +++ b/src/xclim/core/dataflags.py @@ -8,7 +8,6 @@ from __future__ import annotations from collections.abc import Callable, Sequence -from decimal import Decimal from functools import reduce from inspect import signature @@ -674,7 +673,7 @@ def _get_variable_name(function, _kwargs): format_args[arg] = "array" else: val = str2pint(val).magnitude - if Decimal(val) % 1 == 0: + if val == int(val): val = str(int(val)) else: val = str(val).replace(".", "point") diff --git a/src/xclim/core/units.py b/src/xclim/core/units.py index 2bdb233a4..db2797de7 100644 --- a/src/xclim/core/units.py +++ b/src/xclim/core/units.py @@ -64,28 +64,26 @@ units = deepcopy(cf_xarray.units.units) # Changing the default string format for units/quantities. # CF is implemented by cf-xarray, g is the most versatile float format. -# The following try/except logic can be removed when xclim drops support numpy <2.0. +# The following try/except logic can be removed when xclim drops support for pint < 0.24 (i.e. numpy <2.0). try: units.formatter.default_format = "gcf" except UndefinedUnitError: units.default_format = "gcf" -# Switch this flag back to False. Not sure what that implies, but it breaks some tests. -units.force_ndarray_like = False # noqa: F841 -# Another alias not included by cf_xarray -units.define("@alias percent = pct") -# Default context. -null = pint.Context("none") -units.add_context(null) +# CF-xarray forces numpy arrays even for scalar values, not sure why. +# We don't want that in xclim, the magnitude of a scalar is a scalar (float). +units.force_ndarray_like = False # Precipitation units. This is an artificial unit that we're using to verify that a given unit can be converted into -# a precipitation unit. Ideally this could be checked through the `dimensionality`, but I can't get it to work. +# a precipitation unit. It is essentially added for convenience when writing `declare_units` decorators. units.define("[precipitation] = [mass] / [length] ** 2 / [time]") -units.define("mmday = 1 kg / meter ** 2 / day") - units.define("[discharge] = [length] ** 3 / [time]") -units.define("cms = meter ** 3 / second") +# Default context. This is essentially a convenience, so that we can pass a context systemtically to pint's methods. +null = pint.Context("none") +units.add_context(null) + +# Convenience context for common transformation involving liquid water hydro = pint.Context("hydro") hydro.add_transformation( "[mass] / [length]**2", @@ -109,6 +107,8 @@ ) units.add_context(hydro) +# Set as application registry +pint.set_application_registry(units) with (files("xclim.data") / "variables.yml").open() as variables: CF_CONVERSIONS = safe_load(variables)["conversions"] diff --git a/src/xclim/indices/_threshold.py b/src/xclim/indices/_threshold.py index 9f11be29c..90f4c6f30 100644 --- a/src/xclim/indices/_threshold.py +++ b/src/xclim/indices/_threshold.py @@ -2958,7 +2958,7 @@ def maximum_consecutive_tx_days( @declare_units(siconc="[]", areacello="[area]", thresh="[]") def sea_ice_area( - siconc: xarray.DataArray, areacello: xarray.DataArray, thresh: Quantified = "15 pct" + siconc: xarray.DataArray, areacello: xarray.DataArray, thresh: Quantified = "15 %" ) -> xarray.DataArray: """ Total sea ice area. @@ -2989,7 +2989,7 @@ def sea_ice_area( "What is the difference between sea ice area and extent?" - :cite:cts:`nsidc_frequently_2008` """ t = convert_units_to(thresh, siconc) - factor = convert_units_to("100 pct", siconc) + factor = convert_units_to("100 %", siconc) sia = xarray.dot(siconc.where(siconc >= t, 0), areacello) / factor sia = sia.assign_attrs(units=areacello.units) return sia @@ -2997,7 +2997,7 @@ def sea_ice_area( @declare_units(siconc="[]", areacello="[area]", thresh="[]") def sea_ice_extent( - siconc: xarray.DataArray, areacello: xarray.DataArray, thresh: Quantified = "15 pct" + siconc: xarray.DataArray, areacello: xarray.DataArray, thresh: Quantified = "15 %" ) -> xarray.DataArray: """ Total sea ice extent. diff --git a/src/xclim/indices/fire/_cffwis.py b/src/xclim/indices/fire/_cffwis.py index e59dc2cb6..bb2c3a0c6 100644 --- a/src/xclim/indices/fire/_cffwis.py +++ b/src/xclim/indices/fire/_cffwis.py @@ -1397,7 +1397,7 @@ def cffwis_indices( tas = convert_units_to(tas, "C") pr = convert_units_to(pr, "mm/day") sfcWind = convert_units_to(sfcWind, "km/h") - hurs = convert_units_to(hurs, "pct") + hurs = convert_units_to(hurs, "%") if snd is not None: snd = convert_units_to(snd, "m") diff --git a/src/xclim/indices/fire/_ffdi.py b/src/xclim/indices/fire/_ffdi.py index 5c06ee0b2..f8ba04bb1 100644 --- a/src/xclim/indices/fire/_ffdi.py +++ b/src/xclim/indices/fire/_ffdi.py @@ -394,7 +394,7 @@ def mcarthur_forest_fire_danger_index( :cite:cts:`ffdi-noble_1980,ffdi-dowdy_2018,ffdi-holgate_2017` """ tasmax = convert_units_to(tasmax, "C") - hurs = convert_units_to(hurs, "pct") + hurs = convert_units_to(hurs, "%") sfcWind = convert_units_to(sfcWind, "km/h") ffdi = drought_factor**0.987 * np.exp( diff --git a/tests/test_indices.py b/tests/test_indices.py index 22978b49d..3f19466c5 100644 --- a/tests/test_indices.py +++ b/tests/test_indices.py @@ -3255,19 +3255,19 @@ def test_simple(self): np.arange(4).reshape(1, 2, 2), dims=["time", "lat", "lon"], coords={"time": [1], "lat": [-45, 45], "lon": [30, 60]}, - attrs={"units": "mmday"}, + attrs={"units": "mm/day"}, ) tas_baseline = xr.DataArray( np.arange(4).reshape(1, 2, 2), dims=["time", "lat", "lon"], coords={"time": [1], "lat": [-45, 45], "lon": [30, 60]}, - attrs={"units": "C"}, + attrs={"units": "degC"}, ) tas_future = xr.DataArray( np.arange(40).reshape(10, 2, 2), dims=["time_fut", "lat", "lon"], coords={"time_fut": np.arange(10), "lat": [-45, 45], "lon": [30, 60]}, - attrs={"units": "C"}, + attrs={"units": "degC"}, ) delta_tas = tas_future - tas_baseline delta_tas.attrs["units"] = "delta_degC" diff --git a/tests/test_units.py b/tests/test_units.py index a0781ad4e..9307673df 100644 --- a/tests/test_units.py +++ b/tests/test_units.py @@ -43,7 +43,6 @@ def test_hydro(self): with units.context("hydro"): q = 1 * units.kg / units.m**2 / units.s assert q.to("mm/day") == q.to("mm/d") - assert q.to("mmday").magnitude == 24 * 60**2 def test_lat_lon(self): assert 100 * units.degreeN == 100 * units.degree @@ -58,16 +57,13 @@ def test_dimensionality(self): with units.context("hydro"): fu = 1 * units.parse_units("kg / m**2 / s") tu = 1 * units.parse_units("mm / d") - fu.to("mmday") - tu.to("mmday") + fu.to("mm/day") + tu.to("mm/day") def test_fraction(self): q = 5 * units.percent assert q.to("dimensionless") == 0.05 - q = 5 * units.parse_units("pct") - assert q.to("dimensionless") == 0.05 - class TestConvertUnitsTo: def test_deprecation(self, tas_series): @@ -135,9 +131,6 @@ def test_pint2cfunits(self): u = units("percent") assert pint2cfunits(u.units) == "%" - u = units("pct") - assert pint2cfunits(u.units) == "%" - def test_units2pint(self, pr_series): u = units2pint(pr_series([1, 2])) assert pint2cfunits(u) == "kg m-2 s-1" @@ -168,11 +161,9 @@ def test_str2pint(self): class TestCheckUnits: def test_basic(self): check_units("%", "[]") - check_units("pct", "[]") check_units("mm/day", "[precipitation]") check_units("mm/s", "[precipitation]") check_units("kg/m2/s", "[precipitation]") - check_units("cms", "[discharge]") check_units("m3/s", "[discharge]") check_units("m/s", "[speed]") check_units("km/h", "[speed]") From 954bf20c805d0c36b98d7c914bedb6f87ba1fd1f Mon Sep 17 00:00:00 2001 From: Pascal Bourgault Date: Fri, 10 Jan 2025 16:44:11 -0500 Subject: [PATCH 2/4] remove unneeded try-except - ensure cf units are written to cf attributes --- src/xclim/core/indicator.py | 2 +- src/xclim/core/units.py | 17 ++++------------- tests/test_indicators.py | 8 ++++---- 3 files changed, 9 insertions(+), 18 deletions(-) diff --git a/src/xclim/core/indicator.py b/src/xclim/core/indicator.py index 7d417e2e1..37543fec6 100644 --- a/src/xclim/core/indicator.py +++ b/src/xclim/core/indicator.py @@ -1311,7 +1311,7 @@ def _format( # Add formatting {} around values to be able to replace them with _attrs_mapping using format. for k, v in args.items(): if isinstance(v, units.Quantity): - mba[k] = f"{v:g~P}" + mba[k] = f"{v:gcf}" elif isinstance(v, int | float): mba[k] = f"{v:g}" # TODO: What about InputKind.NUMBER_SEQUENCE diff --git a/src/xclim/core/units.py b/src/xclim/core/units.py index db2797de7..036835d3e 100644 --- a/src/xclim/core/units.py +++ b/src/xclim/core/units.py @@ -22,7 +22,6 @@ import pint import xarray as xr from boltons.funcutils import wraps -from pint import UndefinedUnitError from yaml import safe_load from xclim.core._exceptions import ValidationError @@ -64,20 +63,15 @@ units = deepcopy(cf_xarray.units.units) # Changing the default string format for units/quantities. # CF is implemented by cf-xarray, g is the most versatile float format. -# The following try/except logic can be removed when xclim drops support for pint < 0.24 (i.e. numpy <2.0). -try: - units.formatter.default_format = "gcf" -except UndefinedUnitError: - units.default_format = "gcf" - +units.formatter.default_format = "gcf" # CF-xarray forces numpy arrays even for scalar values, not sure why. # We don't want that in xclim, the magnitude of a scalar is a scalar (float). units.force_ndarray_like = False -# Precipitation units. This is an artificial unit that we're using to verify that a given unit can be converted into -# a precipitation unit. It is essentially added for convenience when writing `declare_units` decorators. +# Define dimensionalities for convenience with the `declare_units` decorator units.define("[precipitation] = [mass] / [length] ** 2 / [time]") units.define("[discharge] = [length] ** 3 / [time]") +units.define("[radiation] = [power] / [length]**2") # Default context. This is essentially a convenience, so that we can pass a context systemtically to pint's methods. null = pint.Context("none") @@ -110,6 +104,7 @@ # Set as application registry pint.set_application_registry(units) + with (files("xclim.data") / "variables.yml").open() as variables: CF_CONVERSIONS = safe_load(variables)["conversions"] _CONVERSIONS = {} @@ -136,10 +131,6 @@ def _func_register(func: Callable) -> Callable: return _func_register -# Radiation units -units.define("[radiation] = [power] / [length]**2") - - def units2pint( value: xr.DataArray | units.Unit | units.Quantity | dict | str, ) -> pint.Unit: diff --git a/tests/test_indicators.py b/tests/test_indicators.py index 8df70f4dd..20f71fdac 100644 --- a/tests/test_indicators.py +++ b/tests/test_indicators.py @@ -564,18 +564,18 @@ def test_formatting(pr_series): # pint 0.10 now pretty print day as d. assert ( out.attrs["long_name"] - == "Number of days with daily precipitation at or above 1 mm/d" + == "Number of days with daily precipitation at or above 1 mm d-1" ) assert out.attrs["description"] in [ - "Annual number of days with daily precipitation at or above 1 mm/d." + "Annual number of days with daily precipitation at or above 1 mm d-1." ] out = atmos.wetdays(pr_series(np.arange(366)), thresh=1.5 * units.mm / units.day) assert ( out.attrs["long_name"] - == "Number of days with daily precipitation at or above 1.5 mm/d" + == "Number of days with daily precipitation at or above 1.5 mm d-1" ) assert out.attrs["description"] in [ - "Annual number of days with daily precipitation at or above 1.5 mm/d." + "Annual number of days with daily precipitation at or above 1.5 mm d-1." ] From 7441ef46c709cd28323207eeee250ad8f4b3dbba Mon Sep 17 00:00:00 2001 From: Pascal Bourgault Date: Wed, 15 Jan 2025 16:49:35 -0500 Subject: [PATCH 3/4] Update tests/test_units.py Co-authored-by: Trevor James Smith <10819524+Zeitsperre@users.noreply.github.com> --- tests/test_units.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_units.py b/tests/test_units.py index 9307673df..0da070b11 100644 --- a/tests/test_units.py +++ b/tests/test_units.py @@ -58,7 +58,6 @@ def test_dimensionality(self): fu = 1 * units.parse_units("kg / m**2 / s") tu = 1 * units.parse_units("mm / d") fu.to("mm/day") - tu.to("mm/day") def test_fraction(self): q = 5 * units.percent From 3b456854d847d218d40013b40721aaf1003ffdc5 Mon Sep 17 00:00:00 2001 From: Pascal Bourgault Date: Wed, 15 Jan 2025 16:55:39 -0500 Subject: [PATCH 4/4] Update test_units.py --- tests/test_units.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_units.py b/tests/test_units.py index 0da070b11..fbffbf391 100644 --- a/tests/test_units.py +++ b/tests/test_units.py @@ -54,9 +54,9 @@ def test_pcic(self): np.isclose(1 * fu, 1 * tu) def test_dimensionality(self): + # Check that the hydro context allows flux to rate conversion with units.context("hydro"): fu = 1 * units.parse_units("kg / m**2 / s") - tu = 1 * units.parse_units("mm / d") fu.to("mm/day") def test_fraction(self):