climate-ref-example 0.14.7__tar.gz → 0.15.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.
- {climate_ref_example-0.14.7 → climate_ref_example-0.15.0}/.gitignore +3 -0
- {climate_ref_example-0.14.7 → climate_ref_example-0.15.0}/PKG-INFO +5 -5
- {climate_ref_example-0.14.7 → climate_ref_example-0.15.0}/README.md +1 -1
- {climate_ref_example-0.14.7 → climate_ref_example-0.15.0}/pyproject.toml +5 -4
- {climate_ref_example-0.14.7 → climate_ref_example-0.15.0}/src/climate_ref_example/__init__.py +3 -1
- {climate_ref_example-0.14.7 → climate_ref_example-0.15.0}/src/climate_ref_example/example.py +8 -20
- climate_ref_example-0.15.0/src/climate_ref_example/surface_temperature.py +501 -0
- {climate_ref_example-0.14.7 → climate_ref_example-0.15.0}/tests/integration/test_diagnostics.py +10 -0
- climate_ref_example-0.15.0/tests/test-data/global-mean-timeseries/cmip7/catalog.yaml +84 -0
- climate_ref_example-0.15.0/tests/test-data/global-mean-timeseries/cmip7/manifest.json +29 -0
- {climate_ref_example-0.14.7/tests/test-data/global-mean-timeseries/default → climate_ref_example-0.15.0/tests/test-data/global-mean-timeseries/cmip7}/regression/diagnostic.json +1 -1
- climate_ref_example-0.15.0/tests/test-data/global-mean-timeseries/cmip7/regression/output.json +21 -0
- climate_ref_example-0.15.0/tests/test-data/global-mean-timeseries/cmip7/regression/series.json +1 -0
- {climate_ref_example-0.14.7 → climate_ref_example-0.15.0}/tests/test-data/global-mean-timeseries/default/catalog.yaml +30 -22
- climate_ref_example-0.15.0/tests/test-data/global-mean-timeseries/default/manifest.json +29 -0
- climate_ref_example-0.15.0/tests/test-data/global-mean-timeseries/default/regression/diagnostic.json +59 -0
- climate_ref_example-0.15.0/tests/test-data/global-mean-timeseries/default/regression/output.json +21 -0
- climate_ref_example-0.15.0/tests/test-data/global-mean-timeseries/default/regression/series.json +1 -0
- climate_ref_example-0.15.0/tests/test-data/global-sst-bias/cmip7/catalog.yaml +113 -0
- climate_ref_example-0.15.0/tests/test-data/global-sst-bias/cmip7/manifest.json +37 -0
- climate_ref_example-0.15.0/tests/test-data/global-sst-bias/cmip7/regression/diagnostic.json +54 -0
- climate_ref_example-0.15.0/tests/test-data/global-sst-bias/cmip7/regression/output.json +34 -0
- climate_ref_example-0.15.0/tests/test-data/global-sst-bias/cmip7/regression/series.json +923 -0
- climate_ref_example-0.15.0/tests/test-data/global-sst-bias/default/catalog.yaml +117 -0
- climate_ref_example-0.15.0/tests/test-data/global-sst-bias/default/manifest.json +37 -0
- climate_ref_example-0.15.0/tests/test-data/global-sst-bias/default/regression/diagnostic.json +54 -0
- climate_ref_example-0.15.0/tests/test-data/global-sst-bias/default/regression/output.json +34 -0
- climate_ref_example-0.15.0/tests/test-data/global-sst-bias/default/regression/series.json +923 -0
- {climate_ref_example-0.14.7 → climate_ref_example-0.15.0}/tests/unit/test_provider.py +1 -1
- climate_ref_example-0.15.0/tests/unit/test_surface_temperature.py +204 -0
- climate_ref_example-0.14.7/tests/test-data/global-mean-timeseries/default/regression/annual_mean_global_mean_timeseries.nc +0 -0
- climate_ref_example-0.14.7/tests/test-data/global-mean-timeseries/default/regression/output.json +0 -14
- climate_ref_example-0.14.7/tests/test-data/global-mean-timeseries/default/regression/series.json +0 -1
- {climate_ref_example-0.14.7 → climate_ref_example-0.15.0}/LICENCE +0 -0
- {climate_ref_example-0.14.7 → climate_ref_example-0.15.0}/NOTICE +0 -0
- {climate_ref_example-0.14.7 → climate_ref_example-0.15.0}/src/climate_ref_example/py.typed +0 -0
- {climate_ref_example-0.14.7 → climate_ref_example-0.15.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.
|
|
3
|
+
Version: 0.15.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.
|
|
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>=
|
|
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
|
[](https://badge.fury.io/py/climate-ref-example)
|
|
30
30
|
[](https://climate-ref.readthedocs.io/en/latest/?badge=latest)
|
|
31
31
|
[](https://opensource.org/licenses/Apache-2.0)
|
|
32
|
-
[](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
|
[](https://badge.fury.io/py/climate-ref-example)
|
|
4
4
|
[](https://climate-ref.readthedocs.io/en/latest/?badge=latest)
|
|
5
5
|
[](https://opensource.org/licenses/Apache-2.0)
|
|
6
|
-
[](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.
|
|
3
|
+
version = "0.15.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.
|
|
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
|
-
|
|
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"
|
{climate_ref_example-0.14.7 → climate_ref_example-0.15.0}/src/climate_ref_example/__init__.py
RENAMED
|
@@ -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
|
|
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())
|
{climate_ref_example-0.14.7 → climate_ref_example-0.15.0}/src/climate_ref_example/example.py
RENAMED
|
@@ -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
|
+
)
|
{climate_ref_example-0.14.7 → climate_ref_example-0.15.0}/tests/integration/test_diagnostics.py
RENAMED
|
@@ -22,6 +22,12 @@ def provider_test_data_dir() -> Path:
|
|
|
22
22
|
test_case_params = collect_test_case_params(provider)
|
|
23
23
|
|
|
24
24
|
|
|
25
|
+
@pytest.mark.skip(
|
|
26
|
+
reason="Parked: RegressionValidator replays the committed bundle offline, but native baselines "
|
|
27
|
+
"now live in the object store (Framework B), so build_execution_result cannot rebuild from the "
|
|
28
|
+
"repo alone. `ref test-cases replay` provides regression coverage via the store; this offline "
|
|
29
|
+
"path is retained (body intact) for local step-through debugging."
|
|
30
|
+
)
|
|
25
31
|
@pytest.mark.parametrize("diagnostic,test_case_name", test_case_params)
|
|
26
32
|
def test_validate_test_case_regression(
|
|
27
33
|
diagnostic: Diagnostic,
|
|
@@ -54,6 +60,10 @@ def test_validate_test_case_regression(
|
|
|
54
60
|
if not validator.has_regression_data():
|
|
55
61
|
pytest.skip(f"No regression data for {diagnostic.slug}/{test_case_name}")
|
|
56
62
|
|
|
63
|
+
# TODO: remove this once we have migrated test cases to use the committed bundles.
|
|
64
|
+
if not any(paths.regression.glob("*.nc")):
|
|
65
|
+
pytest.skip(f"No committed NetCDF baseline for {diagnostic.slug}/{test_case_name}")
|
|
66
|
+
|
|
57
67
|
definition = validator.load_regression_definition(tmp_path / diagnostic.slug / test_case_name)
|
|
58
68
|
validator.validate(definition)
|
|
59
69
|
|