Skip to content

Commit 51cb1b1

Browse files
committed
Various further compatibility changes.
1 parent ed8dc2d commit 51cb1b1

File tree

2 files changed

+154
-23
lines changed

2 files changed

+154
-23
lines changed

lib/iris/common/mixin.py

Lines changed: 120 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,13 @@
99
from collections.abc import Mapping
1010
from datetime import timedelta
1111
from functools import wraps
12-
from typing import Any, Literal
12+
from typing import Any, TypeAlias
1313
import warnings
1414

1515
import cftime
1616

17+
from iris.util import is_masked
18+
1719
# TODO: use a FUTURE flag to control this
1820
try:
1921
import cf_units
@@ -22,6 +24,7 @@
2224

2325
try:
2426
import cfpint
27+
import pint
2528
except 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

451548
def 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
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
from datetime import datetime
2+
3+
import numpy as np
4+
5+
from iris.common.mixin import CfpintUnit
6+
7+
8+
def test_num2date():
9+
unit = CfpintUnit("days since 1970-01-01")
10+
vals = np.array([1.0, 2])
11+
result = unit.num2date(vals)
12+
assert np.all(result == [datetime(1970, 1, 2), datetime(1970, 1, 3)])
13+
14+
15+
def test_date2num():
16+
unit = CfpintUnit("days since 1970-01-01")
17+
vals = np.array([datetime(1970, 1, 2), datetime(1970, 1, 3)])
18+
result = unit.date2num(vals)
19+
assert np.all(result == [1.0, 2])
20+
21+
22+
def test_nounit_eq():
23+
unit = CfpintUnit("m")
24+
assert unit != "no_unit"
25+
26+
27+
def test_calendar():
28+
unit = CfpintUnit("days since 1970-01-01", calendar="360_day")
29+
assert repr(unit) == "<Unit('days since 1970-01-01', calendar='360_day')>"
30+
# TODO: should really add the calendar to the string format
31+
# I think this is a bit horrible,
32+
# .. but it is cf_units behaviour + currently required for correct netcdf saving
33+
# it also means that calendar is not checked in unit/string eq (!!!)
34+
assert str(unit) == "days since 1970-01-01"

0 commit comments

Comments
 (0)