climate-ref-example 0.14.5__tar.gz → 0.16.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. {climate_ref_example-0.14.5 → climate_ref_example-0.16.0}/.gitignore +3 -0
  2. {climate_ref_example-0.14.5 → climate_ref_example-0.16.0}/PKG-INFO +5 -5
  3. {climate_ref_example-0.14.5 → climate_ref_example-0.16.0}/README.md +1 -1
  4. {climate_ref_example-0.14.5 → climate_ref_example-0.16.0}/pyproject.toml +5 -4
  5. {climate_ref_example-0.14.5 → climate_ref_example-0.16.0}/src/climate_ref_example/__init__.py +3 -1
  6. {climate_ref_example-0.14.5 → climate_ref_example-0.16.0}/src/climate_ref_example/example.py +8 -20
  7. climate_ref_example-0.16.0/src/climate_ref_example/surface_temperature.py +501 -0
  8. climate_ref_example-0.16.0/tests/integration/test_diagnostics.py +5 -0
  9. climate_ref_example-0.16.0/tests/test-data/global-mean-timeseries/cmip7/catalog.yaml +84 -0
  10. climate_ref_example-0.16.0/tests/test-data/global-mean-timeseries/cmip7/manifest.json +29 -0
  11. {climate_ref_example-0.14.5/tests/test-data/global-mean-timeseries/default → climate_ref_example-0.16.0/tests/test-data/global-mean-timeseries/cmip7}/regression/diagnostic.json +1 -1
  12. climate_ref_example-0.16.0/tests/test-data/global-mean-timeseries/cmip7/regression/output.json +21 -0
  13. climate_ref_example-0.16.0/tests/test-data/global-mean-timeseries/cmip7/regression/series.json +1 -0
  14. {climate_ref_example-0.14.5 → climate_ref_example-0.16.0}/tests/test-data/global-mean-timeseries/default/catalog.yaml +30 -22
  15. climate_ref_example-0.16.0/tests/test-data/global-mean-timeseries/default/manifest.json +29 -0
  16. climate_ref_example-0.16.0/tests/test-data/global-mean-timeseries/default/regression/diagnostic.json +59 -0
  17. climate_ref_example-0.16.0/tests/test-data/global-mean-timeseries/default/regression/output.json +21 -0
  18. climate_ref_example-0.16.0/tests/test-data/global-mean-timeseries/default/regression/series.json +1 -0
  19. climate_ref_example-0.16.0/tests/test-data/global-sst-bias/cmip7/catalog.yaml +113 -0
  20. climate_ref_example-0.16.0/tests/test-data/global-sst-bias/cmip7/manifest.json +37 -0
  21. climate_ref_example-0.16.0/tests/test-data/global-sst-bias/cmip7/regression/diagnostic.json +54 -0
  22. climate_ref_example-0.16.0/tests/test-data/global-sst-bias/cmip7/regression/output.json +34 -0
  23. climate_ref_example-0.16.0/tests/test-data/global-sst-bias/cmip7/regression/series.json +923 -0
  24. climate_ref_example-0.16.0/tests/test-data/global-sst-bias/default/catalog.yaml +117 -0
  25. climate_ref_example-0.16.0/tests/test-data/global-sst-bias/default/manifest.json +37 -0
  26. climate_ref_example-0.16.0/tests/test-data/global-sst-bias/default/regression/diagnostic.json +54 -0
  27. climate_ref_example-0.16.0/tests/test-data/global-sst-bias/default/regression/output.json +34 -0
  28. climate_ref_example-0.16.0/tests/test-data/global-sst-bias/default/regression/series.json +923 -0
  29. {climate_ref_example-0.14.5 → climate_ref_example-0.16.0}/tests/unit/test_provider.py +1 -1
  30. climate_ref_example-0.16.0/tests/unit/test_surface_temperature.py +204 -0
  31. climate_ref_example-0.14.5/tests/integration/test_diagnostics.py +0 -95
  32. climate_ref_example-0.14.5/tests/test-data/global-mean-timeseries/default/regression/annual_mean_global_mean_timeseries.nc +0 -0
  33. climate_ref_example-0.14.5/tests/test-data/global-mean-timeseries/default/regression/output.json +0 -14
  34. climate_ref_example-0.14.5/tests/test-data/global-mean-timeseries/default/regression/series.json +0 -1
  35. {climate_ref_example-0.14.5 → climate_ref_example-0.16.0}/LICENCE +0 -0
  36. {climate_ref_example-0.14.5 → climate_ref_example-0.16.0}/NOTICE +0 -0
  37. {climate_ref_example-0.14.5 → climate_ref_example-0.16.0}/src/climate_ref_example/py.typed +0 -0
  38. {climate_ref_example-0.14.5 → climate_ref_example-0.16.0}/tests/unit/test_metrics.py +0 -0
@@ -167,6 +167,9 @@ packages/*/NOTICE
167
167
  # User-specific catalog paths (test data)
168
168
  *.paths.yaml
169
169
 
170
+ # Materialised test-case output slots (local inspection only; never committed)
171
+ **/test-data/**/output/
172
+
170
173
  # Debug log files
171
174
  **/main_log_debug.txt
172
175
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: climate-ref-example
3
- Version: 0.14.5
3
+ Version: 0.16.0
4
4
  Summary: Example diagnostic provider for the Rapid Evaluation Framework
5
5
  Author-email: Jared Lewis <jared.lewis@climate-resource.com>
6
6
  License-Expression: Apache-2.0
@@ -13,15 +13,15 @@ Classifier: License :: OSI Approved :: Apache Software License
13
13
  Classifier: Operating System :: OS Independent
14
14
  Classifier: Programming Language :: Python
15
15
  Classifier: Programming Language :: Python :: 3
16
- Classifier: Programming Language :: Python :: 3.11
17
16
  Classifier: Programming Language :: Python :: 3.12
18
17
  Classifier: Programming Language :: Python :: 3.13
19
18
  Classifier: Topic :: Scientific/Engineering
20
- Requires-Python: >=3.11
19
+ Requires-Python: >=3.12
21
20
  Requires-Dist: climate-ref-core
22
21
  Requires-Dist: dask>=2024.10.0
22
+ Requires-Dist: matplotlib>=3.5
23
23
  Requires-Dist: netcdf4>=1.5.0
24
- Requires-Dist: xarray>=2022
24
+ Requires-Dist: xarray>=2025.1.0
25
25
  Description-Content-Type: text/markdown
26
26
 
27
27
  # climate-ref-example
@@ -29,7 +29,7 @@ Description-Content-Type: text/markdown
29
29
  [![PyPI version](https://badge.fury.io/py/climate-ref-example.svg)](https://badge.fury.io/py/climate-ref-example)
30
30
  [![Documentation Status](https://readthedocs.org/projects/climate-ref/badge/?version=latest)](https://climate-ref.readthedocs.io/en/latest/?badge=latest)
31
31
  [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
32
- [![Python Version](https://img.shields.io/badge/python-3.9%2B-blue)](https://www.python.org/downloads/)
32
+ [![Python Version](https://img.shields.io/badge/python-3.12%2B-blue)](https://www.python.org/downloads/)
33
33
 
34
34
  This package provides an example implementation of a diagnostic provider for the Climate REF (Rapid Evaluation Framework).
35
35
  It serves as a template and reference for developers who want to create their own diagnostic providers.
@@ -3,7 +3,7 @@
3
3
  [![PyPI version](https://badge.fury.io/py/climate-ref-example.svg)](https://badge.fury.io/py/climate-ref-example)
4
4
  [![Documentation Status](https://readthedocs.org/projects/climate-ref/badge/?version=latest)](https://climate-ref.readthedocs.io/en/latest/?badge=latest)
5
5
  [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
6
- [![Python Version](https://img.shields.io/badge/python-3.9%2B-blue)](https://www.python.org/downloads/)
6
+ [![Python Version](https://img.shields.io/badge/python-3.12%2B-blue)](https://www.python.org/downloads/)
7
7
 
8
8
  This package provides an example implementation of a diagnostic provider for the Climate REF (Rapid Evaluation Framework).
9
9
  It serves as a template and reference for developers who want to create their own diagnostic providers.
@@ -1,13 +1,13 @@
1
1
  [project]
2
2
  name = "climate-ref-example"
3
- version = "0.14.5"
3
+ version = "0.16.0"
4
4
  description = "Example diagnostic provider for the Rapid Evaluation Framework"
5
5
  readme = "README.md"
6
6
  authors = [
7
7
  { name = "Jared Lewis", email = "jared.lewis@climate-resource.com" }
8
8
  ]
9
9
  license = "Apache-2.0"
10
- requires-python = ">=3.11"
10
+ requires-python = ">=3.12"
11
11
  classifiers = [
12
12
  "Development Status :: 3 - Alpha",
13
13
  "Operating System :: OS Independent",
@@ -15,7 +15,6 @@ classifiers = [
15
15
  "Intended Audience :: Science/Research",
16
16
  "Programming Language :: Python",
17
17
  "Programming Language :: Python :: 3",
18
- "Programming Language :: Python :: 3.11",
19
18
  "Programming Language :: Python :: 3.12",
20
19
  "Programming Language :: Python :: 3.13",
21
20
  "Topic :: Scientific/Engineering",
@@ -23,9 +22,11 @@ classifiers = [
23
22
  ]
24
23
  dependencies = [
25
24
  "climate-ref-core",
26
- "xarray >= 2022",
25
+ # CFDatetimeCoder is part of the public xarray.coders API from 2025.1.0.
26
+ "xarray >= 2025.1.0",
27
27
  "netcdf4>=1.5.0",
28
28
  "dask>=2024.10.0",
29
+ "matplotlib>=3.5",
29
30
  ]
30
31
  [project.entry-points."climate-ref.providers"]
31
32
  example = "climate_ref_example:provider"
@@ -6,9 +6,11 @@ import importlib.metadata
6
6
 
7
7
  from climate_ref_core.providers import DiagnosticProvider
8
8
  from climate_ref_example.example import GlobalMeanTimeseries
9
+ from climate_ref_example.surface_temperature import GlobalMeanSurfaceTemperatureBias
9
10
 
10
11
  __version__ = importlib.metadata.version("climate-ref-example")
11
12
 
12
- # Initialise the diagnostics manager and register the example diagnostic
13
+ # Initialise the diagnostics manager and register the example diagnostics
13
14
  provider = DiagnosticProvider("Example", __version__)
14
15
  provider.register(GlobalMeanTimeseries())
16
+ provider.register(GlobalMeanSurfaceTemperatureBias())
@@ -70,28 +70,16 @@ def format_cmec_output_bundle(dataset: xr.Dataset) -> dict[str, Any]:
70
70
  -------
71
71
  A CMEC output bundle ready to be written to disk
72
72
  """
73
- # TODO: Check how timeseries data are generally serialised
74
- # All keys listed in the sample are the CMEC keywords.
75
- # The value of diagnostics is the json file following the
76
- # CMEC diagnostic bundle standard. Only provenance is required,
77
- # others are optional
78
-
79
- # cmec_output = {
80
- # "index": "index.html",
81
- # "provenance": {
82
- # "environment": {},
83
- # "modeldata": [],
84
- # "obsdata": {},
85
- # "log": "cmec_output.log",
86
- # },
87
- # "data": {},
88
- # "html": {},
89
- # "metrics": {},
90
- # "plots": {},
91
- # }
92
- # create_template will generate the same above dictionary
93
73
  cmec_output = CMECOutput.create_template()
94
74
 
75
+ # Register the NetCDF output to persists it from an execution.
76
+ # The `data` section holds non-plot output files.
77
+ cmec_output["data"]["annual_mean_timeseries"] = {
78
+ "filename": "annual_mean_global_mean_timeseries.nc",
79
+ "long_name": "Annual Mean Global Mean Timeseries",
80
+ "description": "Area-weighted annual mean global mean timeseries for the input variable.",
81
+ }
82
+
95
83
  CMECOutput.model_validate(cmec_output)
96
84
 
97
85
  return cmec_output
@@ -0,0 +1,501 @@
1
+ from pathlib import Path
2
+ from typing import Any, cast
3
+
4
+ import numpy as np
5
+ import xarray as xr
6
+
7
+ from climate_ref_core.constraints import AddSupplementaryDataset, RequireContiguousTimerange
8
+ from climate_ref_core.datasets import FacetFilter, SourceDatasetType
9
+ from climate_ref_core.diagnostics import (
10
+ DataRequirement,
11
+ Diagnostic,
12
+ ExecutionDefinition,
13
+ ExecutionResult,
14
+ )
15
+ from climate_ref_core.esgf import CMIP6Request, CMIP7Request, RegistryRequest
16
+ from climate_ref_core.metric_values import SeriesMetricValue
17
+ from climate_ref_core.pycmec.metric import CMECMetric
18
+ from climate_ref_core.pycmec.output import CMECOutput
19
+ from climate_ref_core.testing import TestCase, TestDataSpecification
20
+
21
+ # Model variable: sea surface temperature on the ocean grid (CMIP `tos`, reported in degC).
22
+ _MODEL_VARIABLE = "tos"
23
+ # Ocean cell-area field used to area-weight the model mean.
24
+ _AREA_VARIABLE = "areacello"
25
+ # Reference observations: the HadISST-1-1 sea surface temperature record (CF name ``ts``,
26
+ # reported in K), distributed via the REF obs4REF registry. It is ocean-only.
27
+ _REFERENCE_VARIABLE = "ts"
28
+ _REFERENCE_SOURCE_ID = "HadISST-1-1"
29
+
30
+
31
+ def _to_celsius(da: xr.DataArray) -> xr.DataArray:
32
+ """Return ``da`` in degrees Celsius, converting from Kelvin when needed."""
33
+ units = str(da.attrs.get("units", "")).strip().lower()
34
+ if units in ("k", "kelvin", "degk", "deg_k"):
35
+ return da - 273.15
36
+ return da
37
+
38
+
39
+ def _global_mean_series(da: xr.DataArray, weights: xr.DataArray) -> xr.DataArray:
40
+ """
41
+ Reduce a gridded field to an annual, area-weighted global-mean series.
42
+
43
+ The weighted mean is taken over every non-time dimension, so it works for both a
44
+ regular latitude/longitude grid (weighted by cosine of latitude) and a curvilinear
45
+ ocean grid (weighted by ``areacello``). Land cells are excluded because the surface
46
+ temperature there is missing.
47
+
48
+ Parameters
49
+ ----------
50
+ da
51
+ Gridded field with a ``time`` dimension.
52
+ weights
53
+ Area weights broadcastable against the spatial dimensions of ``da``.
54
+
55
+ Returns
56
+ -------
57
+ :
58
+ Annual global mean, indexed by integer calendar ``year``.
59
+ """
60
+ spatial_dims = [dim for dim in da.dims if dim != "time"]
61
+ spatial_mean = da.weighted(weights.fillna(0)).mean(dim=spatial_dims, keep_attrs=True)
62
+ # Grouping by integer year keeps the model and reference series alignable even when
63
+ # their calendars differ. Materialise with ``.load()`` so it survives closing the file.
64
+ annual_mean = spatial_mean.groupby("time.year").mean().load()
65
+ return annual_mean
66
+
67
+
68
+ def model_global_mean_sst(input_files: list[Path]) -> xr.DataArray:
69
+ """
70
+ Annual area-weighted global-mean SST for the model, weighted by ``areacello``.
71
+
72
+ Parameters
73
+ ----------
74
+ input_files
75
+ The model ``tos`` files together with the ``areacello`` supplementary.
76
+ """
77
+ time_coder = xr.coders.CFDatetimeCoder(use_cftime=True)
78
+ with xr.open_mfdataset(
79
+ input_files,
80
+ combine="by_coords",
81
+ chunks=None,
82
+ decode_times=time_coder,
83
+ data_vars="all",
84
+ compat="no_conflicts",
85
+ ) as xr_ds:
86
+ sst = _to_celsius(xr_ds[_MODEL_VARIABLE])
87
+ series = _global_mean_series(sst, xr_ds[_AREA_VARIABLE])
88
+ series.name = _MODEL_VARIABLE
89
+ return series
90
+
91
+
92
+ def reference_global_mean_sst(input_files: list[Path]) -> xr.DataArray:
93
+ """
94
+ Annual global-mean SST for the reference, weighted by cosine of latitude.
95
+
96
+ The reference (HadISST-1-1) is on a regular latitude/longitude grid and is already
97
+ ocean-only, so cosine-of-latitude weighting is sufficient.
98
+ """
99
+ time_coder = xr.coders.CFDatetimeCoder(use_cftime=True)
100
+ with xr.open_mfdataset(
101
+ input_files,
102
+ combine="by_coords",
103
+ chunks=None,
104
+ decode_times=time_coder,
105
+ data_vars="all",
106
+ compat="no_conflicts",
107
+ ) as xr_ds:
108
+ sst = _to_celsius(xr_ds[_REFERENCE_VARIABLE])
109
+ weights = cast(xr.DataArray, np.cos(np.deg2rad(xr_ds["lat"])))
110
+ series = _global_mean_series(sst, weights)
111
+ series.name = _REFERENCE_VARIABLE
112
+ return series
113
+
114
+
115
+ def compare_model_and_reference(
116
+ model_series: xr.DataArray,
117
+ reference_series: xr.DataArray,
118
+ ) -> xr.Dataset:
119
+ """
120
+ Build a comparison dataset from a model and a reference series.
121
+
122
+ The two series are restricted to their overlapping years before the bias is computed,
123
+ so the result is well defined even when the model run and the observational record
124
+ cover different periods.
125
+
126
+ Returns
127
+ -------
128
+ :
129
+ Dataset exposing the aligned ``model``, ``reference`` and ``bias`` series (degC),
130
+ with the ``rmse`` and ``mean_bias`` scalar statistics stored as attributes.
131
+ """
132
+ common_years = np.intersect1d(model_series["year"].values, reference_series["year"].values)
133
+ if common_years.size == 0:
134
+ raise ValueError("The model and reference datasets do not share any overlapping years.")
135
+
136
+ model = model_series.sel(year=common_years)
137
+ reference = reference_series.sel(year=common_years)
138
+ bias = (model - reference).rename("bias")
139
+
140
+ rmse = float(np.sqrt((bias**2).mean()))
141
+ mean_bias = float(bias.mean())
142
+
143
+ comparison = xr.Dataset(
144
+ {
145
+ "model": model.rename("model"),
146
+ "reference": reference.rename("reference"),
147
+ "bias": bias,
148
+ }
149
+ )
150
+ comparison.attrs["rmse"] = rmse
151
+ comparison.attrs["mean_bias"] = mean_bias
152
+ comparison.attrs["reference_source_id"] = _REFERENCE_SOURCE_ID
153
+ return comparison
154
+
155
+
156
+ def plot_comparison(comparison: xr.Dataset, output_directory: Path) -> None:
157
+ """
158
+ Write the two summary figures for the comparison.
159
+
160
+ Produces ``surface_temperature_timeseries.png`` (model and reference series together)
161
+ and ``surface_temperature_bias.png`` (the model-minus-reference bias).
162
+ """
163
+ # Imported lazily so the provider module stays cheap to import for diagnostic discovery,
164
+ # and so the headless Agg backend is selected before pyplot is loaded.
165
+ import matplotlib # noqa: PLC0415
166
+
167
+ matplotlib.use("Agg")
168
+ import matplotlib.pyplot as plt # noqa: PLC0415
169
+
170
+ years = comparison["year"].values
171
+
172
+ # Figure 1 - model and reference series together ("A" and "B").
173
+ fig, ax = plt.subplots(figsize=(7, 4))
174
+ ax.plot(years, comparison["model"].values, label="Model", color="C1")
175
+ ax.plot(years, comparison["reference"].values, label=f"Reference ({_REFERENCE_SOURCE_ID})", color="C0")
176
+ ax.set_xlabel("Year")
177
+ ax.set_ylabel("Global-mean SST (degC)")
178
+ ax.set_title("Global-mean sea surface temperature")
179
+ ax.legend()
180
+ fig.tight_layout()
181
+ fig.savefig(output_directory / "surface_temperature_timeseries.png", dpi=120)
182
+ plt.close(fig)
183
+
184
+ # Figure 2 - the model-minus-reference bias ("A - B").
185
+ fig, ax = plt.subplots(figsize=(7, 4))
186
+ ax.plot(years, comparison["bias"].values, color="C3")
187
+ ax.axhline(0.0, color="k", linewidth=0.6)
188
+ ax.set_xlabel("Year")
189
+ ax.set_ylabel("Model - reference (degC)")
190
+ ax.set_title(f"Global-mean SST bias (model - {_REFERENCE_SOURCE_ID})")
191
+ fig.tight_layout()
192
+ fig.savefig(output_directory / "surface_temperature_bias.png", dpi=120)
193
+ plt.close(fig)
194
+
195
+
196
+ def format_cmec_output_bundle() -> dict[str, Any]:
197
+ """
198
+ Create a CMEC output bundle registering the comparison series and figures.
199
+ """
200
+ cmec_output = CMECOutput.create_template()
201
+
202
+ cmec_output["data"]["surface_temperature_comparison"] = {
203
+ "filename": "global_mean_surface_temperature.nc",
204
+ "long_name": "Global Mean Sea Surface Temperature: model, reference and bias",
205
+ "description": (
206
+ "Annual area-weighted global-mean sea surface temperature for the model "
207
+ "(tos, weighted by areacello) and the HadISST-1-1 reference, with the "
208
+ "model-minus-reference bias."
209
+ ),
210
+ }
211
+ cmec_output["plots"]["timeseries"] = {
212
+ "filename": "surface_temperature_timeseries.png",
213
+ "long_name": "Global-mean SST: model and reference",
214
+ "description": "Annual global-mean SST for the model and the HadISST-1-1 reference.",
215
+ }
216
+ cmec_output["plots"]["bias"] = {
217
+ "filename": "surface_temperature_bias.png",
218
+ "long_name": "Global-mean SST bias (model - reference)",
219
+ "description": "Annual model-minus-reference global-mean SST bias.",
220
+ }
221
+
222
+ CMECOutput.model_validate(cmec_output)
223
+ return cmec_output
224
+
225
+
226
+ def format_cmec_metric_bundle(comparison: xr.Dataset) -> dict[str, Any]:
227
+ """
228
+ Create a CMEC diagnostic bundle from the model-vs-reference comparison.
229
+ """
230
+ cmec_metric = {
231
+ "DIMENSIONS": {
232
+ "json_structure": [
233
+ "region",
234
+ "metric",
235
+ "statistic",
236
+ ],
237
+ "region": {"global": {}},
238
+ "metric": {_MODEL_VARIABLE: {}},
239
+ "statistic": {"rmse": {}, "mean-bias": {}},
240
+ },
241
+ "RESULTS": {
242
+ "global": {
243
+ _MODEL_VARIABLE: {
244
+ "rmse": comparison.attrs["rmse"],
245
+ "mean-bias": comparison.attrs["mean_bias"],
246
+ }
247
+ },
248
+ },
249
+ }
250
+
251
+ CMECMetric.model_validate(cmec_metric)
252
+ return cmec_metric
253
+
254
+
255
+ def build_series(
256
+ comparison: xr.Dataset,
257
+ model_selectors: dict[str, str],
258
+ reference_source_id: str,
259
+ ) -> list[SeriesMetricValue]:
260
+ """
261
+ Extract the model, reference and bias annual series as CMEC series values.
262
+
263
+ These are ingested into the REF database so the time series can be queried and plotted
264
+ alongside the scalar metrics, rather than only being available inside the NetCDF output.
265
+ The model and reference series share ``statistic="mean"`` and are distinguished by
266
+ ``source_id`` (the reference uses ``"Reference"``, following the convention used by other
267
+ providers); the bias series uses ``statistic="bias"``.
268
+ """
269
+ years = [int(year) for year in comparison["year"].values]
270
+ common = {
271
+ "experiment_id": model_selectors["experiment_id"],
272
+ "variant_label": model_selectors["variant_label"],
273
+ "reference_source_id": reference_source_id,
274
+ "region": "global",
275
+ "metric": _MODEL_VARIABLE,
276
+ }
277
+ return [
278
+ SeriesMetricValue(
279
+ dimensions={"source_id": model_selectors["source_id"], "statistic": "mean", **common},
280
+ values=comparison["model"].values.tolist(),
281
+ index=years,
282
+ index_name="year",
283
+ ),
284
+ SeriesMetricValue(
285
+ dimensions={"source_id": "Reference", "statistic": "mean", **common},
286
+ values=comparison["reference"].values.tolist(),
287
+ index=years,
288
+ index_name="year",
289
+ ),
290
+ SeriesMetricValue(
291
+ dimensions={"source_id": model_selectors["source_id"], "statistic": "bias", **common},
292
+ values=comparison["bias"].values.tolist(),
293
+ index=years,
294
+ index_name="year",
295
+ ),
296
+ ]
297
+
298
+
299
+ class GlobalMeanSurfaceTemperatureBias(Diagnostic):
300
+ """
301
+ Compare modelled sea surface temperature against observations.
302
+
303
+ This diagnostic computes the annual, area-weighted global-mean sea surface temperature for a model
304
+ (``tos``, weighted by ``areacello``) and for the HadISST-1-1 observational record,
305
+ then reports the root-mean-square error and mean bias between them.
306
+
307
+ This is a deliberately small model-versus-observation example that runs in seconds on the sample data,
308
+ and shows how a diagnostic combines a model dataset with a reference dataset.
309
+
310
+ Supports both CMIP6 and CMIP7 model datasets via alternative data requirements.
311
+ """
312
+
313
+ name = "Global Mean SST Bias"
314
+ slug = "global-sst-bias"
315
+
316
+ # Each option is an AND-group: a model requirement combined with the observational
317
+ # reference requirement. The diagnostic runs if either the CMIP6 or the CMIP7 model
318
+ # data is available alongside the reference dataset.
319
+ _reference_requirement = DataRequirement(
320
+ source_type=SourceDatasetType.obs4MIPs,
321
+ filters=(
322
+ FacetFilter(facets={"source_id": (_REFERENCE_SOURCE_ID,), "variable_id": (_REFERENCE_VARIABLE,)}),
323
+ ),
324
+ group_by=("source_id", "variable_id"),
325
+ )
326
+ data_requirements = (
327
+ # Option 1: CMIP6 model data + reference observations
328
+ (
329
+ DataRequirement(
330
+ source_type=SourceDatasetType.CMIP6,
331
+ filters=(
332
+ FacetFilter(
333
+ facets={
334
+ "variable_id": (_MODEL_VARIABLE,),
335
+ "experiment_id": ("historical",),
336
+ }
337
+ ),
338
+ ),
339
+ group_by=("source_id", "variable_id", "experiment_id", "variant_label"),
340
+ constraints=(
341
+ RequireContiguousTimerange(group_by=("instance_id",)),
342
+ AddSupplementaryDataset.from_defaults(_AREA_VARIABLE, SourceDatasetType.CMIP6),
343
+ ),
344
+ ),
345
+ _reference_requirement,
346
+ ),
347
+ # Option 2: CMIP7 model data + reference observations
348
+ (
349
+ DataRequirement(
350
+ source_type=SourceDatasetType.CMIP7,
351
+ filters=(
352
+ FacetFilter(
353
+ facets={
354
+ "variable_id": (_MODEL_VARIABLE,),
355
+ "experiment_id": ("historical",),
356
+ }
357
+ ),
358
+ ),
359
+ group_by=("source_id", "variable_id", "experiment_id", "variant_label"),
360
+ constraints=(
361
+ RequireContiguousTimerange(group_by=("instance_id",)),
362
+ AddSupplementaryDataset.from_defaults(_AREA_VARIABLE, SourceDatasetType.CMIP7),
363
+ ),
364
+ ),
365
+ _reference_requirement,
366
+ ),
367
+ )
368
+ facets = (
369
+ "source_id",
370
+ "experiment_id",
371
+ "variant_label",
372
+ "reference_source_id",
373
+ "region",
374
+ "metric",
375
+ "statistic",
376
+ )
377
+
378
+ test_data_spec = TestDataSpecification(
379
+ test_cases=(
380
+ TestCase(
381
+ name="default",
382
+ description="Historical tos from ACCESS-ESM1-5 compared against HadISST-1-1 observations",
383
+ requests=(
384
+ RegistryRequest(
385
+ slug="surface-temperature-obs",
386
+ registry_name="obs4ref",
387
+ source_type="obs4MIPs",
388
+ facets={"source_id": _REFERENCE_SOURCE_ID, "variable_id": _REFERENCE_VARIABLE},
389
+ ),
390
+ CMIP6Request(
391
+ slug="surface-temperature-tos",
392
+ facets={
393
+ "source_id": "ACCESS-ESM1-5",
394
+ "experiment_id": "historical",
395
+ "variable_id": _MODEL_VARIABLE,
396
+ "member_id": "r1i1p1f1",
397
+ "table_id": "Omon",
398
+ },
399
+ time_span=("2000-01", "2014-12"),
400
+ ),
401
+ CMIP6Request(
402
+ slug="surface-temperature-areacello",
403
+ facets={
404
+ "source_id": "ACCESS-ESM1-5",
405
+ "experiment_id": "historical",
406
+ "variable_id": _AREA_VARIABLE,
407
+ "member_id": "r1i1p1f1",
408
+ "table_id": "Ofx",
409
+ },
410
+ ),
411
+ ),
412
+ ),
413
+ TestCase(
414
+ name="cmip7",
415
+ description="CMIP7 historical tos from ACCESS-ESM1-5 compared against HadISST-1-1",
416
+ requests=(
417
+ RegistryRequest(
418
+ slug="surface-temperature-obs-cmip7",
419
+ registry_name="obs4ref",
420
+ source_type="obs4MIPs",
421
+ facets={"source_id": _REFERENCE_SOURCE_ID, "variable_id": _REFERENCE_VARIABLE},
422
+ ),
423
+ CMIP7Request(
424
+ slug="surface-temperature-tos-cmip7",
425
+ facets={
426
+ "source_id": "ACCESS-ESM1-5",
427
+ "experiment_id": "historical",
428
+ "variable_id": _MODEL_VARIABLE,
429
+ "variant_label": "r1i1p1f1",
430
+ "table_id": "Omon",
431
+ },
432
+ time_span=("2000-01", "2014-12"),
433
+ ),
434
+ CMIP7Request(
435
+ slug="surface-temperature-areacello-cmip7",
436
+ facets={
437
+ "source_id": "ACCESS-ESM1-5",
438
+ "experiment_id": "historical",
439
+ "variable_id": _AREA_VARIABLE,
440
+ "variant_label": "r1i1p1f1",
441
+ "table_id": "Ofx",
442
+ },
443
+ ),
444
+ ),
445
+ ),
446
+ ),
447
+ )
448
+
449
+ def _get_model_source_type(self, definition: ExecutionDefinition) -> SourceDatasetType:
450
+ """Determine which model source type is present in the datasets."""
451
+ if SourceDatasetType.CMIP7 in definition.datasets:
452
+ return SourceDatasetType.CMIP7
453
+ return SourceDatasetType.CMIP6
454
+
455
+ def execute(self, definition: ExecutionDefinition) -> None:
456
+ """
457
+ Run the diagnostic.
458
+
459
+ Parameters
460
+ ----------
461
+ definition
462
+ A description of the information needed for this execution of the diagnostic.
463
+ """
464
+ model_source_type = self._get_model_source_type(definition)
465
+ model_datasets = definition.datasets[model_source_type]
466
+ reference_datasets = definition.datasets[SourceDatasetType.obs4MIPs]
467
+
468
+ model_series = model_global_mean_sst(model_datasets.path.to_list())
469
+ reference_series = reference_global_mean_sst(reference_datasets.path.to_list())
470
+
471
+ comparison = compare_model_and_reference(model_series, reference_series)
472
+ comparison.to_netcdf(definition.output_directory / "global_mean_surface_temperature.nc")
473
+ plot_comparison(comparison, definition.output_directory)
474
+
475
+ def build_execution_result(self, definition: ExecutionDefinition) -> ExecutionResult:
476
+ """
477
+ Create a result object from the output of the diagnostic.
478
+ """
479
+ with xr.open_dataset(
480
+ definition.output_directory / "global_mean_surface_temperature.nc"
481
+ ) as comparison:
482
+ model_source_type = self._get_model_source_type(definition)
483
+ model_selectors = definition.datasets[model_source_type].selector_dict()
484
+ reference_selectors = definition.datasets[SourceDatasetType.obs4MIPs].selector_dict()
485
+
486
+ cmec_metric_bundle = CMECMetric(**format_cmec_metric_bundle(comparison)).prepend_dimensions(
487
+ {
488
+ "source_id": model_selectors["source_id"],
489
+ "experiment_id": model_selectors["experiment_id"],
490
+ "variant_label": model_selectors["variant_label"],
491
+ "reference_source_id": reference_selectors["source_id"],
492
+ }
493
+ )
494
+ series = build_series(comparison, model_selectors, reference_selectors["source_id"])
495
+
496
+ return ExecutionResult.build_from_output_bundle(
497
+ definition,
498
+ cmec_output_bundle=format_cmec_output_bundle(),
499
+ cmec_metric_bundle=cmec_metric_bundle,
500
+ series=series,
501
+ )
@@ -0,0 +1,5 @@
1
+ from climate_ref_example import provider
2
+
3
+ from climate_ref.testing import create_no_drift_test
4
+
5
+ test_run_test_cases = create_no_drift_test(provider)