climate-ref-pmp 0.5.5__tar.gz → 0.6.1__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 (29) hide show
  1. {climate_ref_pmp-0.5.5 → climate_ref_pmp-0.6.1}/PKG-INFO +3 -3
  2. {climate_ref_pmp-0.5.5 → climate_ref_pmp-0.6.1}/pyproject.toml +4 -5
  3. {climate_ref_pmp-0.5.5 → climate_ref_pmp-0.6.1}/src/climate_ref_pmp/__init__.py +10 -2
  4. {climate_ref_pmp-0.5.5 → climate_ref_pmp-0.6.1}/src/climate_ref_pmp/diagnostics/__init__.py +2 -0
  5. {climate_ref_pmp-0.5.5 → climate_ref_pmp-0.6.1}/src/climate_ref_pmp/diagnostics/annual_cycle.py +51 -42
  6. climate_ref_pmp-0.6.1/src/climate_ref_pmp/diagnostics/enso.py +245 -0
  7. {climate_ref_pmp-0.5.5 → climate_ref_pmp-0.6.1}/src/climate_ref_pmp/diagnostics/variability_modes.py +70 -7
  8. climate_ref_pmp-0.6.1/src/climate_ref_pmp/drivers/enso_driver.py +458 -0
  9. {climate_ref_pmp-0.5.5 → climate_ref_pmp-0.6.1}/src/climate_ref_pmp/requirements/conda-lock.yml +1809 -2362
  10. {climate_ref_pmp-0.5.5 → climate_ref_pmp-0.6.1}/src/climate_ref_pmp/requirements/environment.yml +1 -0
  11. {climate_ref_pmp-0.5.5 → climate_ref_pmp-0.6.1}/tests/integration/test_diagnostics.py +4 -0
  12. {climate_ref_pmp-0.5.5 → climate_ref_pmp-0.6.1}/tests/unit/conftest.py +1 -1
  13. {climate_ref_pmp-0.5.5 → climate_ref_pmp-0.6.1}/tests/unit/test_annual_cycle.py +143 -21
  14. climate_ref_pmp-0.6.1/tests/unit/test_enso.py +33 -0
  15. {climate_ref_pmp-0.5.5 → climate_ref_pmp-0.6.1}/tests/unit/test_variability_modes.py +1 -12
  16. {climate_ref_pmp-0.5.5 → climate_ref_pmp-0.6.1}/.gitignore +0 -0
  17. {climate_ref_pmp-0.5.5 → climate_ref_pmp-0.6.1}/LICENCE +0 -0
  18. {climate_ref_pmp-0.5.5 → climate_ref_pmp-0.6.1}/NOTICE +0 -0
  19. {climate_ref_pmp-0.5.5 → climate_ref_pmp-0.6.1}/README.md +0 -0
  20. {climate_ref_pmp-0.5.5 → climate_ref_pmp-0.6.1}/conftest.py +0 -0
  21. {climate_ref_pmp-0.5.5 → climate_ref_pmp-0.6.1}/src/climate_ref_pmp/dataset_registry/pmp_climatology.txt +0 -0
  22. {climate_ref_pmp-0.5.5 → climate_ref_pmp-0.6.1}/src/climate_ref_pmp/params/pmp_param_MoV-psl.py +0 -0
  23. {climate_ref_pmp-0.5.5 → climate_ref_pmp-0.6.1}/src/climate_ref_pmp/params/pmp_param_MoV-ts.py +0 -0
  24. {climate_ref_pmp-0.5.5 → climate_ref_pmp-0.6.1}/src/climate_ref_pmp/params/pmp_param_annualcycle_1-clims.py +0 -0
  25. {climate_ref_pmp-0.5.5 → climate_ref_pmp-0.6.1}/src/climate_ref_pmp/params/pmp_param_annualcycle_2-metrics.py +0 -0
  26. {climate_ref_pmp-0.5.5 → climate_ref_pmp-0.6.1}/src/climate_ref_pmp/pmp_driver.py +0 -0
  27. {climate_ref_pmp-0.5.5 → climate_ref_pmp-0.6.1}/src/climate_ref_pmp/py.typed +0 -0
  28. {climate_ref_pmp-0.5.5 → climate_ref_pmp-0.6.1}/tests/unit/test_pmp_driver.py +0 -0
  29. {climate_ref_pmp-0.5.5 → climate_ref_pmp-0.6.1}/tests/unit/test_provider.py +0 -0
@@ -1,9 +1,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: climate-ref-pmp
3
- Version: 0.5.5
3
+ Version: 0.6.1
4
4
  Summary: PMP diagnostic provider for the Rapid Evaluation Framework
5
- Author-email: Jiwoo Lee <jwlee@llnl.gov>
6
- License: Apache-2.0
5
+ Author-email: Jiwoo Lee <jwlee@llnl.gov>, Jared Lewis <jared.lewis@climate-resource.com>
6
+ License-Expression: Apache-2.0
7
7
  License-File: LICENCE
8
8
  License-File: NOTICE
9
9
  Classifier: Development Status :: 3 - Alpha
@@ -1,11 +1,13 @@
1
1
  [project]
2
2
  name = "climate-ref-pmp"
3
- version = "0.5.5"
3
+ version = "0.6.1"
4
4
  description = "PMP diagnostic provider for the Rapid Evaluation Framework"
5
5
  readme = "README.md"
6
6
  authors = [
7
- { name = "Jiwoo Lee", email = "jwlee@llnl.gov" }
7
+ { name = "Jiwoo Lee", email = "jwlee@llnl.gov" },
8
+ { name = "Jared Lewis", email = "jared.lewis@climate-resource.com" },
8
9
  ]
10
+ license = "Apache-2.0"
9
11
  requires-python = ">=3.11"
10
12
  classifiers = [
11
13
  "Development Status :: 3 - Alpha",
@@ -24,9 +26,6 @@ dependencies = [
24
26
  "climate-ref-core",
25
27
  ]
26
28
 
27
- [project.license]
28
- text = "Apache-2.0"
29
-
30
29
  [dependency-groups]
31
30
  dev = []
32
31
 
@@ -6,7 +6,7 @@ import importlib.metadata
6
6
 
7
7
  from climate_ref_core.dataset_registry import DATASET_URL, dataset_registry_manager
8
8
  from climate_ref_core.providers import CondaDiagnosticProvider
9
- from climate_ref_pmp.diagnostics import AnnualCycle, ExtratropicalModesOfVariability
9
+ from climate_ref_pmp.diagnostics import ENSO, AnnualCycle, ExtratropicalModesOfVariability
10
10
 
11
11
  __version__ = importlib.metadata.version("climate-ref-pmp")
12
12
 
@@ -14,6 +14,15 @@ __version__ = importlib.metadata.version("climate-ref-pmp")
14
14
  # PMP uses a conda environment to run the diagnostics
15
15
  provider = CondaDiagnosticProvider("PMP", __version__)
16
16
 
17
+ # Annual cycle diagnostics and metrics
18
+ provider.register(AnnualCycle())
19
+
20
+ # ENSO diagnostics and metrics
21
+ # provider.register(ENSO("ENSO_perf")) # Assigned to ESMValTool
22
+ provider.register(ENSO("ENSO_tel"))
23
+ provider.register(ENSO("ENSO_proc"))
24
+
25
+ # Extratropical modes of variability diagnostics and metrics
17
26
  provider.register(ExtratropicalModesOfVariability("PDO"))
18
27
  provider.register(ExtratropicalModesOfVariability("NPGO"))
19
28
  provider.register(ExtratropicalModesOfVariability("NAO"))
@@ -21,7 +30,6 @@ provider.register(ExtratropicalModesOfVariability("NAM"))
21
30
  provider.register(ExtratropicalModesOfVariability("PNA"))
22
31
  provider.register(ExtratropicalModesOfVariability("NPO"))
23
32
  provider.register(ExtratropicalModesOfVariability("SAM"))
24
- provider.register(AnnualCycle())
25
33
 
26
34
 
27
35
  dataset_registry_manager.register(
@@ -1,9 +1,11 @@
1
1
  """PMP diagnostics."""
2
2
 
3
3
  from climate_ref_pmp.diagnostics.annual_cycle import AnnualCycle
4
+ from climate_ref_pmp.diagnostics.enso import ENSO
4
5
  from climate_ref_pmp.diagnostics.variability_modes import ExtratropicalModesOfVariability
5
6
 
6
7
  __all__ = [
8
+ "ENSO",
7
9
  "AnnualCycle",
8
10
  "ExtratropicalModesOfVariability",
9
11
  ]
@@ -15,6 +15,44 @@ from climate_ref_core.pycmec.metric import remove_dimensions
15
15
  from climate_ref_pmp.pmp_driver import build_glob_pattern, build_pmp_command, process_json_result
16
16
 
17
17
 
18
+ def make_data_requirement(variable_id: str, obs_source: str) -> tuple[DataRequirement, DataRequirement]:
19
+ """
20
+ Create a data requirement for the annual cycle diagnostic.
21
+
22
+ Parameters
23
+ ----------
24
+ variable_id : str
25
+ The variable ID to filter the data requirement.
26
+ obs_source : str
27
+ The observation source ID to filter the data requirement.
28
+
29
+ Returns
30
+ -------
31
+ DataRequirement
32
+ A DataRequirement object containing the necessary filters and groupings.
33
+ """
34
+ return (
35
+ DataRequirement(
36
+ source_type=SourceDatasetType.PMPClimatology,
37
+ filters=(FacetFilter(facets={"source_id": (obs_source,), "variable_id": (variable_id,)}),),
38
+ group_by=("variable_id", "source_id"),
39
+ ),
40
+ DataRequirement(
41
+ source_type=SourceDatasetType.CMIP6,
42
+ filters=(
43
+ FacetFilter(
44
+ facets={
45
+ "frequency": "mon",
46
+ "experiment_id": ("amip", "historical", "hist-GHG", "piControl"),
47
+ "variable_id": (variable_id,),
48
+ }
49
+ ),
50
+ ),
51
+ group_by=("variable_id", "source_id", "experiment_id", "member_id", "grid_label"),
52
+ ),
53
+ )
54
+
55
+
18
56
  class AnnualCycle(CommandLineDiagnostic):
19
57
  """
20
58
  Calculate the annual cycle for a dataset
@@ -32,49 +70,20 @@ class AnnualCycle(CommandLineDiagnostic):
32
70
  "statistic",
33
71
  "season",
34
72
  )
73
+
35
74
  data_requirements = (
36
- # Surface temperature
37
- (
38
- DataRequirement(
39
- source_type=SourceDatasetType.PMPClimatology,
40
- filters=(FacetFilter(facets={"source_id": ("ERA-5",), "variable_id": ("ts",)}),),
41
- group_by=("variable_id", "source_id"),
42
- ),
43
- DataRequirement(
44
- source_type=SourceDatasetType.CMIP6,
45
- filters=(
46
- FacetFilter(
47
- facets={
48
- "frequency": "mon",
49
- "experiment_id": ("amip", "historical", "hist-GHG", "piControl"),
50
- "variable_id": ("ts",),
51
- }
52
- ),
53
- ),
54
- group_by=("variable_id", "source_id", "experiment_id", "member_id"),
55
- ),
56
- ),
57
- # Precipitation
58
- (
59
- DataRequirement(
60
- source_type=SourceDatasetType.PMPClimatology,
61
- filters=(FacetFilter(facets={"source_id": ("GPCP-Monthly-3-2",), "variable_id": ("pr",)}),),
62
- group_by=("variable_id", "source_id"),
63
- ),
64
- DataRequirement(
65
- source_type=SourceDatasetType.CMIP6,
66
- filters=(
67
- FacetFilter(
68
- facets={
69
- "frequency": "mon",
70
- "experiment_id": ("amip", "historical", "hist-GHG", "piControl"),
71
- "variable_id": ("pr",),
72
- }
73
- ),
74
- ),
75
- group_by=("variable_id", "source_id", "experiment_id", "member_id"),
76
- ),
77
- ),
75
+ make_data_requirement("ts", "ERA-5"),
76
+ make_data_requirement("uas", "ERA-5"),
77
+ make_data_requirement("vas", "ERA-5"),
78
+ make_data_requirement("psl", "ERA-5"),
79
+ make_data_requirement("pr", "GPCP-Monthly-3-2"),
80
+ make_data_requirement("rlds", "CERES-EBAF-4-2"),
81
+ make_data_requirement("rlus", "CERES-EBAF-4-2"),
82
+ make_data_requirement("rlut", "CERES-EBAF-4-2"),
83
+ make_data_requirement("rsds", "CERES-EBAF-4-2"),
84
+ make_data_requirement("rsdt", "CERES-EBAF-4-2"),
85
+ make_data_requirement("rsus", "CERES-EBAF-4-2"),
86
+ make_data_requirement("rsut", "CERES-EBAF-4-2"),
78
87
  )
79
88
 
80
89
  def __init__(self) -> None:
@@ -0,0 +1,245 @@
1
+ import json
2
+ import os
3
+ from collections.abc import Collection, Iterable
4
+ from typing import Any
5
+
6
+ from loguru import logger
7
+
8
+ from climate_ref_core.constraints import AddSupplementaryDataset
9
+ from climate_ref_core.datasets import DatasetCollection, FacetFilter, SourceDatasetType
10
+ from climate_ref_core.diagnostics import (
11
+ CommandLineDiagnostic,
12
+ DataRequirement,
13
+ ExecutionDefinition,
14
+ ExecutionResult,
15
+ )
16
+ from climate_ref_pmp.pmp_driver import _get_resource, process_json_result
17
+
18
+
19
+ class ENSO(CommandLineDiagnostic):
20
+ """
21
+ Calculate the ENSO performance metrics for a dataset
22
+ """
23
+
24
+ facets = ("source_id", "member_id", "grid_label", "experiment_id", "metric", "reference_datasets")
25
+
26
+ def __init__(self, metrics_collection: str, experiments: Collection[str] = ("historical",)) -> None:
27
+ self.name = metrics_collection
28
+ self.slug = metrics_collection.lower()
29
+ self.metrics_collection = metrics_collection
30
+ self.parameter_file = "pmp_param_enso.py"
31
+ self.obs_sources: tuple[str, ...]
32
+ self.model_variables: tuple[str, ...]
33
+
34
+ if metrics_collection == "ENSO_perf": # pragma: no cover
35
+ self.model_variables = ("pr", "ts", "tauu")
36
+ self.obs_sources = ("GPCP-Monthly-3-2", "TropFlux-1-0", "HadISST-1-1")
37
+ elif metrics_collection == "ENSO_tel":
38
+ self.model_variables = ("pr", "ts")
39
+ self.obs_sources = ("GPCP-Monthly-3-2", "TropFlux-1-0", "HadISST-1-1")
40
+ elif metrics_collection == "ENSO_proc":
41
+ self.model_variables = ("ts", "tauu", "hfls", "hfss", "rlds", "rlus", "rsds", "rsus")
42
+ self.obs_sources = (
43
+ "GPCP-Monthly-3-2",
44
+ "TropFlux-1-0",
45
+ "HadISST-1-1",
46
+ "CERES-EBAF-4-2",
47
+ )
48
+ else:
49
+ raise ValueError(
50
+ f"Unknown metrics collection: {metrics_collection}. "
51
+ "Valid options are: ENSO_perf, ENSO_tel, ENSO_proc"
52
+ )
53
+
54
+ self.data_requirements = self._get_data_requirements(experiments)
55
+
56
+ def _get_data_requirements(
57
+ self,
58
+ experiments: Collection[str] = ("historical",),
59
+ ) -> tuple[DataRequirement, DataRequirement]:
60
+ filters = [
61
+ FacetFilter(
62
+ facets={
63
+ "frequency": "mon",
64
+ "experiment_id": tuple(experiments),
65
+ "variable_id": self.model_variables,
66
+ }
67
+ )
68
+ ]
69
+
70
+ return (
71
+ DataRequirement(
72
+ source_type=SourceDatasetType.obs4MIPs,
73
+ filters=(
74
+ FacetFilter(facets={"source_id": self.obs_sources, "variable_id": self.model_variables}),
75
+ ),
76
+ group_by=("activity_id",),
77
+ ),
78
+ DataRequirement(
79
+ source_type=SourceDatasetType.CMIP6,
80
+ filters=tuple(filters),
81
+ group_by=("source_id", "experiment_id", "member_id", "grid_label"),
82
+ constraints=(
83
+ AddSupplementaryDataset.from_defaults("areacella", SourceDatasetType.CMIP6),
84
+ AddSupplementaryDataset.from_defaults("sftlf", SourceDatasetType.CMIP6),
85
+ ),
86
+ ),
87
+ )
88
+
89
+ def build_cmd(self, definition: ExecutionDefinition) -> Iterable[str]:
90
+ """
91
+ Run the diagnostic on the given configuration.
92
+
93
+ Parameters
94
+ ----------
95
+ definition : ExecutionDefinition
96
+ The configuration to run the diagnostic on.
97
+
98
+ Returns
99
+ -------
100
+ :
101
+ The result of running the diagnostic.
102
+ """
103
+ mc_name = self.metrics_collection
104
+
105
+ # ------------------------------------------------
106
+ # Get the input datasets information for the model
107
+ # ------------------------------------------------
108
+ input_datasets = definition.datasets[SourceDatasetType.CMIP6]
109
+ input_selectors = input_datasets.selector_dict()
110
+ source_id = input_selectors["source_id"]
111
+ member_id = input_selectors["member_id"]
112
+ experiment_id = input_selectors["experiment_id"]
113
+ variable_ids = set(input_datasets["variable_id"].unique()) - {"areacella", "sftlf"}
114
+ mod_run = f"{source_id}_{member_id}"
115
+
116
+ # We only need one entry for the model run
117
+ dict_mod: dict[str, dict[str, Any]] = {mod_run: {}}
118
+
119
+ def extract_variable(dc: DatasetCollection, variable: str) -> list[str]:
120
+ return dc.datasets[input_datasets["variable_id"] == variable]["path"].to_list() # type: ignore
121
+
122
+ # TO DO: Get the path to the files per variable
123
+ for variable in variable_ids:
124
+ list_files = extract_variable(input_datasets, variable)
125
+ list_areacella = extract_variable(input_datasets, "areacella")
126
+ list_sftlf = extract_variable(input_datasets, "sftlf")
127
+
128
+ if len(list_files) > 0:
129
+ dict_mod[mod_run][variable] = {
130
+ "path + filename": list_files,
131
+ "varname": variable,
132
+ "path + filename_area": list_areacella,
133
+ "areaname": "areacella",
134
+ "path + filename_landmask": list_sftlf,
135
+ "landmaskname": "sftlf",
136
+ }
137
+
138
+ # -------------------------------------------------------
139
+ # Get the input datasets information for the observations
140
+ # -------------------------------------------------------
141
+ reference_dataset = definition.datasets[SourceDatasetType.obs4MIPs]
142
+ reference_dataset_names = reference_dataset["source_id"].unique()
143
+
144
+ dict_obs: dict[str, dict[str, Any]] = {}
145
+
146
+ # TO DO: Get the path to the files per variable and per source
147
+ for obs_name in reference_dataset_names:
148
+ dict_obs[obs_name] = {}
149
+ for variable in variable_ids:
150
+ # Get the list of files for the current variable and observation source
151
+ list_files = reference_dataset.datasets[
152
+ (reference_dataset["variable_id"] == variable)
153
+ & (reference_dataset["source_id"] == obs_name)
154
+ ]["path"].to_list()
155
+ # If the list is not empty, add it to the dictionary
156
+ if len(list_files) > 0:
157
+ dict_obs[obs_name][variable] = {
158
+ "path + filename": list_files,
159
+ "varname": variable,
160
+ }
161
+
162
+ # Create input directory
163
+ dict_datasets = {
164
+ "model": dict_mod,
165
+ "observations": dict_obs,
166
+ "metricsCollection": mc_name,
167
+ "experiment_id": experiment_id,
168
+ }
169
+
170
+ # Create JSON file for dictDatasets
171
+ json_file = os.path.join(
172
+ definition.output_directory, f"input_{mc_name}_{source_id}_{experiment_id}_{member_id}.json"
173
+ )
174
+ with open(json_file, "w") as f:
175
+ json.dump(dict_datasets, f, indent=4)
176
+ logger.debug(f"JSON file created: {json_file}")
177
+
178
+ driver_file = _get_resource("climate_ref_pmp.drivers", "enso_driver.py", use_resources=True)
179
+ return [
180
+ "python",
181
+ driver_file,
182
+ "--metrics_collection",
183
+ mc_name,
184
+ "--experiment_id",
185
+ experiment_id,
186
+ "--input_json_path",
187
+ json_file,
188
+ "--output_directory",
189
+ str(definition.output_directory),
190
+ ]
191
+
192
+ def build_execution_result(self, definition: ExecutionDefinition) -> ExecutionResult:
193
+ """
194
+ Build a diagnostic result from the output of the PMP driver
195
+
196
+ Parameters
197
+ ----------
198
+ definition
199
+ Definition of the diagnostic execution
200
+
201
+ Returns
202
+ -------
203
+ Result of the diagnostic execution
204
+ """
205
+ input_datasets = definition.datasets[SourceDatasetType.CMIP6]
206
+ source_id = input_datasets["source_id"].unique()[0]
207
+ experiment_id = input_datasets["experiment_id"].unique()[0]
208
+ member_id = input_datasets["member_id"].unique()[0]
209
+ mc_name = self.metrics_collection
210
+ pattern = f"{mc_name}_{source_id}_{experiment_id}_{member_id}"
211
+
212
+ # Find the results files
213
+ results_files = list(definition.output_directory.glob(f"{pattern}_cmec.json"))
214
+ logger.debug(f"Results files: {results_files}")
215
+
216
+ if len(results_files) != 1: # pragma: no cover
217
+ logger.warning(f"A single cmec output file not found: {results_files}")
218
+ return ExecutionResult.build_from_failure(definition)
219
+
220
+ # Find the other outputs
221
+ png_files = [definition.as_relative_path(f) for f in definition.output_directory.glob("*.png")]
222
+ data_files = [definition.as_relative_path(f) for f in definition.output_directory.glob("*.nc")]
223
+
224
+ cmec_output, cmec_metric = process_json_result(results_files[0], png_files, data_files)
225
+
226
+ input_selectors = definition.datasets[SourceDatasetType.CMIP6].selector_dict()
227
+ cmec_metric_bundle = cmec_metric.remove_dimensions(
228
+ [
229
+ "model",
230
+ "realization",
231
+ ],
232
+ ).prepend_dimensions(
233
+ {
234
+ "source_id": input_selectors["source_id"],
235
+ "member_id": input_selectors["member_id"],
236
+ "grid_label": input_selectors["grid_label"],
237
+ "experiment_id": input_selectors["experiment_id"],
238
+ }
239
+ )
240
+
241
+ return ExecutionResult.build_from_output_bundle(
242
+ definition,
243
+ cmec_output_bundle=cmec_output,
244
+ cmec_metric_bundle=cmec_metric_bundle,
245
+ )
@@ -1,4 +1,6 @@
1
1
  from collections.abc import Iterable
2
+ from pathlib import Path
3
+ from typing import Any, Union
2
4
 
3
5
  from loguru import logger
4
6
 
@@ -37,10 +39,10 @@ class ExtratropicalModesOfVariability(CommandLineDiagnostic):
37
39
  self.name = f"Extratropical modes of variability: {mode_id}"
38
40
  self.slug = f"extratropical-modes-of-variability-{mode_id.lower()}"
39
41
 
40
- def get_data_requirements(
42
+ def _get_data_requirements(
41
43
  obs_source: str,
42
44
  obs_variable: str,
43
- cmip_variable: str,
45
+ model_variable: str,
44
46
  extra_experiments: str | tuple[str, ...] | list[str] = (),
45
47
  ) -> tuple[DataRequirement, DataRequirement]:
46
48
  filters = [
@@ -48,7 +50,7 @@ class ExtratropicalModesOfVariability(CommandLineDiagnostic):
48
50
  facets={
49
51
  "frequency": "mon",
50
52
  "experiment_id": ("historical", "hist-GHG", "piControl", *extra_experiments),
51
- "variable_id": cmip_variable,
53
+ "variable_id": model_variable,
52
54
  }
53
55
  )
54
56
  ]
@@ -64,17 +66,16 @@ class ExtratropicalModesOfVariability(CommandLineDiagnostic):
64
66
  DataRequirement(
65
67
  source_type=SourceDatasetType.CMIP6,
66
68
  filters=tuple(filters),
67
- # TODO: remove unneeded variant_label
68
- group_by=("source_id", "experiment_id", "variant_label", "member_id"),
69
+ group_by=("source_id", "experiment_id", "member_id", "grid_label"),
69
70
  ),
70
71
  )
71
72
 
72
73
  if self.mode_id in self.ts_modes:
73
74
  self.parameter_file = "pmp_param_MoV-ts.py"
74
- self.data_requirements = get_data_requirements("HadISST-1-1", "ts", "ts")
75
+ self.data_requirements = _get_data_requirements("HadISST-1-1", "ts", "ts")
75
76
  elif self.mode_id in self.psl_modes:
76
77
  self.parameter_file = "pmp_param_MoV-psl.py"
77
- self.data_requirements = get_data_requirements("20CR", "psl", "psl", extra_experiments=("amip",))
78
+ self.data_requirements = _get_data_requirements("20CR", "psl", "psl", extra_experiments=("amip",))
78
79
  else:
79
80
  raise ValueError(
80
81
  f"Unknown mode_id '{self.mode_id}'. Must be one of {self.ts_modes + self.psl_modes}"
@@ -172,6 +173,8 @@ class ExtratropicalModesOfVariability(CommandLineDiagnostic):
172
173
  logger.warning(f"A single cmec output file not found: {results_files}")
173
174
  return ExecutionResult.build_from_failure(definition)
174
175
 
176
+ clean_up_json(results_files[0])
177
+
175
178
  # Find the other outputs
176
179
  png_files = [definition.as_relative_path(f) for f in definition.output_directory.glob("*.png")]
177
180
  data_files = [definition.as_relative_path(f) for f in definition.output_directory.glob("*.nc")]
@@ -201,3 +204,63 @@ class ExtratropicalModesOfVariability(CommandLineDiagnostic):
201
204
  cmec_output_bundle=cmec_output_bundle,
202
205
  cmec_metric_bundle=cmec_metric_bundle,
203
206
  )
207
+
208
+
209
+ def clean_up_json(json_file: Union[str, Path]) -> None:
210
+ """
211
+ Clean up the JSON file by removing unnecessary fields.
212
+
213
+ Parameters
214
+ ----------
215
+ json_file : str or Path
216
+ Path to the JSON file to clean up.
217
+ """
218
+ import json
219
+
220
+ with open(str(json_file)) as f:
221
+ data = json.load(f)
222
+
223
+ # Remove null values from the JSON data
224
+ data = remove_null_values(data)
225
+
226
+ with open(str(json_file), "w") as f:
227
+ json.dump(data, f, indent=4)
228
+
229
+ # Log the cleanup action
230
+ logger.debug(f"Cleaned up JSON file: {json_file}")
231
+ logger.info("JSON file cleaned up successfully.")
232
+
233
+
234
+ def remove_null_values(data: Union[dict[Any, Any], list[Any], Any]) -> Union[dict[Any, Any], list[Any], Any]:
235
+ """
236
+ Recursively removes keys with null (None) values from a dictionary or list.
237
+
238
+ Parameters
239
+ ----------
240
+ data : dict, list, or Any
241
+ The JSON-like data structure to process. It can be a dictionary, a list,
242
+ or any other type of data.
243
+
244
+ Returns
245
+ -------
246
+ dict, list, or Any
247
+ A new data structure with null values removed. If the input is a dictionary,
248
+ keys with `None` values are removed. If the input is a list, items are
249
+ recursively processed to remove `None` values. For other types, the input
250
+ is returned unchanged.
251
+
252
+ Examples
253
+ --------
254
+ >>> data = {
255
+ ... "key1": None,
256
+ ... "key2": {"subkey1": 123, "subkey2": None},
257
+ ... "key3": [None, 456, {"subkey3": None}],
258
+ ... }
259
+ >>> remove_null_values(data)
260
+ {'key2': {'subkey1': 123}, 'key3': [456, {}]}
261
+ """
262
+ if isinstance(data, dict):
263
+ return {key: remove_null_values(value) for key, value in data.items() if value is not None}
264
+ if isinstance(data, list):
265
+ return [remove_null_values(item) for item in data if item is not None]
266
+ return data