diff --git a/compliance_checker/cf/util.py b/compliance_checker/cf/util.py index c13accc7..042753f2 100644 --- a/compliance_checker/cf/util.py +++ b/compliance_checker/cf/util.py @@ -337,7 +337,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..2c8bde1f --- /dev/null +++ b/compliance_checker/cfunits.py @@ -0,0 +1,113 @@ +try: + from cf_units import Unit +except ImportError: + cf_units = None + Unit = object +import functools + +from pyudunits2 import UnitSystem, UnresolvableUnitException + + +class CFUnitMixin: + """Standardize the methods from cf-untis and pyudunits2 used in compliance-checker.""" + + def is_convertible_to(self, other): + return self.is_convertible(other) + + def expand_definition(self): + return self.definition + + def has_shift_from(self): + return self.is_time_reference() + + +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 + + +class Udunits2(CFUnitMixin, PyUdunits2): + def __init__(self, units): + super().__init__(units) + + +class CFUnits(CFUnitMixin, Unit): + def __init__(self, units): + super().__init__(units) + + +@functools.lru_cache(128) +def _units(units): + if cf_units is not None: + return CFUnits(units) + return Udunits2(units) diff --git a/compliance_checker/cfutil.py b/compliance_checker/cfutil.py index 95c8479f..69ce7209 100644 --- a/compliance_checker/cfutil.py +++ b/compliance_checker/cfutil.py @@ -10,6 +10,8 @@ from importlib_resources import files +from compliance_checker.cfunits import _units + _UNITLESS_DB = None _SEA_NAMES = None @@ -2029,66 +2031,7 @@ def guess_feature_type(nc, variable): return "reduced-grid" -class _CCUnit: - def __init__(self, units): - import cf_units - - self.u = cf_units.Unit(units) - self.is_time_reference = self.u.is_time_reference() - - def __eq__(self, other): - return self.u == other.u - - def is_convertible_to(self, other): - if isinstance(other, str): - ret = self.u.is_convertible(other) - else: - ret = self.u.is_convertible(other.u) - return ret - - def is_dimensionless(self): - return self.u.is_dimensionless() - - def expanded(self): - return self.u.definition - - -def _units(units: str): - """PLACEHOLDER.""" - # FIXME: Make cf_units optional in a more elegant way when pyudunits2 is release. - try: - return _CCUnit(units) - except ImportError: - from pyudunits2 import UnitSystem, UnresolvableUnitException - - ut_system = UnitSystem.from_udunits2_xml() - - # FIXME: cf_units.Unit(None) -> Unit('unknown') - if units is None: - units = "" - - # FIXME: cf_units raised only ValueError - try: - u = ut_system.unit(units) - except (SyntaxError, UnresolvableUnitException) as err: - raise ValueError from err - # FIXME: cf_units defined .is_time_reference for time reference units. - u.is_time_reference = False - try: - if hasattr(u._definition, "shift_from"): - u.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 u - - -def units_convertible(units1, units2, reftimeistime=True): +def units_convertible(units1, units2): """ Return True if a Unit representing the string units1 can be converted to a Unit representing the string units2, else False. @@ -2096,22 +2039,9 @@ def units_convertible(units1, units2, reftimeistime=True): :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) 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_to(u2) diff --git a/compliance_checker/ioos.py b/compliance_checker/ioos.py index 8a8015d9..cb70594b 100644 --- a/compliance_checker/ioos.py +++ b/compliance_checker/ioos.py @@ -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 + _units(unit_str).expand_definition() for unit_str in expected_unit_strs } try: units = _units(units_str) - pass_stat = units.expanded() in unit_def_set + pass_stat = units.expand_definition() in unit_def_set # unknown unit not convertible to UDUNITS except ValueError: pass_stat = False