From 79efc233100574065bc28c089dbcc52aefb465c2 Mon Sep 17 00:00:00 2001 From: seh2 Date: Tue, 27 Oct 2026 17:57:02 +0000 Subject: [PATCH 1/4] Warn users about the upcoming move to UNC draft v0.2 obsarray writes uncertainty metadata following v0.1 of the UNC specification. Draft v0.2 is being adopted into the CF conventions, is not backwards compatible with v0.1, and will become obsarray's default in a future release. - Add obsarray.set_options / get_options, with an unc_conventions option. "v0.1" (current behaviour) is the only valid value for now; the option is intended to become the conventions backend selector. - Add UNCConventionsWarning (a FutureWarning, so shown to end users by default). It is issued once per session, the first time obsarray writes uncertainty metadata (adding an uncertainty variable via the unc accessor or the templater), unless unc_conventions has been set explicitly. Reading uncertainty information never warns. - The warning's stacklevel is found dynamically so it points at the first frame outside obsarray, i.e. the user's own code. --- obsarray/__init__.py | 2 + obsarray/conventions.py | 84 +++++++++++++++++++ obsarray/options.py | 78 ++++++++++++++++++ obsarray/templater/dataset_util.py | 3 + obsarray/test/test_conventions.py | 127 +++++++++++++++++++++++++++++ obsarray/test/test_options.py | 51 ++++++++++++ obsarray/unc_accessor.py | 3 + 7 files changed, 348 insertions(+) create mode 100644 obsarray/conventions.py create mode 100644 obsarray/options.py create mode 100644 obsarray/test/test_conventions.py create mode 100644 obsarray/test/test_options.py 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..f096462 --- /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 being " + "adopted 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 From 19b9967da188c0af1613a34f11669b1b129a3e48 Mon Sep 17 00:00:00 2001 From: seh2 Date: Tue, 27 Oct 2026 17:57:02 +0000 Subject: [PATCH 2/4] docs: explain the upcoming UNC draft v0.2 change - New user guide page covering what changes between v0.1 and draft v0.2, when the warning is shown, how to choose the conventions version or filter the warning, and guidance for packages built on obsarray - Site-wide announcement banner linking to it - Add set_options, get_options and UNCConventionsWarning to the API reference - Note the upcoming change in the README --- README.md | 8 +++ docs/conf.py | 8 ++- docs/content/user/api.rst | 12 +++- docs/content/user/unc_conventions.rst | 89 +++++++++++++++++++++++++++ docs/content/user/user_guide.rst | 3 +- 5 files changed, 117 insertions(+), 3 deletions(-) create mode 100644 docs/content/user/unc_conventions.rst diff --git a/README.md b/README.md index d2023d3..ededa89 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 being adopted into 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 From 1d8441a678c9cf4c0ee456dd7bdedc1570f602fa Mon Sep 17 00:00:00 2001 From: seh2 Date: Tue, 27 Oct 2026 18:06:43 +0000 Subject: [PATCH 3/4] amend warning --- obsarray/conventions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/obsarray/conventions.py b/obsarray/conventions.py index f096462..881facf 100644 --- a/obsarray/conventions.py +++ b/obsarray/conventions.py @@ -16,8 +16,8 @@ _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 being " - "adopted into the CF conventions and is not backwards compatible with v0.1 (for " + "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 " From 4df2e9297f0743976a3ab58634479e47edbb80c6 Mon Sep 17 00:00:00 2001 From: seh2 Date: Tue, 27 Oct 2026 18:08:33 +0000 Subject: [PATCH 4/4] README: describe UNC draft v0.2 as proposed for CF, matching the warning --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ededa89..d361398 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ > [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 being adopted into the CF conventions and is not backwards +> 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).