climate-ref-core 0.5.0__py3-none-any.whl → 0.5.1__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.
- climate_ref_core/datasets.py +62 -1
- climate_ref_core/diagnostics.py +18 -2
- climate_ref_core/exceptions.py +7 -0
- climate_ref_core/executor.py +84 -13
- climate_ref_core/logging.py +16 -5
- climate_ref_core/metric_values/__init__.py +16 -0
- climate_ref_core/metric_values/typing.py +74 -0
- climate_ref_core/pycmec/cv_cmip7_aft.yaml +62 -11
- climate_ref_core/pycmec/metric.py +141 -19
- {climate_ref_core-0.5.0.dist-info → climate_ref_core-0.5.1.dist-info}/METADATA +1 -1
- climate_ref_core-0.5.1.dist-info/RECORD +24 -0
- climate_ref_core-0.5.0.dist-info/RECORD +0 -22
- {climate_ref_core-0.5.0.dist-info → climate_ref_core-0.5.1.dist-info}/WHEEL +0 -0
- {climate_ref_core-0.5.0.dist-info → climate_ref_core-0.5.1.dist-info}/licenses/LICENCE +0 -0
- {climate_ref_core-0.5.0.dist-info → climate_ref_core-0.5.1.dist-info}/licenses/NOTICE +0 -0
climate_ref_core/datasets.py
CHANGED
|
@@ -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:
|
|
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
|
climate_ref_core/diagnostics.py
CHANGED
|
@@ -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
|
-
|
|
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
|
|
445
|
+
The implementation of this method is left to the diagnostic providers.
|
|
430
446
|
|
|
431
447
|
|
|
432
448
|
Parameters
|
climate_ref_core/exceptions.py
CHANGED
|
@@ -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)
|
climate_ref_core/executor.py
CHANGED
|
@@ -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
|
|
8
|
-
|
|
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
|
-
|
|
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}")
|
climate_ref_core/logging.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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:
|
|
3
|
-
long_name:
|
|
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:
|
|
8
|
-
long_name:
|
|
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: [
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
231
|
+
root: dict[str, float | int]
|
|
215
232
|
|
|
216
233
|
|
|
217
|
-
|
|
234
|
+
def remove_dimensions(raw_metric_bundle: dict[str, Any], dimensions: str | list[str]) -> dict[str, Any]:
|
|
218
235
|
"""
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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[
|
|
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[
|
|
413
|
-
assert len(dimensions), "Not enough dimensions"
|
|
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,
|
|
420
|
-
yield
|
|
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.
|
|
3
|
+
Version: 0.5.1
|
|
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=pUdfGkNfQEpgx4RYn5F_O0LQJp7R-lEWQY9bYFtenOo,5095
|
|
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.1.dist-info/METADATA,sha256=Plx7GjLSqiqkkjt64Iljkv9q3mp3X03FFJlY1L7Ov6A,2700
|
|
21
|
+
climate_ref_core-0.5.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
22
|
+
climate_ref_core-0.5.1.dist-info/licenses/LICENCE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
|
|
23
|
+
climate_ref_core-0.5.1.dist-info/licenses/NOTICE,sha256=4qTlax9aX2-mswYJuVrLqJ9jK1IkN5kSBqfVvYLF3Ws,128
|
|
24
|
+
climate_ref_core-0.5.1.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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|