climate-ref 0.5.0__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/__init__.py +30 -0
- climate_ref/_config_helpers.py +214 -0
- climate_ref/alembic.ini +114 -0
- climate_ref/cli/__init__.py +138 -0
- climate_ref/cli/_utils.py +68 -0
- climate_ref/cli/config.py +28 -0
- climate_ref/cli/datasets.py +205 -0
- climate_ref/cli/executions.py +201 -0
- climate_ref/cli/providers.py +84 -0
- climate_ref/cli/solve.py +23 -0
- climate_ref/config.py +475 -0
- climate_ref/constants.py +8 -0
- climate_ref/database.py +223 -0
- climate_ref/dataset_registry/obs4ref_reference.txt +2 -0
- climate_ref/dataset_registry/sample_data.txt +60 -0
- climate_ref/datasets/__init__.py +40 -0
- climate_ref/datasets/base.py +214 -0
- climate_ref/datasets/cmip6.py +202 -0
- climate_ref/datasets/obs4mips.py +224 -0
- climate_ref/datasets/pmp_climatology.py +15 -0
- climate_ref/datasets/utils.py +16 -0
- climate_ref/executor/__init__.py +274 -0
- climate_ref/executor/local.py +89 -0
- climate_ref/migrations/README +22 -0
- climate_ref/migrations/env.py +139 -0
- climate_ref/migrations/script.py.mako +26 -0
- climate_ref/migrations/versions/2025-05-02T1418_341a4aa2551e_regenerate.py +292 -0
- climate_ref/models/__init__.py +33 -0
- climate_ref/models/base.py +42 -0
- climate_ref/models/dataset.py +206 -0
- climate_ref/models/diagnostic.py +61 -0
- climate_ref/models/execution.py +306 -0
- climate_ref/models/metric_value.py +195 -0
- climate_ref/models/provider.py +39 -0
- climate_ref/provider_registry.py +146 -0
- climate_ref/py.typed +0 -0
- climate_ref/solver.py +395 -0
- climate_ref/testing.py +109 -0
- climate_ref-0.5.0.dist-info/METADATA +97 -0
- climate_ref-0.5.0.dist-info/RECORD +44 -0
- climate_ref-0.5.0.dist-info/WHEEL +4 -0
- climate_ref-0.5.0.dist-info/entry_points.txt +2 -0
- climate_ref-0.5.0.dist-info/licenses/LICENCE +201 -0
- climate_ref-0.5.0.dist-info/licenses/NOTICE +3 -0
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import traceback
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import pandas as pd
|
|
9
|
+
import xarray as xr
|
|
10
|
+
from ecgtools import Builder
|
|
11
|
+
from loguru import logger
|
|
12
|
+
|
|
13
|
+
from climate_ref.datasets.base import DatasetAdapter
|
|
14
|
+
from climate_ref.datasets.cmip6 import _parse_datetime
|
|
15
|
+
from climate_ref.models.dataset import Dataset, Obs4MIPsDataset
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def extract_attr_with_regex(
|
|
19
|
+
input_str: str, regex: str, strip_chars: str | None, ignore_case: bool
|
|
20
|
+
) -> list[Any] | None:
|
|
21
|
+
"""
|
|
22
|
+
Extract version information from attribute with regular expressions.
|
|
23
|
+
"""
|
|
24
|
+
if ignore_case:
|
|
25
|
+
pattern = re.compile(regex, re.IGNORECASE)
|
|
26
|
+
else:
|
|
27
|
+
pattern = re.compile(regex)
|
|
28
|
+
match = re.findall(pattern, input_str)
|
|
29
|
+
if match:
|
|
30
|
+
matchstr = max(match, key=len)
|
|
31
|
+
match = matchstr.strip(strip_chars) if strip_chars else matchstr.strip()
|
|
32
|
+
return match
|
|
33
|
+
else:
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def parse_obs4mips(file: str) -> dict[str, Any | None]:
|
|
38
|
+
"""Parser for obs4mips"""
|
|
39
|
+
keys = sorted(
|
|
40
|
+
list(
|
|
41
|
+
{
|
|
42
|
+
"activity_id",
|
|
43
|
+
"frequency",
|
|
44
|
+
"grid",
|
|
45
|
+
"grid_label",
|
|
46
|
+
"institution_id",
|
|
47
|
+
"nominal_resolution",
|
|
48
|
+
"realm",
|
|
49
|
+
"product",
|
|
50
|
+
"source_id",
|
|
51
|
+
"source_type",
|
|
52
|
+
"variable_id",
|
|
53
|
+
"variant_label",
|
|
54
|
+
}
|
|
55
|
+
)
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
time_coder = xr.coders.CFDatetimeCoder(use_cftime=True)
|
|
60
|
+
with xr.open_dataset(file, chunks={}, decode_times=time_coder) as ds:
|
|
61
|
+
has_none_value = any(ds.attrs.get(key) is None for key in keys)
|
|
62
|
+
if has_none_value:
|
|
63
|
+
missing_fields = [key for key in keys if ds.attrs.get(key) is None]
|
|
64
|
+
traceback_message = str(missing_fields) + " are missing from the file metadata"
|
|
65
|
+
raise AttributeError(traceback_message)
|
|
66
|
+
info = {key: ds.attrs.get(key) for key in keys}
|
|
67
|
+
|
|
68
|
+
if info["activity_id"] != "obs4MIPs":
|
|
69
|
+
traceback_message = f"{file} is not an obs4MIPs dataset"
|
|
70
|
+
raise TypeError(traceback_message)
|
|
71
|
+
|
|
72
|
+
variable_id = info["variable_id"]
|
|
73
|
+
|
|
74
|
+
if variable_id:
|
|
75
|
+
attrs = ds[variable_id].attrs
|
|
76
|
+
for attr in ["long_name", "units"]:
|
|
77
|
+
info[attr] = attrs.get(attr)
|
|
78
|
+
|
|
79
|
+
# Set the default of # of vertical levels to 1
|
|
80
|
+
vertical_levels = 1
|
|
81
|
+
start_time, end_time = None, None
|
|
82
|
+
try:
|
|
83
|
+
vertical_levels = ds[ds.cf["vertical"].name].size
|
|
84
|
+
except (KeyError, AttributeError, ValueError):
|
|
85
|
+
...
|
|
86
|
+
try:
|
|
87
|
+
start_time, end_time = str(ds.cf["T"][0].data), str(ds.cf["T"][-1].data)
|
|
88
|
+
except (KeyError, AttributeError, ValueError):
|
|
89
|
+
...
|
|
90
|
+
|
|
91
|
+
info["vertical_levels"] = vertical_levels
|
|
92
|
+
info["start_time"] = start_time
|
|
93
|
+
info["end_time"] = end_time
|
|
94
|
+
if not (start_time and end_time):
|
|
95
|
+
info["time_range"] = None
|
|
96
|
+
else:
|
|
97
|
+
info["time_range"] = f"{start_time}-{end_time}"
|
|
98
|
+
info["path"] = str(file)
|
|
99
|
+
info["source_version_number"] = (
|
|
100
|
+
extract_attr_with_regex(
|
|
101
|
+
str(file), regex=r"v\d{4}\d{2}\d{2}|v\d{1}", strip_chars=None, ignore_case=True
|
|
102
|
+
)
|
|
103
|
+
or "v0"
|
|
104
|
+
)
|
|
105
|
+
return info
|
|
106
|
+
|
|
107
|
+
except (TypeError, AttributeError) as err:
|
|
108
|
+
if (len(err.args)) == 1:
|
|
109
|
+
logger.warning(str(err.args[0]))
|
|
110
|
+
else:
|
|
111
|
+
logger.warning(str(err.args))
|
|
112
|
+
return {"INVALID_ASSET": file, "TRACEBACK": traceback_message}
|
|
113
|
+
except Exception:
|
|
114
|
+
logger.warning(traceback.format_exc())
|
|
115
|
+
return {"INVALID_ASSET": file, "TRACEBACK": traceback.format_exc()}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class Obs4MIPsDatasetAdapter(DatasetAdapter):
|
|
119
|
+
"""
|
|
120
|
+
Adapter for obs4MIPs datasets
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
dataset_cls: type[Dataset] = Obs4MIPsDataset
|
|
124
|
+
slug_column = "instance_id"
|
|
125
|
+
|
|
126
|
+
dataset_specific_metadata = (
|
|
127
|
+
"activity_id",
|
|
128
|
+
"frequency",
|
|
129
|
+
"grid",
|
|
130
|
+
"grid_label",
|
|
131
|
+
"institution_id",
|
|
132
|
+
"nominal_resolution",
|
|
133
|
+
"product",
|
|
134
|
+
"realm",
|
|
135
|
+
"source_id",
|
|
136
|
+
"source_type",
|
|
137
|
+
"variable_id",
|
|
138
|
+
"variant_label",
|
|
139
|
+
"long_name",
|
|
140
|
+
"units",
|
|
141
|
+
"vertical_levels",
|
|
142
|
+
"source_version_number",
|
|
143
|
+
slug_column,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
file_specific_metadata = ("start_time", "end_time", "path")
|
|
147
|
+
|
|
148
|
+
def __init__(self, n_jobs: int = 1):
|
|
149
|
+
self.n_jobs = n_jobs
|
|
150
|
+
|
|
151
|
+
def pretty_subset(self, data_catalog: pd.DataFrame) -> pd.DataFrame:
|
|
152
|
+
"""
|
|
153
|
+
Get a subset of the data_catalog to pretty print
|
|
154
|
+
|
|
155
|
+
This is particularly useful for obs4MIPs datasets, which have a lot of metadata columns.
|
|
156
|
+
|
|
157
|
+
Parameters
|
|
158
|
+
----------
|
|
159
|
+
data_catalog
|
|
160
|
+
Data catalog to subset
|
|
161
|
+
|
|
162
|
+
Returns
|
|
163
|
+
-------
|
|
164
|
+
:
|
|
165
|
+
Subset of the data catalog to pretty print
|
|
166
|
+
|
|
167
|
+
"""
|
|
168
|
+
return data_catalog[
|
|
169
|
+
[
|
|
170
|
+
"activity_id",
|
|
171
|
+
"institution_id",
|
|
172
|
+
"source_id",
|
|
173
|
+
"variable_id",
|
|
174
|
+
"grid_label",
|
|
175
|
+
"source_version_number",
|
|
176
|
+
]
|
|
177
|
+
]
|
|
178
|
+
|
|
179
|
+
def find_local_datasets(self, file_or_directory: Path) -> pd.DataFrame:
|
|
180
|
+
"""
|
|
181
|
+
Generate a data catalog from the specified file or directory
|
|
182
|
+
|
|
183
|
+
Each dataset may contain multiple files, which are represented as rows in the data catalog.
|
|
184
|
+
Each dataset has a unique identifier, which is in `slug_column`.
|
|
185
|
+
|
|
186
|
+
Parameters
|
|
187
|
+
----------
|
|
188
|
+
file_or_directory
|
|
189
|
+
File or directory containing the datasets
|
|
190
|
+
|
|
191
|
+
Returns
|
|
192
|
+
-------
|
|
193
|
+
:
|
|
194
|
+
Data catalog containing the metadata for the dataset
|
|
195
|
+
"""
|
|
196
|
+
builder = Builder(
|
|
197
|
+
paths=[str(file_or_directory)],
|
|
198
|
+
depth=10,
|
|
199
|
+
include_patterns=["*.nc"],
|
|
200
|
+
joblib_parallel_kwargs={"n_jobs": self.n_jobs},
|
|
201
|
+
).build(parsing_func=parse_obs4mips) # type: ignore[arg-type]
|
|
202
|
+
|
|
203
|
+
datasets = builder.df
|
|
204
|
+
if datasets.empty:
|
|
205
|
+
logger.error("No datasets found")
|
|
206
|
+
raise ValueError("No obs4MIPs-compliant datasets found")
|
|
207
|
+
|
|
208
|
+
# Convert the start_time and end_time columns to datetime objects
|
|
209
|
+
# We don't know the calendar used in the dataset (TODO: Check what ecgtools does)
|
|
210
|
+
datasets["start_time"] = _parse_datetime(datasets["start_time"])
|
|
211
|
+
datasets["end_time"] = _parse_datetime(datasets["end_time"])
|
|
212
|
+
|
|
213
|
+
drs_items = [
|
|
214
|
+
"activity_id",
|
|
215
|
+
"institution_id",
|
|
216
|
+
"source_id",
|
|
217
|
+
"variable_id",
|
|
218
|
+
"grid_label",
|
|
219
|
+
"source_version_number",
|
|
220
|
+
]
|
|
221
|
+
datasets["instance_id"] = datasets.apply(
|
|
222
|
+
lambda row: "obs4MIPs." + ".".join([row[item] for item in drs_items]), axis=1
|
|
223
|
+
)
|
|
224
|
+
return datasets
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from climate_ref.datasets.obs4mips import Obs4MIPsDatasetAdapter
|
|
4
|
+
from climate_ref.models.dataset import PMPClimatologyDataset
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class PMPClimatologyDatasetAdapter(Obs4MIPsDatasetAdapter):
|
|
8
|
+
"""
|
|
9
|
+
Adapter for climatology datasets post-processed from obs4MIPs datasets by PMP.
|
|
10
|
+
|
|
11
|
+
These data look like obs4MIPs datasets and are ingested in the same way, but
|
|
12
|
+
are treated separately as they may have the same metadata as the obs4MIPs datasets.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
dataset_cls = PMPClimatologyDataset
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def validate_path(raw_path: str) -> Path:
|
|
5
|
+
"""
|
|
6
|
+
Validate the prefix of a dataset against the data directory
|
|
7
|
+
"""
|
|
8
|
+
prefix = Path(raw_path)
|
|
9
|
+
|
|
10
|
+
if not prefix.exists():
|
|
11
|
+
raise FileNotFoundError(prefix)
|
|
12
|
+
|
|
13
|
+
if not prefix.is_absolute():
|
|
14
|
+
raise ValueError(f"Path {prefix} must be absolute")
|
|
15
|
+
|
|
16
|
+
return prefix
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Execute diagnostics in different environments
|
|
3
|
+
|
|
4
|
+
We support running diagnostics in different environments, such as locally,
|
|
5
|
+
in a separate process, or in a container.
|
|
6
|
+
These environments are represented by `climate_ref.executor.Executor` classes.
|
|
7
|
+
|
|
8
|
+
The simplest executor is the `LocalExecutor`, which runs the diagnostic in the same process.
|
|
9
|
+
This is useful for local testing and debugging.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import importlib
|
|
13
|
+
import pathlib
|
|
14
|
+
import shutil
|
|
15
|
+
from typing import TYPE_CHECKING
|
|
16
|
+
|
|
17
|
+
from loguru import logger
|
|
18
|
+
from sqlalchemy import insert
|
|
19
|
+
|
|
20
|
+
from climate_ref.database import Database
|
|
21
|
+
from climate_ref.models.execution import Execution, ExecutionOutput, ResultOutputType
|
|
22
|
+
from climate_ref.models.metric_value import MetricValue
|
|
23
|
+
from climate_ref_core.diagnostics import ExecutionResult, ensure_relative_path
|
|
24
|
+
from climate_ref_core.exceptions import InvalidExecutorException, ResultValidationError
|
|
25
|
+
from climate_ref_core.executor import EXECUTION_LOG_FILENAME, Executor
|
|
26
|
+
from climate_ref_core.pycmec.controlled_vocabulary import CV
|
|
27
|
+
from climate_ref_core.pycmec.metric import CMECMetric
|
|
28
|
+
from climate_ref_core.pycmec.output import CMECOutput, OutputDict
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from climate_ref.config import Config
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def import_executor_cls(fqn: str) -> type[Executor]:
|
|
35
|
+
"""
|
|
36
|
+
Import an executor using a fully qualified module path
|
|
37
|
+
|
|
38
|
+
Parameters
|
|
39
|
+
----------
|
|
40
|
+
fqn
|
|
41
|
+
Full package and attribute name of the executor to import
|
|
42
|
+
|
|
43
|
+
For example: `climate_ref_example.executor` will use the `executor` attribute from the
|
|
44
|
+
`climate_ref_example` package.
|
|
45
|
+
|
|
46
|
+
Raises
|
|
47
|
+
------
|
|
48
|
+
climate_ref_core.exceptions.InvalidExecutorException
|
|
49
|
+
If the executor cannot be imported
|
|
50
|
+
|
|
51
|
+
If the executor isn't a valid `DiagnosticProvider`.
|
|
52
|
+
|
|
53
|
+
Returns
|
|
54
|
+
-------
|
|
55
|
+
:
|
|
56
|
+
Executor instance
|
|
57
|
+
"""
|
|
58
|
+
module, attribute_name = fqn.rsplit(".", 1)
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
imp = importlib.import_module(module)
|
|
62
|
+
executor: type[Executor] = getattr(imp, attribute_name)
|
|
63
|
+
|
|
64
|
+
# We can't really check if the executor is a subclass of Executor here
|
|
65
|
+
# Protocols can't be used with issubclass if they have non-method members
|
|
66
|
+
# We have to check this at class instantiation time
|
|
67
|
+
|
|
68
|
+
return executor
|
|
69
|
+
except ModuleNotFoundError:
|
|
70
|
+
logger.error(f"Package '{fqn}' not found")
|
|
71
|
+
raise InvalidExecutorException(fqn, f"Module '{module}' not found")
|
|
72
|
+
except AttributeError:
|
|
73
|
+
logger.error(f"Provider '{fqn}' not found")
|
|
74
|
+
raise InvalidExecutorException(fqn, f"Executor '{attribute_name}' not found in {module}")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _copy_file_to_results(
|
|
78
|
+
scratch_directory: pathlib.Path,
|
|
79
|
+
results_directory: pathlib.Path,
|
|
80
|
+
fragment: pathlib.Path | str,
|
|
81
|
+
filename: pathlib.Path | str,
|
|
82
|
+
) -> None:
|
|
83
|
+
"""
|
|
84
|
+
Copy a file from the scratch directory to the executions directory
|
|
85
|
+
|
|
86
|
+
Parameters
|
|
87
|
+
----------
|
|
88
|
+
scratch_directory
|
|
89
|
+
The directory where the file is currently located
|
|
90
|
+
results_directory
|
|
91
|
+
The directory where the file should be copied to
|
|
92
|
+
fragment
|
|
93
|
+
The fragment of the executions directory where the file should be copied
|
|
94
|
+
filename
|
|
95
|
+
The name of the file to be copied
|
|
96
|
+
"""
|
|
97
|
+
assert results_directory != scratch_directory # noqa
|
|
98
|
+
input_directory = scratch_directory / fragment
|
|
99
|
+
output_directory = results_directory / fragment
|
|
100
|
+
|
|
101
|
+
filename = ensure_relative_path(filename, input_directory)
|
|
102
|
+
|
|
103
|
+
if not (input_directory / filename).exists():
|
|
104
|
+
raise FileNotFoundError(f"Could not find {filename} in {input_directory}")
|
|
105
|
+
|
|
106
|
+
output_filename = output_directory / filename
|
|
107
|
+
output_filename.parent.mkdir(parents=True, exist_ok=True)
|
|
108
|
+
|
|
109
|
+
shutil.copy(input_directory / filename, output_filename)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def handle_execution_result(
|
|
113
|
+
config: "Config",
|
|
114
|
+
database: Database,
|
|
115
|
+
execution: Execution,
|
|
116
|
+
result: "ExecutionResult",
|
|
117
|
+
) -> None:
|
|
118
|
+
"""
|
|
119
|
+
Handle the result of a diagnostic execution
|
|
120
|
+
|
|
121
|
+
This will update the diagnostic execution result with the output of the diagnostic execution.
|
|
122
|
+
The output will be copied from the scratch directory to the executions directory.
|
|
123
|
+
|
|
124
|
+
Parameters
|
|
125
|
+
----------
|
|
126
|
+
config
|
|
127
|
+
The configuration to use
|
|
128
|
+
database
|
|
129
|
+
The active database session to use
|
|
130
|
+
execution
|
|
131
|
+
The diagnostic execution result DB object to update
|
|
132
|
+
result
|
|
133
|
+
The result of the diagnostic execution, either successful or failed
|
|
134
|
+
"""
|
|
135
|
+
# Always copy log data
|
|
136
|
+
_copy_file_to_results(
|
|
137
|
+
config.paths.scratch,
|
|
138
|
+
config.paths.results,
|
|
139
|
+
execution.output_fragment,
|
|
140
|
+
EXECUTION_LOG_FILENAME,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
if result.successful and result.metric_bundle_filename is not None:
|
|
144
|
+
logger.info(f"{execution} successful")
|
|
145
|
+
|
|
146
|
+
_copy_file_to_results(
|
|
147
|
+
config.paths.scratch,
|
|
148
|
+
config.paths.results,
|
|
149
|
+
execution.output_fragment,
|
|
150
|
+
result.metric_bundle_filename,
|
|
151
|
+
)
|
|
152
|
+
execution.mark_successful(result.as_relative_path(result.metric_bundle_filename))
|
|
153
|
+
|
|
154
|
+
if result.output_bundle_filename:
|
|
155
|
+
_copy_file_to_results(
|
|
156
|
+
config.paths.scratch,
|
|
157
|
+
config.paths.results,
|
|
158
|
+
execution.output_fragment,
|
|
159
|
+
result.output_bundle_filename,
|
|
160
|
+
)
|
|
161
|
+
_handle_output_bundle(
|
|
162
|
+
config,
|
|
163
|
+
database,
|
|
164
|
+
execution,
|
|
165
|
+
result.to_output_path(result.output_bundle_filename),
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
cmec_metric_bundle = CMECMetric.load_from_json(result.to_output_path(result.metric_bundle_filename))
|
|
169
|
+
|
|
170
|
+
# Check that the diagnostic values conform with the controlled vocabulary
|
|
171
|
+
try:
|
|
172
|
+
cv = CV.load_from_file(config.paths.dimensions_cv)
|
|
173
|
+
cv.validate_metrics(cmec_metric_bundle)
|
|
174
|
+
except (ResultValidationError, AssertionError):
|
|
175
|
+
logger.exception("Diagnostic values do not conform with the controlled vocabulary")
|
|
176
|
+
# TODO: Mark the diagnostic execution result as failed once the CV has stabilised
|
|
177
|
+
# execution.mark_failed()
|
|
178
|
+
|
|
179
|
+
# Perform a bulk insert of a diagnostic bundle
|
|
180
|
+
# TODO: The section below will likely fail until we have agreed on a controlled vocabulary
|
|
181
|
+
# The current implementation will swallow the exception, but display a log message
|
|
182
|
+
try:
|
|
183
|
+
# Perform this in a nested transaction to (hopefully) gracefully rollback if something
|
|
184
|
+
# goes wrong
|
|
185
|
+
with database.session.begin_nested():
|
|
186
|
+
database.session.execute(
|
|
187
|
+
insert(MetricValue),
|
|
188
|
+
[
|
|
189
|
+
{
|
|
190
|
+
"execution_id": execution.id,
|
|
191
|
+
"value": result.value,
|
|
192
|
+
"attributes": result.attributes,
|
|
193
|
+
**result.dimensions,
|
|
194
|
+
}
|
|
195
|
+
for result in cmec_metric_bundle.iter_results()
|
|
196
|
+
],
|
|
197
|
+
)
|
|
198
|
+
except Exception:
|
|
199
|
+
# TODO: Remove once we have settled on a controlled vocabulary
|
|
200
|
+
logger.exception("Something went wrong when ingesting diagnostic values")
|
|
201
|
+
|
|
202
|
+
# TODO: This should check if the result is the most recent for the execution,
|
|
203
|
+
# if so then update the dirty fields
|
|
204
|
+
# i.e. if there are outstanding executions don't make as clean
|
|
205
|
+
execution.execution_group.dirty = False
|
|
206
|
+
else:
|
|
207
|
+
logger.error(f"{execution} failed")
|
|
208
|
+
execution.mark_failed()
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _handle_output_bundle(
|
|
212
|
+
config: "Config",
|
|
213
|
+
database: Database,
|
|
214
|
+
execution: Execution,
|
|
215
|
+
cmec_output_bundle_filename: pathlib.Path,
|
|
216
|
+
) -> None:
|
|
217
|
+
# Extract the registered outputs
|
|
218
|
+
# Copy the content to the output directory
|
|
219
|
+
# Track in the db
|
|
220
|
+
cmec_output_bundle = CMECOutput.load_from_json(cmec_output_bundle_filename)
|
|
221
|
+
_handle_outputs(
|
|
222
|
+
cmec_output_bundle.plots,
|
|
223
|
+
output_type=ResultOutputType.Plot,
|
|
224
|
+
config=config,
|
|
225
|
+
database=database,
|
|
226
|
+
execution=execution,
|
|
227
|
+
)
|
|
228
|
+
_handle_outputs(
|
|
229
|
+
cmec_output_bundle.data,
|
|
230
|
+
output_type=ResultOutputType.Data,
|
|
231
|
+
config=config,
|
|
232
|
+
database=database,
|
|
233
|
+
execution=execution,
|
|
234
|
+
)
|
|
235
|
+
_handle_outputs(
|
|
236
|
+
cmec_output_bundle.html,
|
|
237
|
+
output_type=ResultOutputType.HTML,
|
|
238
|
+
config=config,
|
|
239
|
+
database=database,
|
|
240
|
+
execution=execution,
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _handle_outputs(
|
|
245
|
+
outputs: dict[str, OutputDict] | None,
|
|
246
|
+
output_type: ResultOutputType,
|
|
247
|
+
config: "Config",
|
|
248
|
+
database: Database,
|
|
249
|
+
execution: Execution,
|
|
250
|
+
) -> None:
|
|
251
|
+
if outputs is None:
|
|
252
|
+
return
|
|
253
|
+
|
|
254
|
+
for key, output_info in outputs.items():
|
|
255
|
+
filename = ensure_relative_path(
|
|
256
|
+
output_info.filename, config.paths.scratch / execution.output_fragment
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
_copy_file_to_results(
|
|
260
|
+
config.paths.scratch,
|
|
261
|
+
config.paths.results,
|
|
262
|
+
execution.output_fragment,
|
|
263
|
+
filename,
|
|
264
|
+
)
|
|
265
|
+
database.session.add(
|
|
266
|
+
ExecutionOutput(
|
|
267
|
+
execution_id=execution.id,
|
|
268
|
+
output_type=output_type,
|
|
269
|
+
filename=str(filename),
|
|
270
|
+
description=output_info.description,
|
|
271
|
+
short_name=key,
|
|
272
|
+
long_name=output_info.long_name,
|
|
273
|
+
)
|
|
274
|
+
)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from loguru import logger
|
|
4
|
+
|
|
5
|
+
from climate_ref.config import Config
|
|
6
|
+
from climate_ref.database import Database
|
|
7
|
+
from climate_ref.executor import handle_execution_result
|
|
8
|
+
from climate_ref.models import Execution
|
|
9
|
+
from climate_ref_core.diagnostics import Diagnostic, ExecutionDefinition, ExecutionResult
|
|
10
|
+
from climate_ref_core.logging import redirect_logs
|
|
11
|
+
from climate_ref_core.providers import DiagnosticProvider
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class LocalExecutor:
|
|
15
|
+
"""
|
|
16
|
+
Run a diagnostic locally, in-process.
|
|
17
|
+
|
|
18
|
+
This is mainly useful for debugging and testing.
|
|
19
|
+
The production executor will run the diagnostic in a separate process or container,
|
|
20
|
+
the exact manner of which is yet to be determined.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
name = "local"
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self, *, database: Database | None = None, config: Config | None = None, **kwargs: Any
|
|
27
|
+
) -> None:
|
|
28
|
+
if config is None:
|
|
29
|
+
config = Config.default()
|
|
30
|
+
if database is None:
|
|
31
|
+
database = Database.from_config(config, run_migrations=False)
|
|
32
|
+
|
|
33
|
+
self.database = database
|
|
34
|
+
self.config = config
|
|
35
|
+
|
|
36
|
+
def run(
|
|
37
|
+
self,
|
|
38
|
+
provider: DiagnosticProvider,
|
|
39
|
+
diagnostic: Diagnostic,
|
|
40
|
+
definition: ExecutionDefinition,
|
|
41
|
+
execution: Execution | None = None,
|
|
42
|
+
) -> None:
|
|
43
|
+
"""
|
|
44
|
+
Run a diagnostic in process
|
|
45
|
+
|
|
46
|
+
Parameters
|
|
47
|
+
----------
|
|
48
|
+
provider
|
|
49
|
+
The provider of the diagnostic
|
|
50
|
+
diagnostic
|
|
51
|
+
Diagnostic to run
|
|
52
|
+
definition
|
|
53
|
+
A description of the information needed for this execution of the diagnostic
|
|
54
|
+
execution
|
|
55
|
+
A database model representing the execution of the diagnostic.
|
|
56
|
+
If provided, the result will be updated in the database when completed.
|
|
57
|
+
"""
|
|
58
|
+
definition.output_directory.mkdir(parents=True, exist_ok=True)
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
with redirect_logs(definition, self.config.log_level):
|
|
62
|
+
result = diagnostic.run(definition=definition)
|
|
63
|
+
except Exception:
|
|
64
|
+
if execution is not None: # pragma: no branch
|
|
65
|
+
info_msg = (
|
|
66
|
+
f"\nAdditional information about this execution can be viewed using: "
|
|
67
|
+
f"ref executions inspect {execution.execution_group_id}"
|
|
68
|
+
)
|
|
69
|
+
else:
|
|
70
|
+
info_msg = ""
|
|
71
|
+
|
|
72
|
+
logger.exception(f"Error running diagnostic {diagnostic.slug}. {info_msg}")
|
|
73
|
+
result = ExecutionResult.build_from_failure(definition)
|
|
74
|
+
|
|
75
|
+
if execution:
|
|
76
|
+
handle_execution_result(self.config, self.database, execution, result)
|
|
77
|
+
|
|
78
|
+
def join(self, timeout: float) -> None:
|
|
79
|
+
"""
|
|
80
|
+
Wait for all diagnostics to finish
|
|
81
|
+
|
|
82
|
+
This returns immediately because the local executor runs diagnostics synchronously.
|
|
83
|
+
|
|
84
|
+
Parameters
|
|
85
|
+
----------
|
|
86
|
+
timeout
|
|
87
|
+
Timeout in seconds (Not used)
|
|
88
|
+
"""
|
|
89
|
+
return
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Alembic
|
|
2
|
+
|
|
3
|
+
Alembic is a database migration tool.
|
|
4
|
+
It interoperates with SqlAlchemy to determine how the currently declared models differ from what the database
|
|
5
|
+
expects and generates a migration to apply the changes.
|
|
6
|
+
|
|
7
|
+
The migrations are applied at run-time automatically (see [ref.database.Database]()).
|
|
8
|
+
|
|
9
|
+
## Generating migrations
|
|
10
|
+
|
|
11
|
+
To generate a migration,
|
|
12
|
+
you can use the `uv run` command with the `alembic` package and the `revision` command.
|
|
13
|
+
The `--rev-id` flag is used to specify the revision id.
|
|
14
|
+
If it is omitted the revision id will be generated automatically.
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
uv run --package ref alembic revision --rev-id 0.1.0 --message "initial table" --autogenerate
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
How we name and manage these migrations is still a work in progress.
|
|
21
|
+
It might be nice to have a way to automatically generate the revision id based on the version of the package.
|
|
22
|
+
This would allow us to easily track which migrations have been applied to the database.
|