diff --git a/compliance_checker/cf/cf_1_6.py b/compliance_checker/cf/cf_1_6.py index 8e7291a5..f442d6c9 100644 --- a/compliance_checker/cf/cf_1_6.py +++ b/compliance_checker/cf/cf_1_6.py @@ -17,6 +17,7 @@ grid_mapping_dict16, ) from compliance_checker.cf.cf_base import CFNCCheck, appendix_a_base +from compliance_checker.cfunits import Unit logger = logging.getLogger(__name__) @@ -811,7 +812,7 @@ def _check_valid_cf_units(self, ds, variable_name): ) try: - units_conv = cfutil._units(units) + units_conv = Unit(units) except ValueError: valid_units.messages.append( f'Unit string "{units}" is not recognized by UDUnits', @@ -827,7 +828,7 @@ def _check_valid_cf_units(self, ds, variable_name): # being expressed as "s"/seconds if standard_name not in {"time", "forecast_reference_time"}: valid_units.assert_true( - units_conv.is_convertible_to(cfutil._units(reference)), + units_conv.is_convertible(Unit(reference)), f'Units "{units}" for variable ' f"{variable_name} must be convertible to " f'canonical units "{reference}"', @@ -1493,8 +1494,7 @@ def check_latitude(self, ds): # check that the units aren't in east and north degrees units, # but are convertible to angular units allowed_units.assert_true( - units not in e_n_units - and cfutil._units(units) == cfutil._units("degree"), + units not in e_n_units and Unit(units) == Unit("degree"), f"Grid latitude variable '{latitude}' should use degree equivalent units without east or north components. " f"Current units are {units}", ) @@ -1603,8 +1603,7 @@ def check_longitude(self, ds): # check that the units aren't in east and north degrees units, # but are convertible to angular units allowed_units.assert_true( - units not in e_n_units - and cfutil._units(units) == cfutil._units("degree"), + units not in e_n_units and Unit(units) == Unit("degree"), f"Grid longitude variable '{longitude}' should use degree equivalent units without east or north components. " f"Current units are {units}", ) @@ -2844,13 +2843,13 @@ def _cell_measures_core(self, ds, var, external_set, variable_template): f'cell_methods attribute with a measure type of "{cell_measure_type}".' ) try: - cell_measure_units = cfutil._units(cell_measure_var.units) + cell_measure_units = Unit(cell_measure_var.units) except ValueError: valid = False reasoning.append(conversion_failure_msg) else: - if not cell_measure_units.is_convertible_to( - cfutil._units(f"m{exponent}"), + if not cell_measure_units.is_convertible( + Unit(f"m{exponent}"), ): valid = False reasoning.append(conversion_failure_msg) @@ -3044,7 +3043,7 @@ def _check_cell_methods_paren_info(self, paren_contents, var): # then the units try: - cfutil._units(interval_matches.group("interval_units")) + Unit(interval_matches.group("interval_units")) except ValueError: valid_info.messages.append( 'ยง7.3.3 {}:cell_methods interval units "{}" is not parsable by UDUNITS.'.format( diff --git a/compliance_checker/cf/util.py b/compliance_checker/cf/util.py index 7916650e..eb28cabb 100644 --- a/compliance_checker/cf/util.py +++ b/compliance_checker/cf/util.py @@ -5,11 +5,11 @@ from pkgutil import get_data import requests -from cf_units import Unit from lxml import etree from netCDF4 import Dataset -from compliance_checker.cfutil import _units, units_convertible +from compliance_checker.cfunits import Unit +from compliance_checker.cfutil import units_convertible # copied from paegan # paegan may depend on these later @@ -321,7 +321,7 @@ def create_cached_data_dir(): def units_known(units): try: - _units(units) + Unit(units) except ValueError: return False return True @@ -329,7 +329,7 @@ def units_known(units): def units_temporal(units): try: - u = _units(units) + u = Unit(units) except ValueError: return False # IMPLEMENTATION CONFORMANCE REQUIRED 4.4 1/3 @@ -338,7 +338,7 @@ def units_temporal(units): # IMPLEMENTATION CONFORMANCE REQUIRED 4.4 3/3 # check that reference time seconds is not greater than or # equal to 60 - return u.is_time_reference + return u.is_time_reference() def find_coord_vars(ncds): diff --git a/compliance_checker/cfunits.py b/compliance_checker/cfunits.py new file mode 100644 index 00000000..f53424e5 --- /dev/null +++ b/compliance_checker/cfunits.py @@ -0,0 +1,90 @@ +from pyudunits2 import UnitSystem, UnresolvableUnitException + +try: + import cf_units +except ImportError: + cf_units = False + + +class PyUdunits2: + """Workaround for the differences in pyudunits2 and cf-units. + + NB: Some of these may change and/or get implemented upstream. Pyudunits2 is new and in-flux. + + 1/4 Raise the same ValueError to match cf-unit errors. + 2/4 Creates an empty unit from None to mimic cf-unit's Unit('unknown') + 3/4 Add a definition object that is ust units.expanded() + """ + + def __init__(self, units): + """Keep unit system so we can convert from string later.""" + self.ut_system = UnitSystem.from_udunits2_xml() + + if units is None: + units = "" + + try: + self.units = self.ut_system.unit(units) + except (SyntaxError, UnresolvableUnitException) as err: + raise ValueError from err + self.definition = self.units.expanded() + + def __eq__(self, other): + return self.units == other + + def is_dimensionless(self): + return self.units.is_dimensionless() + + def is_convertible(self, other): + if isinstance(other, str): + other = self.ut_system.unit(other) + elif isinstance(other, (PyUdunits2)): + other = other.units + else: + msg = f"Expected valid unit string or pyudunits2 unit object. Got {other}." + raise ValueError(msg) + + # FIXME: cf-units Workaround 1/4 -> cf_units.Unit(None) -> Unit('unknown'). + if "" in (self.units.expanded(), other.expanded()): + return False + + convertible = self.units.is_convertible_to(other) + # FIXME: cf-units Workaround 2/4 -> time is not convertible to time reference. + + # Both are time reference confirm. + if _is_time_reference(self.units) and _is_time_reference(other): + convertible = True + # One is time, the other is not, change it to False. + if sum((_is_time_reference(self.units), _is_time_reference(other))) == 1: + convertible = False + + return convertible + + def is_time_reference(self): + return _is_time_reference(self.units) + + +def _is_time_reference(self): + # FIXME: cf-units Workaround 4/4 -> cf_units can differentiante between time reference and time units. + is_time_reference = False + try: + if hasattr(self._definition, "shift_from"): + is_time_reference = True + except KeyError: + # FIXME: hasattr should return None in that case. + # pyudunits2/_expr_graph.py:27, in Node.__getattr__(self, name) + # 25 def __getattr__(self, name): + # 26 # Allow the dictionary to raise KeyError if the key doesn't exist. + # ---> 27 return self._attrs[name] + # KeyError: 'shift_from' + pass + return is_time_reference + + +if cf_units: + PyUdunits2 = cf_units.Unit + + +class Unit(PyUdunits2): + def __init__(self, units): + super().__init__(units) diff --git a/compliance_checker/cfutil.py b/compliance_checker/cfutil.py index c56e6de5..cd7fb612 100644 --- a/compliance_checker/cfutil.py +++ b/compliance_checker/cfutil.py @@ -9,7 +9,7 @@ from functools import lru_cache, partial from importlib.resources import files -from cf_units import Unit +from compliance_checker.cfunits import Unit _UNITLESS_DB = None _SEA_NAMES = None @@ -111,7 +111,7 @@ def is_dimensionless_standard_name(standard_name_table, standard_name): f".//entry[@id='{standard_name}']", ) if found_standard_name is not None: - canonical_units = _units( + canonical_units = Unit( found_standard_name.find("canonical_units").text, ) return canonical_units.is_dimensionless() @@ -2038,22 +2038,9 @@ def units_convertible(units1, units2): :param str units1: A string representing the units :param str units2: A string representing the units """ - convertible = False try: - u1 = _units(units1) - u2 = _units(units2) + u1 = Unit(units1) + u2 = Unit(units2) except ValueError: return False - # FIXME: Workaround for unknown units in cf_units. - if "" in (u1.expanded(), u2.expanded()): - return False - - convertible = u1.is_convertible_to(u2) - # FIXME: Workaround for is_time_reference vs time in cf_units. - # Both are time reference confirm. - if u1.is_time_reference and u2.is_time_reference: - convertible = True - # One is time, the other is not, change it to False. - if sum((u1.is_time_reference, u2.is_time_reference)) == 1: - convertible = False - return convertible + return u1.is_convertible(u2) diff --git a/compliance_checker/ioos.py b/compliance_checker/ioos.py index 8a8015d9..ac894564 100644 --- a/compliance_checker/ioos.py +++ b/compliance_checker/ioos.py @@ -22,8 +22,8 @@ ) from compliance_checker.cf import util as cf_util # not to be confused with cfutil.py from compliance_checker.cf.cf import CF1_6Check, CF1_7Check +from compliance_checker.cfunits import Unit from compliance_checker.cfutil import ( - _units, get_geophysical_variables, get_instrument_variables, get_z_variables, @@ -1378,12 +1378,12 @@ def check_vertical_coordinates(self, ds): "fathom", ) unit_def_set = { - _units(unit_str).expanded() for unit_str in expected_unit_strs + Unit(unit_str).definition for unit_str in expected_unit_strs } try: - units = _units(units_str) - pass_stat = units.expanded() in unit_def_set + units = Unit(units_str) + pass_stat = units.definition in unit_def_set # unknown unit not convertible to UDUNITS except ValueError: pass_stat = False