diff --git a/README.md b/README.md index d2023d3..d361398 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,14 @@ **obsarray** is an extension to [xarray](https://docs.xarray.dev/en/stable/) for defining, storing and interfacing with uncertainty information using standardised metadata. It is particularly designed to work well with [netCDF](https://www.unidata.ucar.edu/software/netcdf/) files and for the Earth Observation community. +> **Upcoming change:** obsarray currently follows v0.1 of the +> [UNC specification](https://comet-toolkit.github.io/unc_website/) for +> uncertainty metadata. A future release will default to +> [draft v0.2](https://comet-toolkit.github.io/unc_website/specification/draft-v0.2/unc_specification.html), +> which is proposed for inclusion in the CF conventions and is not backwards +> compatible with v0.1. See +> [what this means for you](https://obsarray.readthedocs.io/en/latest/content/user/unc_conventions.html). + obsarray is part of the [CoMet Toolkit](https://www.comet-toolkit.org) (community metrology toolkit), and can combined with the [punpy](https://punpy.readthedocs.io/en/latest/) (propagating uncertainties in python) module for very simple propagation of defined data uncertainties through arbitrary python functions. ## Installation diff --git a/docs/conf.py b/docs/conf.py index 9cafd60..20acc52 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -110,7 +110,13 @@ # theme further. For a list of options available for each theme, see the # documentation. # -# html_theme_options = {} +html_theme_options = { + "announcement": ( + "Upcoming change: obsarray will move to draft v0.2 of the UNC " + 'specification. Find out what this means for you.' + ), +} html_logo = "figs/obsarray.png" diff --git a/docs/content/user/api.rst b/docs/content/user/api.rst index cd88953..84216d6 100644 --- a/docs/content/user/api.rst +++ b/docs/content/user/api.rst @@ -93,4 +93,14 @@ Utility functions .. autosummary:: :toctree: generated/ - utils.append_names \ No newline at end of file + utils.append_names + +Options +======= + +.. autosummary:: + :toctree: generated/ + + options.set_options + options.get_options + conventions.UNCConventionsWarning \ No newline at end of file diff --git a/docs/content/user/unc_conventions.rst b/docs/content/user/unc_conventions.rst new file mode 100644 index 0000000..34dd971 --- /dev/null +++ b/docs/content/user/unc_conventions.rst @@ -0,0 +1,89 @@ +.. _unc_conventions: + +############################################# +Upcoming change: UNC specification draft v0.2 +############################################# + +**obsarray** stores uncertainty information following the `Uncertainty metadata Naming Conventions (UNC) +specification `_. It currently follows +`v0.1 `_. + +`Draft v0.2 `_ +is a major revision, developed with the CF community and proposed for inclusion in the +`CF conventions `_ as a new "Uncertainty" chapter. A future release of **obsarray** +will support draft v0.2 and make it the default. Support for v0.1 will remain available, selectable with the +``unc_conventions`` option (see :ref:`below `). + +What will change +================ + +Datasets following v0.1 are **not compatible** with draft v0.2. The main changes to the metadata obsarray writes +are: + +.. list-table:: + :widths: 50 50 + :header-rows: 1 + + * - v0.1 (current) + - Draft v0.2 + * - ``unc_comps`` attribute on the observation variable (list of names) + - ``uncertainty_variables`` attribute on the data variable (blank-separated string of names) + * - ``pdf_shape`` attribute (default ``"gaussian"``) + - ``probability_distribution`` attribute + * - ``err_corr_dim_name``, ``_form``, ``_params`` and ``_units`` attributes + - Single ``error_correlation`` string attribute + * - Error-correlation forms ``random`` and ``systematic`` + - Error-correlation structural types ``independent`` and ``common`` + * - Meaning of the uncertainty values not defined + - Required ``coverage_interval`` and ``coverage_probability`` attributes + +The full list is in `Appendix B of the draft v0.2 specification +`_. + +The UNC conventions warning +=========================== + +Until a conventions version is chosen explicitly, **obsarray** issues an :py:class:`~obsarray.conventions.UNCConventionsWarning` +the first time in a session that it writes uncertainty metadata (for example, when adding an uncertainty variable +with ``ds.unc[...]`` or creating a dataset from a template that includes one). It is shown once per session, and +reading existing uncertainty information does not trigger it. + +.. _unc_conventions_option: + +Choosing the conventions version +================================ + +To keep the current behaviour and silence the warning, choose v0.1 explicitly with +:py:class:`~obsarray.options.set_options`, either for the rest of the session: + +.. code-block:: python + + import obsarray + + obsarray.set_options(unc_conventions="v0.1") + +or only within a block: + +.. code-block:: python + + with obsarray.set_options(unc_conventions="v0.1"): + ds.unc["temperature"]["u_noise"] = (["x"], u_noise, {"units": "K"}) + +``"v0.1"`` is currently the only supported value. ``"v0.2"`` will become available once draft v0.2 support is +added. + +The warning can also be filtered like any other Python warning: + +.. code-block:: python + + import warnings + import obsarray + + warnings.filterwarnings("ignore", category=obsarray.UNCConventionsWarning) + +.. note:: + **For developers of packages built on obsarray:** if your package writes uncertainty metadata on behalf of + its users, wrap that code in ``with obsarray.set_options(unc_conventions="v0.1"):``. This keeps your output + unchanged when obsarray's default changes, and avoids showing your users a warning about code they don't + control. Avoid calling ``obsarray.set_options`` globally from library code, as that changes the setting for + your users' own obsarray code too. diff --git a/docs/content/user/user_guide.rst b/docs/content/user/user_guide.rst index 48c29f0..868e9e3 100644 --- a/docs/content/user/user_guide.rst +++ b/docs/content/user/user_guide.rst @@ -9,4 +9,5 @@ In this user guide, you will find detailed descriptions and examples that descri unc_accessor flag_accessor - templater \ No newline at end of file + templater + unc_conventions \ No newline at end of file diff --git a/obsarray/__init__.py b/obsarray/__init__.py index 457c846..842811a 100644 --- a/obsarray/__init__.py +++ b/obsarray/__init__.py @@ -11,6 +11,8 @@ from obsarray.templater.dstemplater import DSTemplater from obsarray.templater.dswriter import DSWriter from obsarray.utils import append_names +from obsarray.options import set_options, get_options +from obsarray.conventions import UNCConventionsWarning try: __version__ = version("obsarray") diff --git a/obsarray/conventions.py b/obsarray/conventions.py new file mode 100644 index 0000000..881facf --- /dev/null +++ b/obsarray/conventions.py @@ -0,0 +1,84 @@ +"""conventions - handling of the UNC metadata conventions version obsarray follows""" + +import inspect +import os +import warnings + +from obsarray.options import OPTIONS + +__author__ = "Sam Hunt " +__all__ = ["UNCConventionsWarning"] + + +UNC_CONVENTIONS_DOCS_URL = ( + "https://obsarray.readthedocs.io/en/latest/content/user/unc_conventions.html" +) + +_UNC_CONVENTIONS_MESSAGE = ( + "obsarray currently writes uncertainty metadata following v0.1 of the UNC " + "specification. A future release will default to UNC draft v0.2, which is proposed " + "for inclusion into the CF conventions and is not backwards compatible with v0.1 (for " + "example, 'unc_comps' becomes 'uncertainty_variables' and 'err_corr_*' becomes " + "'error_correlation'). See " + UNC_CONVENTIONS_DOCS_URL + " for details. To keep " + "the current behaviour and silence this warning, call " + 'obsarray.set_options(unc_conventions="v0.1").' +) + +# set once the warning has been issued, so it is only shown once per session +_warned = False + + +class UNCConventionsWarning(FutureWarning): + """ + Warning that obsarray's default UNC conventions version will change from v0.1 to draft v0.2 + + Issued once per session, the first time obsarray writes uncertainty metadata, unless a conventions + version has been chosen explicitly with :py:class:`~obsarray.options.set_options`. It can also be + filtered like any other warning: + + .. code-block:: python + + import warnings + import obsarray + + warnings.filterwarnings("ignore", category=obsarray.UNCConventionsWarning) + """ + + +def warn_unc_conventions() -> None: + """ + Warns, once per session, that the default UNC conventions version will change - unless a version has + been chosen explicitly with :py:class:`~obsarray.options.set_options` + """ + + global _warned + + if _warned or OPTIONS["unc_conventions"] is not None: + return + + _warned = True + warnings.warn( + _UNC_CONVENTIONS_MESSAGE, UNCConventionsWarning, stacklevel=_find_stack_level() + ) + + +def _find_stack_level() -> int: + """ + Returns the ``stacklevel`` of the first frame outside obsarray, so warnings point at the calling code + + :return: stack level to pass to :py:func:`warnings.warn` from the function calling this one + """ + + pkg_dir = os.path.dirname(os.path.abspath(__file__)) + os.sep + + frame = inspect.currentframe() + level = 0 + while frame is not None and frame.f_code.co_filename.startswith(pkg_dir): + frame = frame.f_back + level += 1 + + return level + + +if __name__ == "__main__": + pass diff --git a/obsarray/options.py b/obsarray/options.py new file mode 100644 index 0000000..e2ccf1d --- /dev/null +++ b/obsarray/options.py @@ -0,0 +1,78 @@ +"""options - global obsarray options""" + +from typing import Any, Dict + +__author__ = "Sam Hunt " +__all__ = ["set_options", "get_options"] + + +# None means "not chosen explicitly" - obsarray then uses its default behaviour +OPTIONS: Dict[str, Any] = {"unc_conventions": None} + +_VALID_VALUES = {"unc_conventions": ("v0.1",)} + + +class set_options: + """ + Sets obsarray global options, either for the rest of the session or within a ``with`` block + + Available options: + + * ``unc_conventions`` - version of the `UNC specification + `_ obsarray follows when writing uncertainty metadata. + Currently the only supported value is ``"v0.1"`` (obsarray's current behaviour). Setting it explicitly + also silences the :py:class:`~obsarray.conventions.UNCConventionsWarning` about the upcoming change of + default to draft v0.2. + + .. code-block:: python + + import obsarray + + # for the rest of the session + obsarray.set_options(unc_conventions="v0.1") + + # or only within a block + with obsarray.set_options(unc_conventions="v0.1"): + ds.unc["temperature"]["u_noise"] = (["x"], u_noise, {"units": "K"}) + + :param kwargs: option names and their new values + """ + + def __init__(self, **kwargs: Any): + self._old: Dict[str, Any] = {} + + for name, value in kwargs.items(): + if name not in OPTIONS: + raise ValueError( + f"{name!r} is not an obsarray option, must be one of {sorted(OPTIONS)}" + ) + + if value not in _VALID_VALUES[name]: + raise ValueError( + f"{value!r} is not a valid value for {name!r}, must be one of " + f"{list(_VALID_VALUES[name])}" + ) + + self._old[name] = OPTIONS[name] + + OPTIONS.update(kwargs) + + def __enter__(self) -> None: + return None + + def __exit__(self, *exc_info: Any) -> None: + OPTIONS.update(self._old) + + +def get_options() -> Dict[str, Any]: + """ + Returns a copy of the current obsarray global options + + :return: option names and their values (``None`` if not set explicitly) + """ + + return dict(OPTIONS) + + +if __name__ == "__main__": + pass diff --git a/obsarray/templater/dataset_util.py b/obsarray/templater/dataset_util.py index 4a170c9..14e8a00 100644 --- a/obsarray/templater/dataset_util.py +++ b/obsarray/templater/dataset_util.py @@ -8,6 +8,7 @@ import numpy from numpy.typing import DTypeLike from typing import Union, Optional, List, Dict, Tuple +from obsarray.conventions import warn_unc_conventions __author__ = "Sam Hunt " @@ -146,6 +147,8 @@ def create_unc_variable( populated to the effect of this assumption. """ + warn_unc_conventions() + # define uncertainty variable attributes, based on FIDUCEO Full FCDR definition (if required) attributes = {} if attributes is None else attributes diff --git a/obsarray/test/test_conventions.py b/obsarray/test/test_conventions.py new file mode 100644 index 0000000..ddf7f64 --- /dev/null +++ b/obsarray/test/test_conventions.py @@ -0,0 +1,127 @@ +"""test_conventions - tests for obsarray.conventions""" + +import unittest +import warnings +import numpy as np +import xarray as xr +from obsarray import UNCConventionsWarning, create_ds, get_options, set_options +from obsarray import conventions +from obsarray.options import OPTIONS + +__author__ = "Sam Hunt " +__all__ = [] + + +def create_obs_ds(): + return xr.Dataset({"temperature": (["x"], np.ones(3), {"units": "K"})}) + + +class TestUNCConventionsWarning(unittest.TestCase): + def setUp(self): + # each test starts as a fresh session that hasn't warned yet + self.original_warned = conventions._warned + self.original_options = get_options() + conventions._warned = False + + self.ds = create_obs_ds() + + def tearDown(self): + conventions._warned = self.original_warned + OPTIONS.update(self.original_options) + + def add_unc_var(self, unc_var="u_noise"): + self.ds.unc["temperature"][unc_var] = (["x"], np.ones(3) * 0.1, {"units": "K"}) + + def assertNoUNCConventionsWarning(self, func, *args, **kwargs): + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + func(*args, **kwargs) + + self.assertFalse( + [w for w in caught if issubclass(w.category, UNCConventionsWarning)] + ) + + def test_is_future_warning(self): + # FutureWarning is shown to end users by default, unlike DeprecationWarning + self.assertTrue(issubclass(UNCConventionsWarning, FutureWarning)) + + def test_warns_on_adding_unc_var(self): + with self.assertWarns(UNCConventionsWarning): + self.add_unc_var() + + def test_warns_on_adding_unc_var_dataarray(self): + unc = xr.DataArray(np.ones(3) * 0.1, dims=["x"], attrs={"units": "K"}) + + with self.assertWarns(UNCConventionsWarning): + self.ds.unc["temperature"]["u_noise"] = unc + + def test_warns_on_template_unc_var(self): + template = { + "temperature": { + "dim": ["x"], + "dtype": np.float32, + "attributes": {"units": "K", "unc_comps": ["u_temperature"]}, + }, + "u_temperature": { + "dim": ["x"], + "dtype": np.float32, + "attributes": { + "units": "K", + "err_corr": [{"dim": "x", "form": "random", "params": []}], + }, + }, + } + + with self.assertWarns(UNCConventionsWarning): + create_ds(template, {"x": 3}) + + def test_warns_once_per_session(self): + with self.assertWarns(UNCConventionsWarning): + self.add_unc_var("u_noise") + + self.assertNoUNCConventionsWarning(self.add_unc_var, "u_calibration") + + def test_no_warning_when_conventions_set(self): + set_options(unc_conventions="v0.1") + + self.assertNoUNCConventionsWarning(self.add_unc_var) + + def test_no_warning_within_set_options_block(self): + with set_options(unc_conventions="v0.1"): + self.assertNoUNCConventionsWarning(self.add_unc_var, "u_noise") + + # not silenced for good - the next write outside the block still warns + with self.assertWarns(UNCConventionsWarning): + self.add_unc_var("u_calibration") + + def test_no_warning_on_read(self): + with set_options(unc_conventions="v0.1"): + self.add_unc_var() + + self.assertNoUNCConventionsWarning( + lambda: self.ds.unc["temperature"]["u_noise"].value + ) + + def test_warning_points_at_calling_code(self): + user_code = compile( + 'ds.unc["temperature"]["u_noise"] = (["x"], u, {"units": "K"})', + "user_script.py", + "exec", + ) + + with self.assertWarns(UNCConventionsWarning) as cm: + exec(user_code, {"ds": self.ds, "u": np.ones(3) * 0.1}) + + self.assertEqual(cm.filename, "user_script.py") + + def test_message_explains_how_to_silence(self): + with self.assertWarns(UNCConventionsWarning) as cm: + self.add_unc_var() + + message = str(cm.warning) + self.assertIn('set_options(unc_conventions="v0.1")', message) + self.assertIn(conventions.UNC_CONVENTIONS_DOCS_URL, message) + + +if __name__ == "__main__": + unittest.main() diff --git a/obsarray/test/test_options.py b/obsarray/test/test_options.py new file mode 100644 index 0000000..4bbea25 --- /dev/null +++ b/obsarray/test/test_options.py @@ -0,0 +1,51 @@ +"""test_options - tests for obsarray.options""" + +import unittest +from obsarray import set_options, get_options +from obsarray.options import OPTIONS + +__author__ = "Sam Hunt " +__all__ = [] + + +class TestSetOptions(unittest.TestCase): + def setUp(self): + self.original_options = get_options() + + def tearDown(self): + OPTIONS.update(self.original_options) + + def test_default(self): + self.assertIsNone(get_options()["unc_conventions"]) + + def test_set_globally(self): + set_options(unc_conventions="v0.1") + self.assertEqual(get_options()["unc_conventions"], "v0.1") + + def test_context_manager_restores(self): + with set_options(unc_conventions="v0.1"): + self.assertEqual(get_options()["unc_conventions"], "v0.1") + + self.assertIsNone(get_options()["unc_conventions"]) + + def test_context_manager_restores_on_error(self): + with self.assertRaises(RuntimeError): + with set_options(unc_conventions="v0.1"): + raise RuntimeError + + self.assertIsNone(get_options()["unc_conventions"]) + + def test_invalid_option(self): + self.assertRaises(ValueError, set_options, not_an_option="v0.1") + + def test_invalid_value(self): + self.assertRaises(ValueError, set_options, unc_conventions="v0.2") + self.assertIsNone(get_options()["unc_conventions"]) + + def test_get_options_returns_copy(self): + get_options()["unc_conventions"] = "v0.1" + self.assertIsNone(get_options()["unc_conventions"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/obsarray/unc_accessor.py b/obsarray/unc_accessor.py index 3b18559..d32b098 100644 --- a/obsarray/unc_accessor.py +++ b/obsarray/unc_accessor.py @@ -9,6 +9,7 @@ from obsarray.templater.dataset_util import DatasetUtil from obsarray.err_corr import err_corr_forms, BaseErrCorrForm from obsarray.utils import empty_err_corr_matrix +from obsarray.conventions import warn_unc_conventions __author__ = "Sam Hunt " @@ -853,6 +854,8 @@ def _add_unc_var( :param unc_def: either xarray DataArray/Variable, or definition through tuple as ``(dims, data[, attrs])``. ``dims`` is a list of variable dimension names, ``data`` is a numpy array of uncertainty values and ``attrs`` is a dictionary of variable attributes. ``attrs`` should include an element ``pdf_shape`` which defines the uncertainty probability density function form, and ``err_corr`` which defines the error-correlation structure of the data. If omitted ``pdf_shape`` is assumed Gaussian and the error-correlation is assumed random. """ + warn_unc_conventions() + # add uncertainty variable if type(unc_def) is xr.DataArray: self._obj[unc_var] = unc_def