99from collections .abc import Mapping
1010from datetime import timedelta
1111from functools import wraps
12- from typing import Any , Literal
12+ from typing import Any , TypeAlias
1313import warnings
1414
1515import cftime
1616
17+ from iris .util import is_masked
18+
1719# TODO: use a FUTURE flag to control this
1820try :
1921 import cf_units
2224
2325try :
2426 import cfpint
27+ import pint
2528except ImportError :
2629 cfpint = None
2730
@@ -251,11 +254,23 @@ class CfpintUnit(cfpint.Unit):
251254 # TODO: ideally we would get rid of this class altogether.
252255 @classmethod
253256 def from_unit (cls , unit ):
254- """Cast anything into the standard Unit class for use within Iris."""
257+ """Cast anything into the standard Unit class for use within Iris.
258+
259+ Unit may be a string,
260+ """
255261 if isinstance (unit , CfpintUnit ):
256262 result = unit
263+ elif isinstance (unit , cf_units .Unit ):
264+ # We need a special case for cf_unit conversion.
265+ # Although we fallback to str() for native Pint units ('else' below),
266+ # we can't do that for cf-units **because the str() omits calendars**.
267+ result = CfpintUnit (str (unit ), calendar = unit .calendar )
268+ elif unit is None :
269+ # A special case, so we can support "None" -> "unknown" for object
270+ # creation with no given units.
271+ result = CfpintUnit ("unknown" )
257272 else :
258- # A crude str-based conversion -- works for cf_units too ?
273+ # E.G. probably a string, or a native Pint unit: take the str()
259274 result = CfpintUnit (str (unit ))
260275 return result
261276
@@ -287,26 +302,72 @@ def __str__(self):
287302 result = self .category
288303 else :
289304 result = super ().__str__ ()
305+ if self .is_datelike () or self .is_time ():
306+ # Recognise short time units + replace with long forms
307+ # -- weird, as most Pint units work "the other way",
308+ # e.g. "m" -> "metre" !
309+ # -- but probably due to the cfxarray-like "short_formatter"
310+ # - see "cfpint._cfarray_units_like.short_formatter"
311+ # TODO: this should probably be fixed **in cfpint** ?
312+ result = self ._make_unitstr_cftimelike (result )
313+ return result
314+
315+ # remove <> from reprs, since CDL seems to use this
316+ # (? so calendars are recorded, due to not appearing in str(date-unit) ?)
317+ # TODO: remove this
318+ _REPR_NO_LTGT = True
319+
320+ def __repr__ (self ):
321+ """Correct the repr.
322+
323+ For fuller backwards-compatibility with cf_units,
324+ mostly because assert_CML (i.e. the xml methods) need it.
325+ TODO: remove this
326+ """
327+ if self .category != "regular" :
328+ result = f"<Unit('{ self .category } ')>"
329+ elif self .dimensionless :
330+ # Cfpint fixes this for "str" but not "repr"
331+ result = f"<Unit('1')>"
332+ else :
333+ result = super ().__repr__ ()
334+
335+ if self ._REPR_NO_LTGT :
336+ # Just strip off the "<>" wrapping. Result should then be equivalent
337+ if len (result ) and result [0 ] == "<" :
338+ result = result [1 :]
339+ if len (result ) and result [- 1 ] == ">" :
340+ result = result [:- 1 ]
341+
342+ if self .is_datelike () or self .is_time ():
343+ # TODO: this should probably be fixed **in cfpint** ?
344+ result = self ._make_unitstr_cftimelike (result )
290345 return result
291346
292347 def convert (self , arraylike , other ):
348+ is_masked = np .ma .isMaskedArray (arraylike )
349+ if is_masked :
350+ arraylike = arraylike .data
293351 quantity = arraylike * self
294352 quantity = quantity .to (str (other ))
295353 # TODO: I *think* this is the appropriate way to strip the units.
296- return quantity .m
354+ result = quantity .m
355+ if is_masked :
356+ result = np .ma .masked_array (result , arraylike .mask )
357+ return result
297358
298- def _cftime_unit (self ) -> str :
299- """Make a cftime-compatible unit string."""
300- if not self .is_datelike ():
301- raise ValueError (f"Called 'num2date' on a non-datelike unit: { self !r} ." )
302- units = str (self )
359+ def _make_unitstr_cftimelike (self , units : str ) -> str :
360+ """Make a unit string cftime-compatible."""
303361 # Some kludges needed for now!
304- # TODO: ideally, fix how cfpoint units represent h/m/s/d
362+ # TODO: to aid use of cftime, this fix **should be in cfpint***
363+ # - ideally, fix how cfpint units represent h/m/s/d
364+ # - if *not* fixed in basic units registry, at least fix str(), as here
305365 reps = {"s" : "seconds" , "m" : "minutes" , "h" : "hours" , "d" : "days" }
306366 for char , name in reps .items ():
307- chars = char + " "
308- if units .startswith (chars ):
309- units = units .replace (chars , name + " " , 1 )
367+ if units == char :
368+ units = name
369+ elif units .startswith (char + " " ):
370+ units = units .replace (char + " " , name + " " , 1 )
310371 return units
311372
312373 def num2date (
@@ -367,11 +428,18 @@ def num2date(
367428 But here, it is explicitly re-implemented using only cftime.
368429 Ultimately, we will lose this, and users should use cftime explicitly.
369430 """
370- units = self ._cftime_unit ()
431+ if not self .is_datelike ():
432+ raise ValueError (f"Called 'num2date' on a non-datelike unit: { self !r} ." )
433+ units_str = str (self )
434+ # TODO: this should probably be fixed **in cfpint** ?
435+ units_str = self ._make_unitstr_cftimelike (units_str )
436+ calendar = self .calendar
437+ if calendar is None :
438+ calendar = "standard"
371439 result = cftime .num2date (
372- times = time_value ,
373- units = units ,
374- calendar = self . calendar ,
440+ time_value ,
441+ units = units_str ,
442+ calendar = calendar ,
375443 only_use_cftime_datetimes = only_use_cftime_datetimes ,
376444 only_use_python_datetimes = only_use_python_datetimes ,
377445 )
@@ -411,8 +479,12 @@ def date2num(self, date):
411479 But here, it is explicitly re-implemented using only cftime.
412480 Ultimately, we will lose this, and users should use cftime explicitly.
413481 """
414- units = self ._cftime_unit ()
415- result = cftime .date2num (date , units , self .calendar )
482+ if not self .is_datelike ():
483+ raise ValueError (f"Called 'date2num' on a non-datelike unit: { self !r} ." )
484+ units_str = str (self )
485+ # TODO: this should probably be fixed **in cfpint** ?
486+ units_str = self ._make_unitstr_cftimelike (units_str )
487+ result = cftime .date2num (date , units_str , self .calendar )
416488 return result
417489
418490 def is_udunits (self ):
@@ -425,9 +497,12 @@ def is_unknown(self):
425497 def is_no_unit (self ):
426498 return self .category == "no_unit"
427499
428- def is_reference_time (self ):
500+ def is_time_reference (self ):
429501 return self .is_datelike ()
430502
503+ def is_long_time_interval (self ):
504+ return False
505+
431506 def is_convertible (self , other ):
432507 if not isinstance (other , cfpint .Unit ):
433508 other = CfpintUnit (other )
@@ -445,8 +520,30 @@ def is_vertical(self):
445520 return self .dimensionality in (pressure_dims , height_dims )
446521
447522
523+ # FOR NOW: insist on pint units
448524_DEFAULT_UNITCLASS : type = CfpintUnit
449525
526+ # And force pint too.
527+ # TODO: since we may have seen problems with doing this dynamically, this could affect
528+ # the whole attempt to divide functions between Iris and Cfpint functionality
529+
530+
531+ # See: https://pint.readthedocs.io/en/stable/advanced/custom-registry-class.html#custom-quantity-and-unit-class
532+ class IrispintRegistry (pint .registry .UnitRegistry ):
533+ Quantity : TypeAlias = pint .Quantity
534+ Unit : TypeAlias = CfpintUnit
535+
536+
537+ # Create our own registry, based on our own UnitRegistry subclass
538+ from cfpint ._cfarray_units_like import make_registry
539+
540+ IRIS_PINT_REGISTRY : IrispintRegistry = make_registry (
541+ IrispintRegistry
542+ ) # include all 'normal' features
543+ pint .set_application_registry (IRIS_PINT_REGISTRY )
544+ pint .application_registry .default_system = "SI"
545+ pint .application_registry .default_format = "cfu"
546+
450547
451548def default_units_class ():
452549 if _DEFAULT_UNITCLASS is not None :
@@ -526,13 +623,13 @@ def var_name(self, name: str | None) -> None:
526623 self ._metadata_manager .var_name = name
527624
528625 @property
529- def units (self ) -> cf_units .Unit :
626+ def units (self ) -> cf_units .Unit | cfpint . Unit :
530627 """The S.I. unit of the object."""
531628 return self ._metadata_manager .units
532629
533630 @units .setter
534- def units (self , unit : cf_units .Unit | str | None ) -> None :
535- unit = cf_units .as_unit (unit )
631+ def units (self , unit : cf_units .Unit | cfpint . Unit | str | None ) -> None :
632+ # unit = cf_units.as_unit(unit)
536633 self ._metadata_manager .units = default_units_class ().from_unit (unit )
537634
538635 @property
0 commit comments