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