climate-ref-core 0.5.0__py3-none-any.whl → 0.5.2__py3-none-any.whl

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.
@@ -142,7 +142,7 @@ class DatasetRegistryManager:
142
142
  This defaults to the value of `name` if not provided.
143
143
  """
144
144
  if cache_name is None:
145
- cache_name = "ref"
145
+ cache_name = "climate_ref"
146
146
 
147
147
  registry = pooch.create(
148
148
  path=pooch.os_cache(cache_name),
@@ -11,6 +11,16 @@ from typing import Any, Self
11
11
  import pandas as pd
12
12
  from attrs import field, frozen
13
13
 
14
+ Selector = tuple[tuple[str, str], ...]
15
+ """
16
+ Type describing the key used to identify a group of datasets
17
+
18
+ This is a tuple of tuples, where each inner tuple contains a metadata and dimension value
19
+ that was used to group the datasets together.
20
+
21
+ This type must be hashable, as it is used as a key in a dictionary.
22
+ """
23
+
14
24
 
15
25
  class SourceDatasetType(enum.Enum):
16
26
  """
@@ -76,6 +86,23 @@ class FacetFilter:
76
86
  """
77
87
 
78
88
 
89
+ def sort_selector(inp: Selector) -> Selector:
90
+ """
91
+ Sort the selector by key
92
+
93
+ Parameters
94
+ ----------
95
+ inp
96
+ Selector to sort
97
+
98
+ Returns
99
+ -------
100
+ :
101
+ Sorted selector
102
+ """
103
+ return tuple(sorted(inp, key=lambda x: x[0]))
104
+
105
+
79
106
  @frozen
80
107
  class DatasetCollection:
81
108
  """
@@ -83,15 +110,33 @@ class DatasetCollection:
83
110
  """
84
111
 
85
112
  datasets: pd.DataFrame
113
+ """
114
+ DataFrame containing the datasets that were selected for the execution.
115
+
116
+ The columns in this dataframe depend on the source dataset type, but always include:
117
+ * path
118
+ * [slug_column]
119
+ """
86
120
  slug_column: str
87
121
  """
88
122
  Column in datasets that contains the unique identifier for the dataset
89
123
  """
90
- selector: tuple[tuple[str, str], ...] = ()
124
+ selector: Selector = field(converter=sort_selector, factory=tuple)
91
125
  """
92
126
  Unique key, value pairs that were selected during the initial groupby
93
127
  """
94
128
 
129
+ def selector_dict(self) -> dict[str, str]:
130
+ """
131
+ Convert the selector to a dictionary
132
+
133
+ Returns
134
+ -------
135
+ :
136
+ Dictionary of the selector
137
+ """
138
+ return {key: value for key, value in self.selector}
139
+
95
140
  def __getattr__(self, item: str) -> Any:
96
141
  return getattr(self.datasets, item)
97
142
 
@@ -155,3 +200,19 @@ class ExecutionDatasetCollection:
155
200
  hash_sum = sum(hash(item) for item in self._collection.values())
156
201
  hash_bytes = hash_sum.to_bytes(16, "little", signed=True)
157
202
  return hashlib.sha1(hash_bytes).hexdigest() # noqa: S324
203
+
204
+ @property
205
+ def selectors(self) -> dict[str, Selector]:
206
+ """
207
+ Collection of selectors used to identify the datasets
208
+
209
+ These are the key, value pairs that were selected during the initial group-by,
210
+ for each data requirement.
211
+ """
212
+ # The "value" of SourceType is used here so this can be stored in the db
213
+ s = {}
214
+ for source_type in SourceDatasetType.ordered():
215
+ if source_type not in self._collection:
216
+ continue
217
+ s[source_type.value] = self._collection[source_type].selector
218
+ return s
@@ -14,6 +14,7 @@ from attrs import field, frozen
14
14
 
15
15
  from climate_ref_core.constraints import GroupConstraint
16
16
  from climate_ref_core.datasets import ExecutionDatasetCollection, FacetFilter, SourceDatasetType
17
+ from climate_ref_core.metric_values import SeriesMetricValue
17
18
  from climate_ref_core.pycmec.metric import CMECMetric
18
19
  from climate_ref_core.pycmec.output import CMECOutput
19
20
 
@@ -61,6 +62,11 @@ class ExecutionDefinition:
61
62
  for a specific set of datasets fulfilling the requirements.
62
63
  """
63
64
 
65
+ diagnostic: Diagnostic
66
+ """
67
+ The diagnostic that is being executed
68
+ """
69
+
64
70
  key: str
65
71
  """
66
72
  The unique identifier for the datasets in the diagnostic execution group.
@@ -85,6 +91,12 @@ class ExecutionDefinition:
85
91
  Root directory for storing the output of the diagnostic execution
86
92
  """
87
93
 
94
+ def execution_slug(self) -> str:
95
+ """
96
+ Get a slug for the execution
97
+ """
98
+ return f"{self.diagnostic.full_slug()}/{self.key}"
99
+
88
100
  def to_output_path(self, filename: pathlib.Path | str | None) -> pathlib.Path:
89
101
  """
90
102
  Get the absolute path for a file in the output directory
@@ -170,7 +182,11 @@ class ExecutionResult:
170
182
  """
171
183
  Whether the diagnostic execution ran successfully.
172
184
  """
173
- # Log info is in the output bundle file already, but is definitely useful
185
+
186
+ series: Sequence[SeriesMetricValue] = field(factory=tuple)
187
+ """
188
+ A collection of series metric values that were extracted from the execution.
189
+ """
174
190
 
175
191
  @staticmethod
176
192
  def build_from_output_bundle(
@@ -426,7 +442,7 @@ class AbstractDiagnostic(Protocol):
426
442
  """
427
443
  Run the diagnostic on the given configuration.
428
444
 
429
- The implementation of this method method is left to the diagnostic providers.
445
+ The implementation of this method is left to the diagnostic providers.
430
446
 
431
447
 
432
448
  Parameters
@@ -46,3 +46,10 @@ class ConstraintNotSatisfied(RefException):
46
46
 
47
47
  class ResultValidationError(RefException):
48
48
  """Exception raised when the executions from a diagnostic are invalid"""
49
+
50
+
51
+ class ExecutionError(RefException):
52
+ """Exception raised when an execution fails"""
53
+
54
+ def __init__(self, message: str) -> None:
55
+ super().__init__(message)
@@ -2,20 +2,54 @@
2
2
  Executor interface for running diagnostics
3
3
  """
4
4
 
5
+ import importlib
6
+ import shutil
5
7
  from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
6
8
 
7
- from climate_ref_core.diagnostics import Diagnostic, ExecutionDefinition
8
- from climate_ref_core.providers import DiagnosticProvider
9
+ from loguru import logger
10
+
11
+ from climate_ref_core.diagnostics import ExecutionDefinition, ExecutionResult
12
+ from climate_ref_core.exceptions import InvalidExecutorException
13
+ from climate_ref_core.logging import redirect_logs
9
14
 
10
15
  if TYPE_CHECKING:
16
+ # TODO: break this import cycle and move it into the execution definition
11
17
  from climate_ref.models import Execution
12
18
 
13
- EXECUTION_LOG_FILENAME = "out.log"
14
- """
15
- Filename for the execution log.
16
19
 
17
- This file is written via [climate_ref_core.logging.redirect_logs][].
18
- """
20
+ def execute_locally(
21
+ definition: ExecutionDefinition,
22
+ log_level: str,
23
+ ) -> ExecutionResult:
24
+ """
25
+ Run a diagnostic execution
26
+
27
+ This is the chunk of work that should be executed by an executor.
28
+
29
+ Parameters
30
+ ----------
31
+ definition
32
+ A description of the information needed for this execution of the diagnostic
33
+ log_level
34
+ The log level to use for the execution
35
+ """
36
+ logger.info(f"Executing {definition.execution_slug()!r}")
37
+
38
+ try:
39
+ if definition.output_directory.exists():
40
+ logger.warning(
41
+ f"Output directory {definition.output_directory} already exists. "
42
+ f"Removing the existing directory."
43
+ )
44
+ shutil.rmtree(definition.output_directory)
45
+ definition.output_directory.mkdir(parents=True, exist_ok=True)
46
+
47
+ with redirect_logs(definition, log_level):
48
+ return definition.diagnostic.run(definition=definition)
49
+ except Exception:
50
+ # If the diagnostic fails, we want to log the error and return a failure result
51
+ logger.exception(f"Error running {definition.execution_slug()!r}")
52
+ return ExecutionResult.build_from_failure(definition)
19
53
 
20
54
 
21
55
  @runtime_checkable
@@ -37,8 +71,6 @@ class Executor(Protocol):
37
71
 
38
72
  def run(
39
73
  self,
40
- provider: DiagnosticProvider,
41
- diagnostic: Diagnostic,
42
74
  definition: ExecutionDefinition,
43
75
  execution: "Execution | None" = None,
44
76
  ) -> None:
@@ -55,10 +87,6 @@ class Executor(Protocol):
55
87
 
56
88
  Parameters
57
89
  ----------
58
- provider
59
- Provider of the diagnostic
60
- diagnostic
61
- Diagnostic to run
62
90
  definition
63
91
  Definition of the information needed to execute a diagnostic
64
92
 
@@ -94,3 +122,46 @@ class Executor(Protocol):
94
122
  TimeoutError
95
123
  If the timeout is reached
96
124
  """
125
+
126
+
127
+ def import_executor_cls(fqn: str) -> type[Executor]:
128
+ """
129
+ Import an executor using a fully qualified module path
130
+
131
+ Parameters
132
+ ----------
133
+ fqn
134
+ Full package and attribute name of the executor to import
135
+
136
+ For example: `climate_ref_example.executor` will use the `executor` attribute from the
137
+ `climate_ref_example` package.
138
+
139
+ Raises
140
+ ------
141
+ InvalidExecutorException
142
+ If the executor cannot be imported
143
+
144
+ If the executor isn't a valid `DiagnosticProvider`.
145
+
146
+ Returns
147
+ -------
148
+ :
149
+ Executor instance
150
+ """
151
+ module, attribute_name = fqn.rsplit(".", 1)
152
+
153
+ try:
154
+ imp = importlib.import_module(module)
155
+ executor: type[Executor] = getattr(imp, attribute_name)
156
+
157
+ # We can't really check if the executor is a subclass of Executor here
158
+ # Protocols can't be used with issubclass if they have non-method members
159
+ # We have to check this at class instantiation time
160
+
161
+ return executor
162
+ except ModuleNotFoundError:
163
+ logger.error(f"Package '{fqn}' not found")
164
+ raise InvalidExecutorException(fqn, f"Module '{module}' not found")
165
+ except AttributeError:
166
+ logger.error(f"Provider '{fqn}' not found")
167
+ raise InvalidExecutorException(fqn, f"Executor '{attribute_name}' not found in {module}")
@@ -1,7 +1,7 @@
1
1
  """
2
2
  Logging utilities
3
3
 
4
- The REF uses [loguru](https://loguru.readthedocs.io/en/stable/), a simple logging framework
4
+ The REF uses [loguru](https://loguru.readthedocs.io/en/stable/), a simple logging framework.
5
5
  """
6
6
 
7
7
  import contextlib
@@ -16,7 +16,13 @@ from loguru import logger
16
16
  from rich.pretty import pretty_repr
17
17
 
18
18
  from climate_ref_core.diagnostics import ExecutionDefinition
19
- from climate_ref_core.executor import EXECUTION_LOG_FILENAME
19
+
20
+ EXECUTION_LOG_FILENAME = "out.log"
21
+ """
22
+ Filename for the execution log.
23
+
24
+ This file is written via [climate_ref_core.logging.redirect_logs][].
25
+ """
20
26
 
21
27
 
22
28
  class _InterceptHandler(logging.Handler):
@@ -72,7 +78,7 @@ def add_log_handler(**kwargs: Any) -> None:
72
78
 
73
79
  # Track the current handler via custom attributes on the logger
74
80
  # This is a bit of a workaround because of loguru's super slim API that doesn't allow for
75
- # modificiation of existing handlers.
81
+ # modification of existing handlers.
76
82
  logger.default_handler_id = handled_id # type: ignore[attr-defined]
77
83
  logger.default_handler_kwargs = kwargs # type: ignore[attr-defined]
78
84
 
@@ -88,7 +94,12 @@ def remove_log_handler() -> None:
88
94
  logger should be readded later
89
95
  """
90
96
  if hasattr(logger, "default_handler_id"):
91
- logger.remove(logger.default_handler_id)
97
+ try:
98
+ logger.remove(logger.default_handler_id)
99
+ except ValueError:
100
+ # This can happen if the handler has already been removed
101
+ # or if the logger was never configured
102
+ pass
92
103
  del logger.default_handler_id
93
104
  else:
94
105
  raise AssertionError("No default log handler to remove.")
@@ -143,4 +154,4 @@ def redirect_logs(definition: ExecutionDefinition, log_level: str) -> Generator[
143
154
  add_log_handler(**logger.default_handler_kwargs) # type: ignore[attr-defined]
144
155
 
145
156
 
146
- __all__ = ["add_log_handler", "capture_logging", "logger", "redirect_logs"]
157
+ __all__ = ["EXECUTION_LOG_FILENAME", "add_log_handler", "capture_logging", "logger", "redirect_logs"]
@@ -0,0 +1,16 @@
1
+ """
2
+ Metric Values
3
+
4
+ A metric is a single statistical evaluation contained within a diagnostic.
5
+ A diagnostic may consist of more than one metric.
6
+
7
+ Examples include bias, root mean squared error (RMSE), Earth Mover's Distance,
8
+ phase/timing of the seasonal cycle, amplitude of the seasonal cycle, spatial or temporal correlations,
9
+ interannual variability.
10
+ Not all metrics are useful for all variables or should be used with every observationally constrained dataset.
11
+ Each metric may be converted into a performance score.
12
+ """
13
+
14
+ from .typing import ScalarMetricValue, SeriesMetricValue
15
+
16
+ __all__ = ["ScalarMetricValue", "SeriesMetricValue"]
@@ -0,0 +1,74 @@
1
+ from collections.abc import Sequence
2
+ from typing import Self
3
+
4
+ from pydantic import BaseModel, model_validator
5
+
6
+ Value = float | int
7
+
8
+
9
+ class SeriesMetricValue(BaseModel):
10
+ """
11
+ A 1-d array with an associated index and additional dimensions
12
+
13
+ These values are typically sourced from the CMEC metrics bundle
14
+ """
15
+
16
+ dimensions: dict[str, str]
17
+ """
18
+ Key, value pairs that identify the dimensions of the metric
19
+
20
+ These values are used for a faceted search of the metric values.
21
+ """
22
+ values: Sequence[Value]
23
+ """
24
+ A 1-d array of values
25
+ """
26
+ index: Sequence[str | Value]
27
+ """
28
+ A 1-d array of index values
29
+
30
+ Values must be strings or numbers and have the same length as values.
31
+ Non-unique index values are not allowed.
32
+ """
33
+
34
+ index_name: str
35
+ """
36
+ The name of the index.
37
+
38
+ This is used for presentation purposes and is not used in the controlled vocabulary.
39
+ """
40
+
41
+ attributes: dict[str, str | Value] | None = None
42
+ """
43
+ Additional unstructured attributes associated with the metric value
44
+ """
45
+
46
+ @model_validator(mode="after")
47
+ def validate_index_length(self) -> Self:
48
+ """Validate that index has the same length as values"""
49
+ if len(self.index) != len(self.values):
50
+ raise ValueError(
51
+ f"Index length ({len(self.index)}) must match values length ({len(self.values)})"
52
+ )
53
+ return self
54
+
55
+
56
+ class ScalarMetricValue(BaseModel):
57
+ """
58
+ A scalar value with an associated dimensions
59
+ """
60
+
61
+ dimensions: dict[str, str]
62
+ """
63
+ Key, value pairs that identify the dimensions of the metric
64
+
65
+ These values are used for a faceted search of the metric values.
66
+ """
67
+ value: Value
68
+ """
69
+ A scalar value
70
+ """
71
+ attributes: dict[str, str | Value] | None = None
72
+ """
73
+ Additional unstructured attributes associated with the metric value
74
+ """
@@ -1,31 +1,82 @@
1
1
  dimensions:
2
- - name: model
3
- long_name: model_id
4
- description: ""
2
+ - name: source_id
3
+ long_name: Source ID
4
+ description: "Source ID (e.g., GFDL-CM4)"
5
5
  allow_extra_values: true
6
6
  required: false
7
- - name: source_id
8
- long_name: source_id
9
- description: ""
7
+ - name: reference_source_id
8
+ long_name: Reference Source ID
9
+ description: "Source ID of the reference dataset(e.g., HadISST)"
10
+ allow_extra_values: true
11
+ required: false
12
+ - name: experiment_id
13
+ long_name: Experiment ID
14
+ description: "Experiment ID (e.g., historical, ssp585)"
15
+ allow_extra_values: true
16
+ required: false
17
+ - name: variable_id
18
+ long_name: Variable
19
+ description: "Variable ID (e.g., tas, pr, etc.)"
20
+ allow_extra_values: true
21
+ required: false
22
+ - name: reference_variable_id
23
+ long_name: Reference Variable
24
+ description: "Variable ID for the reference dataset (e.g., tas, pr, etc.)"
25
+ allow_extra_values: true
26
+ required: false
27
+ - name: member_id
28
+ long_name: Member ID
29
+ description: "Unique identifier for each ensemble member, includes the variant label and sub-experiment if present"
10
30
  allow_extra_values: true
11
31
  required: false
12
32
  - name: variant_label
13
33
  long_name: Variant Label
14
- description: ""
34
+ description: "Ensemble member (construct from realization, initialization, physics, and forcing indices)"
15
35
  allow_extra_values: true
16
36
  required: false
17
37
  - name: metric
18
- long_name: ""
38
+ long_name: Metric
19
39
  description: ""
20
40
  required: true
21
41
  allow_extra_values: true
22
42
  - name: region
23
- long_name: ""
24
- description: ""
43
+ long_name: Region
44
+ description: "Part of the world from which the metric values are calculated. "
45
+ required: true
46
+ allow_extra_values: true
47
+ values:
48
+ - name: global
49
+ long_name: Global
50
+ description: "Global aggregate"
51
+ units: dimensionless
52
+ - name: season
53
+ long_name: Season
54
+ description: "Parts of the year from which the metric values are calculated"
25
55
  required: true
26
56
  allow_extra_values: true
57
+ values:
58
+ - name: ann
59
+ long_name: Annual
60
+ description: ""
61
+ units: dimensionless
62
+ - name: djf
63
+ long_name: Dec,Jan,Feb
64
+ description: "December, January, February"
65
+ units: dimensionless
66
+ - name: mam
67
+ long_name: Mar,Apr,May
68
+ description: "March, April, May"
69
+ units: dimensionless
70
+ - name: jja
71
+ long_name: Jun,Jul,Aug
72
+ description: "June, July, August"
73
+ units: dimensionless
74
+ - name: son
75
+ long_name: Sep,Oct,Nov
76
+ description: "September, October, November"
77
+ units: dimensionless
27
78
  - name: statistic
28
- long_name: ""
79
+ long_name: Statistic
29
80
  description: ""
30
81
  required: true
31
82
  allow_extra_values: true
@@ -13,11 +13,14 @@ Both ways will create the CMECMetric instance (cmec)
13
13
 
14
14
  import json
15
15
  import pathlib
16
+ import warnings
16
17
  from collections import Counter
17
18
  from collections.abc import Generator
19
+ from copy import deepcopy
18
20
  from enum import Enum
19
21
  from typing import Any, cast
20
22
 
23
+ from loguru import logger
21
24
  from pydantic import (
22
25
  BaseModel,
23
26
  ConfigDict,
@@ -33,6 +36,11 @@ from pydantic.json_schema import GenerateJsonSchema, JsonSchemaMode, JsonSchemaV
33
36
  from pydantic_core import CoreSchema
34
37
  from typing_extensions import Self
35
38
 
39
+ from climate_ref_core.env import env
40
+ from climate_ref_core.metric_values import ScalarMetricValue
41
+
42
+ ALLOW_EXTRA_KEYS = env.bool("ALLOW_EXTRA_KEYS", default=True)
43
+
36
44
 
37
45
  class MetricCV(Enum):
38
46
  """
@@ -58,9 +66,7 @@ class MetricDimensions(RootModel[Any]):
58
66
 
59
67
  root: dict[str, Any] = Field(
60
68
  default={
61
- MetricCV.JSON_STRUCTURE.value: ["model", "metric"],
62
- "model": {},
63
- "metric": {},
69
+ MetricCV.JSON_STRUCTURE.value: [],
64
70
  }
65
71
  )
66
72
 
@@ -151,7 +157,7 @@ class MetricResults(RootModel[Any]):
151
157
  root: dict[str, dict[Any, Any]]
152
158
 
153
159
  @classmethod
154
- def _check_nested_dict_keys(cls, nested: dict[Any, Any], metdims: dict[Any, Any], level: int = 0) -> None:
160
+ def _check_nested_dict_keys(cls, nested: dict[Any, Any], metdims: dict[Any, Any], level: int = 0) -> None: # noqa: PLR0912
155
161
  dim_name = metdims[MetricCV.JSON_STRUCTURE.value][level]
156
162
 
157
163
  dict_keys = set(nested.keys())
@@ -183,7 +189,14 @@ class MetricResults(RootModel[Any]):
183
189
  else:
184
190
  expected_keys = set(metdims[dim_name].keys())
185
191
  if not (dict_keys.issubset(expected_keys)):
186
- raise ValueError(f"Unknown dimension values: {dict_keys - expected_keys}")
192
+ msg = f"Unknown dimension values: {dict_keys - expected_keys} for {dim_name}"
193
+ logger.error(msg)
194
+ if not ALLOW_EXTRA_KEYS: # pragma: no cover
195
+ raise ValueError(f"{msg}\nExpected keys: {expected_keys}")
196
+ else:
197
+ warnings.warn(msg)
198
+ for key in dict_keys - expected_keys:
199
+ nested.pop(key)
187
200
 
188
201
  tmp = dict(nested)
189
202
  if MetricCV.ATTRIBUTES.value in tmp:
@@ -202,7 +215,11 @@ class MetricResults(RootModel[Any]):
202
215
  # executions = rlt.root
203
216
  results = rlt
204
217
  metdims = info.context.root
205
- cls._check_nested_dict_keys(results, metdims, level=0)
218
+ if len(metdims[MetricCV.JSON_STRUCTURE.value]) == 0:
219
+ if rlt != {}:
220
+ raise ValueError("Expected an empty dictionary for the metric bundle")
221
+ else:
222
+ cls._check_nested_dict_keys(results, metdims, level=0)
206
223
 
207
224
  return rlt
208
225
 
@@ -211,19 +228,54 @@ class StrNumDict(RootModel[Any]):
211
228
  """A class contains string key and numeric value"""
212
229
 
213
230
  model_config = ConfigDict(strict=True)
214
- root: dict[str, float | int | list[str | float | int]]
231
+ root: dict[str, float | int]
215
232
 
216
233
 
217
- class MetricValue(BaseModel):
234
+ def remove_dimensions(raw_metric_bundle: dict[str, Any], dimensions: str | list[str]) -> dict[str, Any]:
218
235
  """
219
- A flattened representation of a diagnostic value
220
-
221
- This includes the dimensions and the value of the diagnostic
236
+ Remove the dimensions from the raw metric bundle
237
+
238
+ Currently only the first dimension is supported to be removed.
239
+ Multiple dimensions can be removed at once, but only if they are in order from the first
240
+ dimension.
241
+
242
+ Parameters
243
+ ----------
244
+ raw_metric_bundle
245
+ The raw metric bundle to be modified
246
+ dimensions
247
+ The name of the dimensions to be removed
248
+
249
+ Returns
250
+ -------
251
+ The new, modified metric bundle with the dimension removed
222
252
  """
253
+ if isinstance(dimensions, str):
254
+ dimensions = [dimensions]
255
+
256
+ metric_bundle = deepcopy(raw_metric_bundle)
257
+
258
+ for dim in dimensions:
259
+ # bundle_dims is modified inplace below
260
+ bundle_dims = metric_bundle[MetricCV.DIMENSIONS.value]
261
+
262
+ level_id = bundle_dims[MetricCV.JSON_STRUCTURE.value].index(dim)
263
+ if level_id != 0:
264
+ raise NotImplementedError("Only the first dimension can be removed")
265
+
266
+ values = list(bundle_dims[dim].keys())
267
+ if len(values) != 1:
268
+ raise ValueError(f"Can only remove dimensions with a single value. Found: {values}")
269
+ value = values[0]
223
270
 
224
- dimensions: dict[str, str]
225
- value: float | str
226
- attributes: dict[str, str | float | int] | None = None
271
+ new_result = metric_bundle[MetricCV.RESULTS.value][value]
272
+
273
+ # Update the dimensions and results to remove the dimension
274
+ bundle_dims.pop(dim)
275
+ bundle_dims[MetricCV.JSON_STRUCTURE.value].pop(level_id)
276
+ metric_bundle[MetricCV.RESULTS.value] = new_result
277
+
278
+ return metric_bundle
227
279
 
228
280
 
229
281
  class CMECMetric(BaseModel):
@@ -376,6 +428,72 @@ class CMECMetric(BaseModel):
376
428
 
377
429
  return cls(DIMENSIONS=merged_obj_dims, RESULTS=merged_obj_rlts)
378
430
 
431
+ def remove_dimensions(self, dimensions: str | list[str]) -> "CMECMetric":
432
+ """
433
+ Remove the dimensions from the metric bundle
434
+
435
+ Currently only the first dimension is supported to be removed.
436
+ Multiple dimensions can be removed at once, but only if they are in order from the first
437
+ dimension..
438
+
439
+ Parameters
440
+ ----------
441
+ dimensions
442
+ The name of the dimension to be removed
443
+
444
+ Returns
445
+ -------
446
+ :
447
+ A new CMECMetric object with the dimensions removed
448
+ """
449
+ return CMECMetric(**remove_dimensions(self.model_dump(), dimensions))
450
+
451
+ def prepend_dimensions(self, values: dict[str, str]) -> "CMECMetric":
452
+ """
453
+ Prepend the existing metric values with additional dimensions
454
+
455
+ Parameters
456
+ ----------
457
+ values
458
+ Additional metric dimensions and their values to be added to the metric bundle
459
+
460
+ Returns
461
+ -------
462
+ :
463
+ A new CMECMetric object with the additional dimensions prepended to the existing metric bundle
464
+ """
465
+ results: dict[str, Any] = {}
466
+ current = results
467
+
468
+ existing_dimensions = self.DIMENSIONS.root[MetricCV.JSON_STRUCTURE.value]
469
+ for dim in existing_dimensions:
470
+ if dim in values:
471
+ raise ValueError(f"Dimension {dim!r} is already defined in the metric bundle")
472
+
473
+ dimensions = self.DIMENSIONS.model_copy(deep=True)
474
+ dimensions.root[MetricCV.JSON_STRUCTURE.value] = [
475
+ *list(values.keys()),
476
+ *existing_dimensions,
477
+ ]
478
+
479
+ # Nest each new dimension inside the previous one
480
+ for key, value in values.items():
481
+ if not isinstance(value, str):
482
+ raise TypeError(f"Dimension value {value!r} is not a string")
483
+
484
+ current[value] = {}
485
+ current = current[value]
486
+ dimensions.root[key] = {value: {}}
487
+ # Add the existing dimensions as the innermost dimensions
488
+ current.update(self.RESULTS)
489
+
490
+ MetricResults.model_validate(results, context=dimensions)
491
+
492
+ result = self.model_copy()
493
+ result.DIMENSIONS = dimensions
494
+ result.RESULTS = results
495
+ return result
496
+
379
497
  @staticmethod
380
498
  def create_template() -> dict[str, Any]:
381
499
  """
@@ -391,7 +509,7 @@ class CMECMetric(BaseModel):
391
509
  MetricCV.NOTES.value: None,
392
510
  }
393
511
 
394
- def iter_results(self) -> Generator[MetricValue]:
512
+ def iter_results(self) -> Generator[ScalarMetricValue]:
395
513
  """
396
514
  Iterate over the executions in the diagnostic bundle
397
515
 
@@ -404,20 +522,24 @@ class CMECMetric(BaseModel):
404
522
  """
405
523
  dimensions = cast(list[str], self.DIMENSIONS[MetricCV.JSON_STRUCTURE.value])
406
524
 
525
+ if len(dimensions) == 0:
526
+ # There is no data to iterate over
527
+ return
528
+
407
529
  yield from _walk_results(dimensions, self.RESULTS, {})
408
530
 
409
531
 
410
532
  def _walk_results(
411
533
  dimensions: list[str], results: dict[str, Any], metadata: dict[str, str]
412
- ) -> Generator[MetricValue]:
413
- assert len(dimensions), "Not enough dimensions" # noqa: S101
534
+ ) -> Generator[ScalarMetricValue]:
535
+ assert len(dimensions), "Not enough dimensions"
414
536
  dimension = dimensions[0]
415
537
  for key, value in results.items():
416
538
  if key == MetricCV.ATTRIBUTES.value:
417
539
  continue
418
540
  metadata[dimension] = key
419
- if isinstance(value, str | float | int):
420
- yield MetricValue(
541
+ if isinstance(value, float | int):
542
+ yield ScalarMetricValue(
421
543
  dimensions=metadata, value=value, attributes=results.get(MetricCV.ATTRIBUTES.value)
422
544
  )
423
545
  else:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: climate-ref-core
3
- Version: 0.5.0
3
+ Version: 0.5.2
4
4
  Summary: Core library for the CMIP Rapid Evaluation Framework
5
5
  Author-email: Jared Lewis <jared.lewis@climate-resource.com>, Mika Pflueger <mika.pflueger@climate-resource.com>, Bouwe Andela <b.andela@esciencecenter.nl>, Jiwoo Lee <lee1043@llnl.gov>, Min Xu <xum1@ornl.gov>, Nathan Collier <collierno@ornl.gov>, Dora Hegedus <dora.hegedus@stfc.ac.uk>
6
6
  License: Apache-2.0
@@ -0,0 +1,24 @@
1
+ climate_ref_core/__init__.py,sha256=MtmPThF2F9_2UODEN6rt1x30LDxrHIZ0wyRN_wsHx5I,127
2
+ climate_ref_core/constraints.py,sha256=QOqMh5jDBxdWTnQw2HNBizJQDF6Uu97rfJp9WudQWHc,11819
3
+ climate_ref_core/dataset_registry.py,sha256=kVfVxJoOOAdvrBfNZuOvGGi0UWIcl2QpDPmM5ej-7aU,5103
4
+ climate_ref_core/datasets.py,sha256=bX86XPD1Z5zl3E4_56zUU9cjwNOdurU-HiYx7h1PmN4,6191
5
+ climate_ref_core/diagnostics.py,sha256=yGqZgavupeA3cQYegO54NpbX4OuO80aR37LBRHQwfNk,17995
6
+ climate_ref_core/env.py,sha256=Ph2dejVxTELfP3bL0xES086WLGvV5H6KvsOwCkL6m-k,753
7
+ climate_ref_core/exceptions.py,sha256=psdipWURLyMq5hmloGxt-8kyqEe0IsENfraok7KTi8I,1437
8
+ climate_ref_core/executor.py,sha256=NIXIU2rwMnTOR-ztlPlCD-poZO4vxzKQPWYk8veTVkk,5195
9
+ climate_ref_core/logging.py,sha256=EBe5WAk1dtosr8MLkG-i7iDNZTI9ufxI4xsvbq3Gdt8,5260
10
+ climate_ref_core/providers.py,sha256=by_ZtoLQgg9A60CbFor2-i5EixtZTZ0z8jQqOGRfvA8,12461
11
+ climate_ref_core/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
+ climate_ref_core/metric_values/__init__.py,sha256=aHfwRrqzLOmmaBKf1-4q97DnHb8KwmW0Dhwd79ZQiNQ,634
13
+ climate_ref_core/metric_values/typing.py,sha256=2DpzmjqQ7tqOPAyjthZ_O14c0-MhiYt-A_n9p6-bOao,1903
14
+ climate_ref_core/pycmec/README.md,sha256=PzkovlPpsXqFopsYzz5GRvCAipNRGO1Wo-0gc17qr2Y,36
15
+ climate_ref_core/pycmec/__init__.py,sha256=hXvKGEJQWyAp1i-ndr3D4zuYxkRhcR2LfXgFXlhYOk4,28
16
+ climate_ref_core/pycmec/controlled_vocabulary.py,sha256=xio_4jl6mM_WMrwyxo70d0G5dUeIal4IW7eV-EMW4mU,5093
17
+ climate_ref_core/pycmec/cv_cmip7_aft.yaml,sha256=FflwP71JFdnp-N5_OQ9_g4KE_I16fxn1Zn96yybenW4,2706
18
+ climate_ref_core/pycmec/metric.py,sha256=XXM5DMk0BhpKcPvvCHCcgA6jKoVGMqXcwiG1UerYYps,18181
19
+ climate_ref_core/pycmec/output.py,sha256=4-RQ439sfgNLeQZVDPB1pewF_kTwX7nCK0Z4U6bvbd0,5709
20
+ climate_ref_core-0.5.2.dist-info/METADATA,sha256=n7tyqfzcB2_vOBfLiey8kGXzD3vEZ4PNUS5p11nx64Q,2700
21
+ climate_ref_core-0.5.2.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
22
+ climate_ref_core-0.5.2.dist-info/licenses/LICENCE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
23
+ climate_ref_core-0.5.2.dist-info/licenses/NOTICE,sha256=4qTlax9aX2-mswYJuVrLqJ9jK1IkN5kSBqfVvYLF3Ws,128
24
+ climate_ref_core-0.5.2.dist-info/RECORD,,
@@ -1,22 +0,0 @@
1
- climate_ref_core/__init__.py,sha256=MtmPThF2F9_2UODEN6rt1x30LDxrHIZ0wyRN_wsHx5I,127
2
- climate_ref_core/constraints.py,sha256=QOqMh5jDBxdWTnQw2HNBizJQDF6Uu97rfJp9WudQWHc,11819
3
- climate_ref_core/dataset_registry.py,sha256=pUdfGkNfQEpgx4RYn5F_O0LQJp7R-lEWQY9bYFtenOo,5095
4
- climate_ref_core/datasets.py,sha256=CEyu3RM8dhtFEuC88nfQ61YfcuJbzflSso3aBHamVv4,4526
5
- climate_ref_core/diagnostics.py,sha256=q7sR_0RTYuNvdNjvOD8T5zhOuF6LQkDq2JWUUKvgshE,17614
6
- climate_ref_core/env.py,sha256=Ph2dejVxTELfP3bL0xES086WLGvV5H6KvsOwCkL6m-k,753
7
- climate_ref_core/exceptions.py,sha256=yFs7jWmnFlCYYLm5rWsqqa-JTQ44afqCR0uqqiq6ViM,1267
8
- climate_ref_core/executor.py,sha256=V4su108oR6FFHQM4aFlBvXAUYFoHMaEBSnqguhsVKhU,2780
9
- climate_ref_core/logging.py,sha256=WCAwbN6gGH-oNTFVYuDlitUf_eg3AU498dWHhXcMq1Q,4966
10
- climate_ref_core/providers.py,sha256=by_ZtoLQgg9A60CbFor2-i5EixtZTZ0z8jQqOGRfvA8,12461
11
- climate_ref_core/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
- climate_ref_core/pycmec/README.md,sha256=PzkovlPpsXqFopsYzz5GRvCAipNRGO1Wo-0gc17qr2Y,36
13
- climate_ref_core/pycmec/__init__.py,sha256=hXvKGEJQWyAp1i-ndr3D4zuYxkRhcR2LfXgFXlhYOk4,28
14
- climate_ref_core/pycmec/controlled_vocabulary.py,sha256=xio_4jl6mM_WMrwyxo70d0G5dUeIal4IW7eV-EMW4mU,5093
15
- climate_ref_core/pycmec/cv_cmip7_aft.yaml,sha256=Q_BYDlBclUpfm1XciwKZlRcR1E8YnuY7J4CUrpmXi7I,919
16
- climate_ref_core/pycmec/metric.py,sha256=sFzlUjXyMnINYXlS_46DgKqbiELIR_GSHm-qz7Jbwvk,13855
17
- climate_ref_core/pycmec/output.py,sha256=4-RQ439sfgNLeQZVDPB1pewF_kTwX7nCK0Z4U6bvbd0,5709
18
- climate_ref_core-0.5.0.dist-info/METADATA,sha256=fkVVeeEDT2y623QMbtQorg1GAZjXfYyyKotz0uHGGkk,2700
19
- climate_ref_core-0.5.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
20
- climate_ref_core-0.5.0.dist-info/licenses/LICENCE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
21
- climate_ref_core-0.5.0.dist-info/licenses/NOTICE,sha256=4qTlax9aX2-mswYJuVrLqJ9jK1IkN5kSBqfVvYLF3Ws,128
22
- climate_ref_core-0.5.0.dist-info/RECORD,,