Skip to content
2 changes: 2 additions & 0 deletions .readthedocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ build:
- git stash
post_install:
- git stash pop
# Also make cfpint available : NOTE looks forward to #7037 method
- pip install git+https://github.com/SciTools/cfpint

conda:
environment: requirements/readthedocs.yml
Expand Down
1 change: 1 addition & 0 deletions docs/src/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,7 @@ def _dotv(version):
"matplotlib": ("https://matplotlib.org/stable/", None),
"numpy": ("https://numpy.org/doc/stable/", None),
"pandas": ("https://pandas.pydata.org/docs/", None),
"pint": ("https://pint.readthedocs.io/en/stable/", None),
"python": ("https://docs.python.org/3/", None),
"pyvista": ("https://docs.pyvista.org/", None),
"scipy": ("https://docs.scipy.org/doc/scipy/", None),
Expand Down
91 changes: 21 additions & 70 deletions lib/iris/common/mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,26 @@
from __future__ import annotations

from collections.abc import Mapping
from datetime import timedelta
from functools import wraps
from typing import Any
import warnings

import cf_units
# Optional imports : actually only needed for type hints
try:
import cf_units
except ImportError:
cf_units = None

try:
import cfpint
except ImportError:
cfpint = None

import numpy as np

import iris.std_names

from .metadata import BaseMetadata
from .units import make_unit

__all__ = ["CFVariableMixin", "LimitedAttributeDict"]

Expand Down Expand Up @@ -158,68 +167,6 @@ def update(self, other, **kwargs):
dict.update(self, other, **kwargs)


class Unit(cf_units.Unit):
# TODO: remove this subclass once FUTURE.date_microseconds is removed.

@classmethod
def from_unit(cls, unit: cf_units.Unit):
"""Cast a :class:`cf_units.Unit` to an :class:`Unit`."""
if isinstance(unit, Unit):
result = unit
elif isinstance(unit, cf_units.Unit):
result = cls.__new__(cls)
result.__dict__.update(unit.__dict__)
else:
message = f"Expected a cf_units.Unit, got {type(unit)}"
raise TypeError(message)
return result

def num2date(
self,
time_value,
only_use_cftime_datetimes=True,
only_use_python_datetimes=False,
):
# Used to patch the cf_units.Unit.num2date method to round to the
# nearest second, which was the legacy behaviour. This is under a FUTURE
# flag - users will need to adapt to microsecond precision eventually,
# which may involve floating point issues.
from iris import FUTURE

def _round(date):
if date.microsecond == 0:
return date
elif date.microsecond < 500000:
return date - timedelta(microseconds=date.microsecond)
else:
return (
date
+ timedelta(seconds=1)
- timedelta(microseconds=date.microsecond)
)

result = super().num2date(
time_value, only_use_cftime_datetimes, only_use_python_datetimes
)
if FUTURE.date_microseconds is False:
message = (
"You are using legacy date precision for Iris units - max "
"precision is seconds. In future, Iris will use microsecond "
"precision - available since cf-units version 3.3 - which may "
"affect core behaviour. To opt-in to the "
"new behaviour, set `iris.FUTURE.date_microseconds = True`."
)
warnings.warn(message, category=FutureWarning)

if hasattr(result, "shape"):
vfunc = np.frompyfunc(_round, 1, 1)
result = vfunc(result)
else:
result = _round(result)

return result


class CFVariableMixin:
_metadata_manager: Any

Expand Down Expand Up @@ -282,14 +229,18 @@ def var_name(self, name: str | None) -> None:
self._metadata_manager.var_name = name

@property
def units(self) -> cf_units.Unit:
"""The S.I. unit of the object."""
def units(self) -> cf_units.Unit | cfpint.Unit:
"""The S.I. unit of the object.

If not ``None``, this is always an Iris Unit type - either :class:`CfUnit` or
:class:`cfpint.Unit`. See :func:`iris.common.units.make_unit`.
"""
return self._metadata_manager.units

@units.setter
def units(self, unit: cf_units.Unit | str | None) -> None:
unit = cf_units.as_unit(unit)
self._metadata_manager.units = Unit.from_unit(unit)
def units(self, unit: cf_units.Unit | cfpint.Unit | str | None) -> None:
unit = make_unit(unit)
self._metadata_manager.units = unit

@property
def attributes(self) -> LimitedAttributeDict:
Expand Down
65 changes: 65 additions & 0 deletions lib/iris/common/units/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# Copyright Iris contributors
#
# This file is part of Iris and is released under the BSD license.
# See LICENSE in the root of the repository for full licensing details.
"""Generic definition of units as used in Iris."""

from typing import Any

try:
import cf_units

from ._cf_units import CfUnit
except ImportError:
cf_units = None

try:
import cfpint
import pint

from ._pint import PintUnit
except ImportError:
cfpint = None
pint = None


if not cf_units and not cfpint:
raise ImportError("Either 'cfpint' and or 'cf_units' must be installed.")


def _default_units_class():
from iris.experimental.units import USE_CFPINT

if USE_CFPINT:
result = PintUnit
else:
result = CfUnit
return result


def make_unit(arg: cf_units.Unit | pint.Unit | Any) -> CfUnit | PintUnit:
"""Convert input into an Iris unit.

Converts strings to units, and pint/cf_units Units to the Iris specialised
derived unit types.

The type returned is either :class:`iris.common.units.CfUnit` or
:class:`iris.common.units.PintUnit`. If the argument is a non-unit object, such as
a string or number, the resulting type is determined by the
:data:`iris.experimental.units.USE_CFPINT` control.
"""
if cf_units is not None and isinstance(arg, cf_units.Unit):
unit_class = CfUnit
elif pint is not None and isinstance(arg, pint.Unit):
unit_class = PintUnit
else:
unit_class = _default_units_class()
return unit_class.from_unit(arg)


# What to 'publish' : required to include CfUnit/PintUnit in the API docs.
__all__ = [
"CfUnit",
"PintUnit",
"make_unit",
]
80 changes: 80 additions & 0 deletions lib/iris/common/units/_cf_units.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# Copyright Iris contributors
#
# This file is part of Iris and is released under the BSD license.
# See LICENSE in the root of the repository for full licensing details.
"""Iris cf_units-based units."""

from datetime import timedelta
import warnings

import cf_units
import numpy as np


class CfUnit(cf_units.Unit):
"""Specialised class for Iris units based on cf_units."""

@classmethod
def from_unit(cls, unit: cf_units.Unit):
"""Cast a :class:`cf_units.Unit` to an :class:`Unit`."""
unit = cf_units.as_unit(unit)
if isinstance(unit, CfUnit):
result = unit
elif isinstance(unit, cf_units.Unit):
result = cls.__new__(cls)
result.__dict__.update(unit.__dict__)
else:
message = f"Expected a cf_units.Unit, got {type(unit)}"
raise TypeError(message)
return result

def num2date(
self,
time_value,
only_use_cftime_datetimes=True,
only_use_python_datetimes=False,
):
# Used to patch the cf_units.Unit.num2date method to round to the
# nearest second, which was the legacy behaviour. This is under a FUTURE
# flag - users will need to adapt to microsecond precision eventually,
# which may involve floating point issues.
from iris import FUTURE

def _round(date):
if date.microsecond == 0:
return date
elif date.microsecond < 500000:
return date - timedelta(microseconds=date.microsecond)
else:
return (
date
+ timedelta(seconds=1)
- timedelta(microseconds=date.microsecond)
)

result = super().num2date(
time_value, only_use_cftime_datetimes, only_use_python_datetimes
)
if FUTURE.date_microseconds is False:
message = (
"You are using legacy date precision for Iris units - max "
"precision is seconds. In future, Iris will use microsecond "
"precision - available since cf-units version 3.3 - which may "
"affect core behaviour. To opt-in to the "
"new behaviour, set `iris.FUTURE.date_microseconds = True`."
)
warnings.warn(message, category=FutureWarning)

if hasattr(result, "shape"):
vfunc = np.frompyfunc(_round, 1, 1)
result = vfunc(result)
else:
result = _round(result)

return result

def __repr__(self):
# Adjust repr to look like the parent class, to avoid many CML errors.
string = super().__repr__()
string = string.replace(self.__class__.__name__ + "(", "Unit(", 1)
return string
Loading
Loading