climate-ref 0.6.6__py3-none-any.whl → 0.8.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/cli/__init__.py +12 -3
- climate_ref/cli/_utils.py +56 -2
- climate_ref/cli/datasets.py +49 -12
- climate_ref/cli/executions.py +333 -24
- climate_ref/cli/providers.py +1 -2
- climate_ref/config.py +67 -4
- climate_ref/database.py +62 -4
- climate_ref/dataset_registry/obs4ref_reference.txt +0 -9
- climate_ref/dataset_registry/sample_data.txt +10 -19
- climate_ref/datasets/__init__.py +3 -3
- climate_ref/datasets/base.py +121 -20
- climate_ref/datasets/cmip6.py +2 -0
- climate_ref/datasets/obs4mips.py +26 -15
- climate_ref/executor/hpc.py +149 -53
- climate_ref/executor/local.py +1 -2
- climate_ref/executor/result_handling.py +17 -7
- climate_ref/migrations/env.py +12 -10
- climate_ref/migrations/versions/2025-09-10T1358_2f6e36738e06_use_version_as_version_facet_for_.py +35 -0
- climate_ref/migrations/versions/2025-09-22T2359_20cd136a5b04_add_pmp_version.py +35 -0
- climate_ref/models/__init__.py +1 -6
- climate_ref/models/base.py +4 -20
- climate_ref/models/dataset.py +2 -0
- climate_ref/models/diagnostic.py +2 -1
- climate_ref/models/execution.py +219 -7
- climate_ref/models/metric_value.py +25 -110
- climate_ref/models/mixins.py +144 -0
- climate_ref/models/provider.py +2 -1
- climate_ref/provider_registry.py +4 -4
- climate_ref/slurm.py +2 -2
- climate_ref/solver.py +17 -6
- climate_ref/testing.py +1 -1
- {climate_ref-0.6.6.dist-info → climate_ref-0.8.0.dist-info}/METADATA +1 -1
- climate_ref-0.8.0.dist-info/RECORD +58 -0
- {climate_ref-0.6.6.dist-info → climate_ref-0.8.0.dist-info}/WHEEL +1 -1
- climate_ref-0.6.6.dist-info/RECORD +0 -55
- {climate_ref-0.6.6.dist-info → climate_ref-0.8.0.dist-info}/entry_points.txt +0 -0
- {climate_ref-0.6.6.dist-info → climate_ref-0.8.0.dist-info}/licenses/LICENCE +0 -0
- {climate_ref-0.6.6.dist-info → climate_ref-0.8.0.dist-info}/licenses/NOTICE +0 -0
climate_ref/executor/hpc.py
CHANGED
|
@@ -19,8 +19,9 @@ except ImportError: # pragma: no cover
|
|
|
19
19
|
)
|
|
20
20
|
|
|
21
21
|
import os
|
|
22
|
+
import re
|
|
22
23
|
import time
|
|
23
|
-
from typing import Any
|
|
24
|
+
from typing import Annotated, Any, Literal
|
|
24
25
|
|
|
25
26
|
import parsl
|
|
26
27
|
from loguru import logger
|
|
@@ -29,6 +30,7 @@ from parsl.config import Config as ParslConfig
|
|
|
29
30
|
from parsl.executors import HighThroughputExecutor
|
|
30
31
|
from parsl.launchers import SimpleLauncher, SrunLauncher
|
|
31
32
|
from parsl.providers import SlurmProvider
|
|
33
|
+
from pydantic import BaseModel, Field, StrictBool, field_validator, model_validator
|
|
32
34
|
from tqdm import tqdm
|
|
33
35
|
|
|
34
36
|
from climate_ref.config import Config
|
|
@@ -43,6 +45,72 @@ from .local import ExecutionFuture, process_result
|
|
|
43
45
|
from .pbs_scheduler import SmartPBSProvider
|
|
44
46
|
|
|
45
47
|
|
|
48
|
+
class SlurmConfig(BaseModel):
|
|
49
|
+
"""Slurm Configurations"""
|
|
50
|
+
|
|
51
|
+
scheduler: Literal["slurm"]
|
|
52
|
+
account: str
|
|
53
|
+
username: str
|
|
54
|
+
partition: str | None = None
|
|
55
|
+
log_dir: str = "runinfo"
|
|
56
|
+
qos: str | None = None
|
|
57
|
+
req_nodes: Annotated[int, Field(strict=True, ge=1, le=1000)] = 1
|
|
58
|
+
cores_per_worker: Annotated[int, Field(strict=True, ge=1, le=1000)] = 1
|
|
59
|
+
mem_per_worker: Annotated[float, Field(strict=True, gt=0, lt=1000.0)] | None = None
|
|
60
|
+
max_workers_per_node: Annotated[int, Field(strict=True, ge=1, le=1000)] = 16
|
|
61
|
+
validation: StrictBool = False
|
|
62
|
+
walltime: str = "00:30:00"
|
|
63
|
+
scheduler_options: str = ""
|
|
64
|
+
retries: Annotated[int, Field(strict=True, ge=1, le=3)] = 2
|
|
65
|
+
max_blocks: Annotated[int, Field(strict=True, ge=1)] = 1 # one block mean one job?
|
|
66
|
+
worker_init: str = ""
|
|
67
|
+
overrides: str = ""
|
|
68
|
+
cmd_timeout: Annotated[int, Field(strict=True, ge=0)] = 120
|
|
69
|
+
cpu_affinity: str = "none"
|
|
70
|
+
|
|
71
|
+
@model_validator(mode="before")
|
|
72
|
+
def _check_parition_qos(cls, data: Any) -> Any:
|
|
73
|
+
if not ("partition" in data or "qos" in data):
|
|
74
|
+
raise ValueError("partition or qos is needed")
|
|
75
|
+
return data
|
|
76
|
+
|
|
77
|
+
@field_validator("scheduler_options")
|
|
78
|
+
def _validate_sbatch_syntax(cls, v: str | None) -> Any:
|
|
79
|
+
if not v:
|
|
80
|
+
return v
|
|
81
|
+
|
|
82
|
+
sbatch_pattern = re.compile(
|
|
83
|
+
r"^\s*#SBATCH\s+" # Start with #SBATCH
|
|
84
|
+
r"(?:-\w+\s+[^\s]+" # Option-value pairs
|
|
85
|
+
r"(?:\s+-\w+\s+[^\s]+)*)" # Additional options
|
|
86
|
+
r"\s*$",
|
|
87
|
+
re.IGNORECASE | re.MULTILINE,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
invalid_lines = [
|
|
91
|
+
line
|
|
92
|
+
for line in v.split("\n")
|
|
93
|
+
if not (line.strip().upper().startswith("#SBATCH") and sbatch_pattern.match(line.strip()))
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
if invalid_lines:
|
|
97
|
+
error_msg = (
|
|
98
|
+
"Invalid SBATCH directives:\n"
|
|
99
|
+
+ "\n".join(invalid_lines)
|
|
100
|
+
+ "\n"
|
|
101
|
+
+ "Expected format: '#SBATCH -option value [-option value ...]'"
|
|
102
|
+
)
|
|
103
|
+
raise ValueError(error_msg)
|
|
104
|
+
return v
|
|
105
|
+
|
|
106
|
+
@field_validator("walltime")
|
|
107
|
+
def _validate_walltime(cls, v: str) -> str:
|
|
108
|
+
pattern = r"^(\d+-)?\d{1,5}:[0-5][0-9]:[0-5][0-9]$"
|
|
109
|
+
if not re.match(pattern, v):
|
|
110
|
+
raise ValueError("Walltime must be in `D-HH:MM:SS/HH:MM:SS` format")
|
|
111
|
+
return v
|
|
112
|
+
|
|
113
|
+
|
|
46
114
|
@python_app
|
|
47
115
|
def _process_run(definition: ExecutionDefinition, log_level: str) -> ExecutionResult:
|
|
48
116
|
"""Run the function on computer nodes"""
|
|
@@ -112,13 +180,18 @@ class HPCExecutor:
|
|
|
112
180
|
self.cores_per_worker = _to_int(executor_config.get("cores_per_worker"))
|
|
113
181
|
self.mem_per_worker = _to_float(executor_config.get("mem_per_worker"))
|
|
114
182
|
|
|
115
|
-
|
|
183
|
+
if self.scheduler == "slurm":
|
|
184
|
+
self.slurm_config = SlurmConfig.model_validate(executor_config)
|
|
185
|
+
hours, minutes, seconds = map(int, self.slurm_config.walltime.split(":"))
|
|
186
|
+
|
|
187
|
+
if self.slurm_config.validation and HAS_REAL_SLURM:
|
|
188
|
+
self._validate_slurm_params()
|
|
189
|
+
else:
|
|
190
|
+
hours, minutes, seconds = map(int, self.walltime.split(":"))
|
|
191
|
+
|
|
116
192
|
total_minutes = hours * 60 + minutes + seconds / 60
|
|
117
193
|
self.total_minutes = total_minutes
|
|
118
194
|
|
|
119
|
-
if executor_config.get("validation") and HAS_REAL_SLURM:
|
|
120
|
-
self._validate_slurm_params()
|
|
121
|
-
|
|
122
195
|
self._initialize_parsl()
|
|
123
196
|
|
|
124
197
|
self.parsl_results: list[ExecutionFuture] = []
|
|
@@ -131,45 +204,52 @@ class HPCExecutor:
|
|
|
131
204
|
ValueError: If account, partition or QOS are invalid or inaccessible.
|
|
132
205
|
"""
|
|
133
206
|
slurm_checker = SlurmChecker()
|
|
134
|
-
if self.account and not slurm_checker.get_account_info(self.account):
|
|
135
|
-
raise ValueError(f"Account: {self.account} not valid")
|
|
207
|
+
if self.slurm_config.account and not slurm_checker.get_account_info(self.slurm_config.account):
|
|
208
|
+
raise ValueError(f"Account: {self.slurm_config.account} not valid")
|
|
136
209
|
|
|
137
210
|
partition_limits = None
|
|
138
211
|
node_info = None
|
|
139
212
|
|
|
140
|
-
if self.partition:
|
|
141
|
-
if not slurm_checker.get_partition_info(self.partition):
|
|
142
|
-
raise ValueError(f"Partition: {self.partition} not valid")
|
|
213
|
+
if self.slurm_config.partition:
|
|
214
|
+
if not slurm_checker.get_partition_info(self.slurm_config.partition):
|
|
215
|
+
raise ValueError(f"Partition: {self.slurm_config.partition} not valid")
|
|
143
216
|
|
|
144
|
-
if not slurm_checker.can_account_use_partition(
|
|
145
|
-
|
|
217
|
+
if not slurm_checker.can_account_use_partition(
|
|
218
|
+
self.slurm_config.account, self.slurm_config.partition
|
|
219
|
+
):
|
|
220
|
+
raise ValueError(
|
|
221
|
+
f"Account: {self.slurm_config.account}"
|
|
222
|
+
f" cannot access partiton: {self.slurm_config.partition}"
|
|
223
|
+
)
|
|
146
224
|
|
|
147
|
-
partition_limits = slurm_checker.get_partition_limits(self.partition)
|
|
148
|
-
node_info = slurm_checker.get_node_from_partition(self.partition)
|
|
225
|
+
partition_limits = slurm_checker.get_partition_limits(self.slurm_config.partition)
|
|
226
|
+
node_info = slurm_checker.get_node_from_partition(self.slurm_config.partition)
|
|
149
227
|
|
|
150
228
|
qos_limits = None
|
|
151
|
-
if self.qos:
|
|
152
|
-
if not slurm_checker.get_qos_info(self.qos):
|
|
153
|
-
raise ValueError(f"QOS: {self.qos} not valid")
|
|
229
|
+
if self.slurm_config.qos:
|
|
230
|
+
if not slurm_checker.get_qos_info(self.slurm_config.qos):
|
|
231
|
+
raise ValueError(f"QOS: {self.slurm_config.qos} not valid")
|
|
154
232
|
|
|
155
|
-
if not slurm_checker.can_account_use_qos(self.account, self.qos):
|
|
156
|
-
raise ValueError(
|
|
233
|
+
if not slurm_checker.can_account_use_qos(self.slurm_config.account, self.slurm_config.qos):
|
|
234
|
+
raise ValueError(
|
|
235
|
+
f"Account: {self.slurm_config.account} cannot access qos: {self.slurm_config.qos}"
|
|
236
|
+
)
|
|
157
237
|
|
|
158
|
-
qos_limits = slurm_checker.get_qos_limits(self.qos)
|
|
238
|
+
qos_limits = slurm_checker.get_qos_limits(self.slurm_config.qos)
|
|
159
239
|
|
|
160
240
|
max_cores_per_node = int(node_info["cpus"]) if node_info else None
|
|
161
|
-
if max_cores_per_node and self.cores_per_worker:
|
|
162
|
-
if self.cores_per_worker > max_cores_per_node:
|
|
241
|
+
if max_cores_per_node and self.slurm_config.cores_per_worker:
|
|
242
|
+
if self.slurm_config.cores_per_worker > max_cores_per_node:
|
|
163
243
|
raise ValueError(
|
|
164
|
-
f"cores_per_work:{self.cores_per_worker}"
|
|
244
|
+
f"cores_per_work:{self.slurm_config.cores_per_worker}"
|
|
165
245
|
f"larger than the maximum in a node {max_cores_per_node}"
|
|
166
246
|
)
|
|
167
247
|
|
|
168
248
|
max_mem_per_node = float(node_info["real_memory"]) if node_info else None
|
|
169
|
-
if max_mem_per_node and self.mem_per_worker:
|
|
170
|
-
if self.mem_per_worker > max_mem_per_node:
|
|
249
|
+
if max_mem_per_node and self.slurm_config.mem_per_worker:
|
|
250
|
+
if self.slurm_config.mem_per_worker > max_mem_per_node:
|
|
171
251
|
raise ValueError(
|
|
172
|
-
f"mem_per_work:{self.mem_per_worker}"
|
|
252
|
+
f"mem_per_work:{self.slurm_config.mem_per_worker}"
|
|
173
253
|
f"larger than the maximum mem in a node {max_mem_per_node}"
|
|
174
254
|
)
|
|
175
255
|
|
|
@@ -182,8 +262,8 @@ class HPCExecutor:
|
|
|
182
262
|
|
|
183
263
|
if self.total_minutes > float(max_walltime_minutes):
|
|
184
264
|
raise ValueError(
|
|
185
|
-
f"Walltime: {self.walltime} exceed the maximum time "
|
|
186
|
-
f"{max_walltime_minutes} allowed by {self.partition} and {self.qos}"
|
|
265
|
+
f"Walltime: {self.slurm_config.walltime} exceed the maximum time "
|
|
266
|
+
f"{max_walltime_minutes} allowed by {self.slurm_config.partition} and {self.slurm_config.qos}"
|
|
187
267
|
)
|
|
188
268
|
|
|
189
269
|
def _initialize_parsl(self) -> None:
|
|
@@ -192,19 +272,34 @@ class HPCExecutor:
|
|
|
192
272
|
provider: SlurmProvider | SmartPBSProvider
|
|
193
273
|
if self.scheduler == "slurm":
|
|
194
274
|
provider = SlurmProvider(
|
|
195
|
-
account=self.account,
|
|
196
|
-
partition=self.partition,
|
|
197
|
-
qos=self.qos,
|
|
198
|
-
nodes_per_block=self.req_nodes,
|
|
199
|
-
max_blocks=
|
|
200
|
-
scheduler_options=
|
|
201
|
-
worker_init=
|
|
275
|
+
account=self.slurm_config.account,
|
|
276
|
+
partition=self.slurm_config.partition,
|
|
277
|
+
qos=self.slurm_config.qos,
|
|
278
|
+
nodes_per_block=self.slurm_config.req_nodes,
|
|
279
|
+
max_blocks=self.slurm_config.max_blocks,
|
|
280
|
+
scheduler_options=self.slurm_config.scheduler_options,
|
|
281
|
+
worker_init=self.slurm_config.worker_init,
|
|
202
282
|
launcher=SrunLauncher(
|
|
203
283
|
debug=True,
|
|
204
|
-
overrides=
|
|
284
|
+
overrides=self.slurm_config.overrides,
|
|
205
285
|
),
|
|
206
|
-
walltime=self.walltime,
|
|
207
|
-
cmd_timeout=
|
|
286
|
+
walltime=self.slurm_config.walltime,
|
|
287
|
+
cmd_timeout=self.slurm_config.cmd_timeout,
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
executor = HighThroughputExecutor(
|
|
291
|
+
label="ref_hpc_executor",
|
|
292
|
+
cores_per_worker=self.slurm_config.cores_per_worker,
|
|
293
|
+
mem_per_worker=self.slurm_config.mem_per_worker,
|
|
294
|
+
max_workers_per_node=self.slurm_config.max_workers_per_node,
|
|
295
|
+
cpu_affinity=self.slurm_config.cpu_affinity,
|
|
296
|
+
provider=provider,
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
hpc_config = ParslConfig(
|
|
300
|
+
run_dir=self.slurm_config.log_dir,
|
|
301
|
+
executors=[executor],
|
|
302
|
+
retries=self.slurm_config.retries,
|
|
208
303
|
)
|
|
209
304
|
|
|
210
305
|
elif self.scheduler == "pbs":
|
|
@@ -227,23 +322,24 @@ class HPCExecutor:
|
|
|
227
322
|
walltime=self.walltime,
|
|
228
323
|
cmd_timeout=int(executor_config.get("cmd_timeout", 120)),
|
|
229
324
|
)
|
|
230
|
-
else:
|
|
231
|
-
raise ValueError(f"Unsupported scheduler: {self.scheduler}")
|
|
232
325
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
326
|
+
executor = HighThroughputExecutor(
|
|
327
|
+
label="ref_hpc_executor",
|
|
328
|
+
cores_per_worker=self.cores_per_worker if self.cores_per_worker else 1,
|
|
329
|
+
mem_per_worker=self.mem_per_worker,
|
|
330
|
+
max_workers_per_node=_to_int(executor_config.get("max_workers_per_node", 16)),
|
|
331
|
+
cpu_affinity=str(executor_config.get("cpu_affinity")),
|
|
332
|
+
provider=provider,
|
|
333
|
+
)
|
|
241
334
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
335
|
+
hpc_config = ParslConfig(
|
|
336
|
+
run_dir=self.log_dir,
|
|
337
|
+
executors=[executor],
|
|
338
|
+
retries=int(executor_config.get("retries", 2)),
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
else:
|
|
342
|
+
raise ValueError(f"Unsupported scheduler: {self.scheduler}")
|
|
247
343
|
|
|
248
344
|
parsl.load(hpc_config)
|
|
249
345
|
|
climate_ref/executor/local.py
CHANGED
|
@@ -88,8 +88,7 @@ def _process_run(definition: ExecutionDefinition, log_level: str) -> ExecutionRe
|
|
|
88
88
|
except Exception: # pragma: no cover
|
|
89
89
|
# This isn't expected but if it happens we want to log the error before the process exits
|
|
90
90
|
logger.exception("Error running diagnostic")
|
|
91
|
-
|
|
92
|
-
raise
|
|
91
|
+
return ExecutionResult.build_from_failure(definition)
|
|
93
92
|
|
|
94
93
|
|
|
95
94
|
class LocalExecutor:
|
|
@@ -154,6 +154,8 @@ def _process_execution_series(
|
|
|
154
154
|
"execution_id": execution.id,
|
|
155
155
|
"values": series_result.values,
|
|
156
156
|
"attributes": series_result.attributes,
|
|
157
|
+
"index": series_result.index,
|
|
158
|
+
"index_name": series_result.index_name,
|
|
157
159
|
**series_result.dimensions,
|
|
158
160
|
}
|
|
159
161
|
for series_result in series_values
|
|
@@ -195,12 +197,19 @@ def handle_execution_result(
|
|
|
195
197
|
The result of the diagnostic execution, either successful or failed
|
|
196
198
|
"""
|
|
197
199
|
# Always copy log data to the results directory
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
200
|
+
try:
|
|
201
|
+
_copy_file_to_results(
|
|
202
|
+
config.paths.scratch,
|
|
203
|
+
config.paths.results,
|
|
204
|
+
execution.output_fragment,
|
|
205
|
+
EXECUTION_LOG_FILENAME,
|
|
206
|
+
)
|
|
207
|
+
except FileNotFoundError:
|
|
208
|
+
logger.error(
|
|
209
|
+
f"Could not find log file {EXECUTION_LOG_FILENAME} in scratch directory: {config.paths.scratch}"
|
|
210
|
+
)
|
|
211
|
+
execution.mark_failed()
|
|
212
|
+
return
|
|
204
213
|
|
|
205
214
|
if not result.successful or result.metric_bundle_filename is None:
|
|
206
215
|
logger.error(f"{execution} failed")
|
|
@@ -304,12 +313,13 @@ def _handle_outputs(
|
|
|
304
313
|
filename,
|
|
305
314
|
)
|
|
306
315
|
database.session.add(
|
|
307
|
-
ExecutionOutput(
|
|
316
|
+
ExecutionOutput.build(
|
|
308
317
|
execution_id=execution.id,
|
|
309
318
|
output_type=output_type,
|
|
310
319
|
filename=str(filename),
|
|
311
320
|
description=output_info.description,
|
|
312
321
|
short_name=key,
|
|
313
322
|
long_name=output_info.long_name,
|
|
323
|
+
dimensions=output_info.dimensions or {},
|
|
314
324
|
)
|
|
315
325
|
)
|
climate_ref/migrations/env.py
CHANGED
|
@@ -4,7 +4,10 @@ from sqlalchemy import Connection, inspect
|
|
|
4
4
|
|
|
5
5
|
from climate_ref.config import Config
|
|
6
6
|
from climate_ref.database import Database
|
|
7
|
-
from climate_ref.models import Base
|
|
7
|
+
from climate_ref.models import Base
|
|
8
|
+
from climate_ref.models.execution import ExecutionOutput
|
|
9
|
+
from climate_ref.models.metric_value import MetricValue
|
|
10
|
+
from climate_ref.models.mixins import DimensionMixin
|
|
8
11
|
from climate_ref_core.logging import capture_logging
|
|
9
12
|
from climate_ref_core.pycmec.controlled_vocabulary import CV
|
|
10
13
|
|
|
@@ -33,7 +36,7 @@ target_metadata = Base.metadata
|
|
|
33
36
|
# Custom migration functions that are run on every migration
|
|
34
37
|
|
|
35
38
|
|
|
36
|
-
def
|
|
39
|
+
def _add_dimension_columns(connection: Connection, table: str, Cls: type[DimensionMixin]) -> None:
|
|
37
40
|
"""
|
|
38
41
|
Add any missing columns in the current CV to the database
|
|
39
42
|
|
|
@@ -44,27 +47,25 @@ def _add_metric_value_columns(connection: Connection) -> None:
|
|
|
44
47
|
connection
|
|
45
48
|
Open connection to the database
|
|
46
49
|
"""
|
|
47
|
-
metric_value_table = "metric_value"
|
|
48
|
-
|
|
49
50
|
inspector = inspect(connection)
|
|
50
51
|
|
|
51
52
|
# Check if table already exists
|
|
52
53
|
# Skip if it doesn't
|
|
53
54
|
tables = inspector.get_table_names()
|
|
54
|
-
if
|
|
55
|
-
logger.warning(f"No table named {
|
|
55
|
+
if table not in tables:
|
|
56
|
+
logger.warning(f"No table named {table!r} found")
|
|
56
57
|
return
|
|
57
58
|
|
|
58
59
|
# Extract the current columns in the DB
|
|
59
|
-
existing_columns = [c["name"] for c in inspector.get_columns(
|
|
60
|
+
existing_columns = [c["name"] for c in inspector.get_columns(table)]
|
|
60
61
|
|
|
61
62
|
cv_file = ref_config.paths.dimensions_cv
|
|
62
63
|
cv = CV.load_from_file(cv_file)
|
|
63
64
|
|
|
64
65
|
for dimension in cv.dimensions:
|
|
65
66
|
if dimension.name not in existing_columns:
|
|
66
|
-
logger.info(f"Adding missing
|
|
67
|
-
op.add_column(
|
|
67
|
+
logger.info(f"Adding missing value dimension: {dimension.name!r}")
|
|
68
|
+
op.add_column(table, Cls.build_dimension_column(dimension))
|
|
68
69
|
|
|
69
70
|
|
|
70
71
|
def include_object(object_, name: str, type_, reflected, compare_to) -> bool:
|
|
@@ -134,7 +135,8 @@ def run_migrations_online() -> None:
|
|
|
134
135
|
# Set up the Operations context
|
|
135
136
|
# This is needed to alter the tables
|
|
136
137
|
with op.Operations.context(context.get_context()): # type: ignore
|
|
137
|
-
|
|
138
|
+
_add_dimension_columns(connection, "metric_value", MetricValue)
|
|
139
|
+
_add_dimension_columns(connection, "execution_output", ExecutionOutput)
|
|
138
140
|
|
|
139
141
|
|
|
140
142
|
if context.is_offline_mode():
|
climate_ref/migrations/versions/2025-09-10T1358_2f6e36738e06_use_version_as_version_facet_for_.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""use 'version' as version facet for obs4MIPs
|
|
2
|
+
|
|
3
|
+
Revision ID: 2f6e36738e06
|
|
4
|
+
Revises: 8d28e5e0f9c3
|
|
5
|
+
Create Date: 2025-09-10 13:58:40.660076
|
|
6
|
+
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from collections.abc import Sequence
|
|
10
|
+
from typing import Union
|
|
11
|
+
|
|
12
|
+
import sqlalchemy as sa
|
|
13
|
+
from alembic import op
|
|
14
|
+
|
|
15
|
+
# revision identifiers, used by Alembic.
|
|
16
|
+
revision: str = "2f6e36738e06"
|
|
17
|
+
down_revision: Union[str, None] = "8d28e5e0f9c3"
|
|
18
|
+
branch_labels: Union[str, Sequence[str], None] = None
|
|
19
|
+
depends_on: Union[str, Sequence[str], None] = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def upgrade() -> None:
|
|
23
|
+
# ### commands auto generated by Alembic - please adjust! ###
|
|
24
|
+
with op.batch_alter_table("obs4mips_dataset", schema=None) as batch_op:
|
|
25
|
+
batch_op.add_column(sa.Column("version", sa.String(), nullable=False))
|
|
26
|
+
|
|
27
|
+
# ### end Alembic commands ###
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def downgrade() -> None:
|
|
31
|
+
# ### commands auto generated by Alembic - please adjust! ###
|
|
32
|
+
with op.batch_alter_table("obs4mips_dataset", schema=None) as batch_op:
|
|
33
|
+
batch_op.drop_column("version")
|
|
34
|
+
|
|
35
|
+
# ### end Alembic commands ###
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""add pmp version
|
|
2
|
+
|
|
3
|
+
Revision ID: 20cd136a5b04
|
|
4
|
+
Revises: 2f6e36738e06
|
|
5
|
+
Create Date: 2025-09-22 23:59:42.724007
|
|
6
|
+
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from collections.abc import Sequence
|
|
10
|
+
from typing import Union
|
|
11
|
+
|
|
12
|
+
import sqlalchemy as sa
|
|
13
|
+
from alembic import op
|
|
14
|
+
|
|
15
|
+
# revision identifiers, used by Alembic.
|
|
16
|
+
revision: str = "20cd136a5b04"
|
|
17
|
+
down_revision: Union[str, None] = "2f6e36738e06"
|
|
18
|
+
branch_labels: Union[str, Sequence[str], None] = None
|
|
19
|
+
depends_on: Union[str, Sequence[str], None] = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def upgrade() -> None:
|
|
23
|
+
# ### commands auto generated by Alembic - please adjust! ###
|
|
24
|
+
with op.batch_alter_table("pmp_climatology_dataset", schema=None) as batch_op:
|
|
25
|
+
batch_op.add_column(sa.Column("version", sa.String(), nullable=False))
|
|
26
|
+
|
|
27
|
+
# ### end Alembic commands ###
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def downgrade() -> None:
|
|
31
|
+
# ### commands auto generated by Alembic - please adjust! ###
|
|
32
|
+
with op.batch_alter_table("pmp_climatology_dataset", schema=None) as batch_op:
|
|
33
|
+
batch_op.drop_column("version")
|
|
34
|
+
|
|
35
|
+
# ### end Alembic commands ###
|
climate_ref/models/__init__.py
CHANGED
|
@@ -4,9 +4,7 @@ Declaration of the models used by the REF.
|
|
|
4
4
|
These models are used to represent the data that is stored in the database.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
-
from
|
|
8
|
-
|
|
9
|
-
from climate_ref.models.base import Base
|
|
7
|
+
from climate_ref.models.base import Base, Table
|
|
10
8
|
from climate_ref.models.dataset import Dataset
|
|
11
9
|
from climate_ref.models.diagnostic import Diagnostic
|
|
12
10
|
from climate_ref.models.execution import (
|
|
@@ -17,9 +15,6 @@ from climate_ref.models.execution import (
|
|
|
17
15
|
from climate_ref.models.metric_value import MetricValue, ScalarMetricValue, SeriesMetricValue
|
|
18
16
|
from climate_ref.models.provider import Provider
|
|
19
17
|
|
|
20
|
-
Table = TypeVar("Table", bound=Base)
|
|
21
|
-
|
|
22
|
-
|
|
23
18
|
__all__ = [
|
|
24
19
|
"Base",
|
|
25
20
|
"Dataset",
|
climate_ref/models/base.py
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import
|
|
2
|
-
from typing import Any
|
|
1
|
+
from typing import Any, TypeVar
|
|
3
2
|
|
|
4
|
-
from sqlalchemy import JSON, MetaData
|
|
5
|
-
from sqlalchemy.orm import DeclarativeBase
|
|
3
|
+
from sqlalchemy import JSON, MetaData
|
|
4
|
+
from sqlalchemy.orm import DeclarativeBase
|
|
6
5
|
|
|
7
6
|
|
|
8
7
|
class Base(DeclarativeBase):
|
|
@@ -28,19 +27,4 @@ class Base(DeclarativeBase):
|
|
|
28
27
|
)
|
|
29
28
|
|
|
30
29
|
|
|
31
|
-
|
|
32
|
-
"""
|
|
33
|
-
Mixin for models that have a created_at and updated_at fields
|
|
34
|
-
"""
|
|
35
|
-
|
|
36
|
-
created_at: Mapped[datetime.datetime] = mapped_column(server_default=func.now())
|
|
37
|
-
"""
|
|
38
|
-
When the dataset was added to the database
|
|
39
|
-
"""
|
|
40
|
-
|
|
41
|
-
updated_at: Mapped[datetime.datetime] = mapped_column(
|
|
42
|
-
server_default=func.now(), onupdate=func.now(), index=True
|
|
43
|
-
)
|
|
44
|
-
"""
|
|
45
|
-
When the dataset was updated.
|
|
46
|
-
"""
|
|
30
|
+
Table = TypeVar("Table", bound=Base)
|
climate_ref/models/dataset.py
CHANGED
|
@@ -172,6 +172,7 @@ class Obs4MIPsDataset(Dataset):
|
|
|
172
172
|
units: Mapped[str] = mapped_column()
|
|
173
173
|
variable_id: Mapped[str] = mapped_column()
|
|
174
174
|
variant_label: Mapped[str] = mapped_column()
|
|
175
|
+
version: Mapped[str] = mapped_column()
|
|
175
176
|
vertical_levels: Mapped[int] = mapped_column()
|
|
176
177
|
source_version_number: Mapped[str] = mapped_column()
|
|
177
178
|
|
|
@@ -206,6 +207,7 @@ class PMPClimatologyDataset(Dataset):
|
|
|
206
207
|
units: Mapped[str] = mapped_column()
|
|
207
208
|
variable_id: Mapped[str] = mapped_column()
|
|
208
209
|
variant_label: Mapped[str] = mapped_column()
|
|
210
|
+
version: Mapped[str] = mapped_column()
|
|
209
211
|
vertical_levels: Mapped[int] = mapped_column()
|
|
210
212
|
source_version_number: Mapped[str] = mapped_column()
|
|
211
213
|
|
climate_ref/models/diagnostic.py
CHANGED
|
@@ -3,7 +3,8 @@ from typing import TYPE_CHECKING
|
|
|
3
3
|
from sqlalchemy import ForeignKey, UniqueConstraint
|
|
4
4
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
5
5
|
|
|
6
|
-
from climate_ref.models.base import Base
|
|
6
|
+
from climate_ref.models.base import Base
|
|
7
|
+
from climate_ref.models.mixins import CreatedUpdatedMixin
|
|
7
8
|
|
|
8
9
|
if TYPE_CHECKING:
|
|
9
10
|
from climate_ref.models.execution import ExecutionGroup
|