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.
Files changed (38) hide show
  1. climate_ref/cli/__init__.py +12 -3
  2. climate_ref/cli/_utils.py +56 -2
  3. climate_ref/cli/datasets.py +49 -12
  4. climate_ref/cli/executions.py +333 -24
  5. climate_ref/cli/providers.py +1 -2
  6. climate_ref/config.py +67 -4
  7. climate_ref/database.py +62 -4
  8. climate_ref/dataset_registry/obs4ref_reference.txt +0 -9
  9. climate_ref/dataset_registry/sample_data.txt +10 -19
  10. climate_ref/datasets/__init__.py +3 -3
  11. climate_ref/datasets/base.py +121 -20
  12. climate_ref/datasets/cmip6.py +2 -0
  13. climate_ref/datasets/obs4mips.py +26 -15
  14. climate_ref/executor/hpc.py +149 -53
  15. climate_ref/executor/local.py +1 -2
  16. climate_ref/executor/result_handling.py +17 -7
  17. climate_ref/migrations/env.py +12 -10
  18. climate_ref/migrations/versions/2025-09-10T1358_2f6e36738e06_use_version_as_version_facet_for_.py +35 -0
  19. climate_ref/migrations/versions/2025-09-22T2359_20cd136a5b04_add_pmp_version.py +35 -0
  20. climate_ref/models/__init__.py +1 -6
  21. climate_ref/models/base.py +4 -20
  22. climate_ref/models/dataset.py +2 -0
  23. climate_ref/models/diagnostic.py +2 -1
  24. climate_ref/models/execution.py +219 -7
  25. climate_ref/models/metric_value.py +25 -110
  26. climate_ref/models/mixins.py +144 -0
  27. climate_ref/models/provider.py +2 -1
  28. climate_ref/provider_registry.py +4 -4
  29. climate_ref/slurm.py +2 -2
  30. climate_ref/solver.py +17 -6
  31. climate_ref/testing.py +1 -1
  32. {climate_ref-0.6.6.dist-info → climate_ref-0.8.0.dist-info}/METADATA +1 -1
  33. climate_ref-0.8.0.dist-info/RECORD +58 -0
  34. {climate_ref-0.6.6.dist-info → climate_ref-0.8.0.dist-info}/WHEEL +1 -1
  35. climate_ref-0.6.6.dist-info/RECORD +0 -55
  36. {climate_ref-0.6.6.dist-info → climate_ref-0.8.0.dist-info}/entry_points.txt +0 -0
  37. {climate_ref-0.6.6.dist-info → climate_ref-0.8.0.dist-info}/licenses/LICENCE +0 -0
  38. {climate_ref-0.6.6.dist-info → climate_ref-0.8.0.dist-info}/licenses/NOTICE +0 -0
@@ -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
- hours, minutes, seconds = map(int, self.walltime.split(":"))
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(self.account, self.partition):
145
- raise ValueError(f"Account: {self.account} cannot access partiton: {self.partition}")
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(f"Account: {self.account} cannot access qos: {self.qos}")
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=int(executor_config.get("max_blocks", 1)),
200
- scheduler_options=executor_config.get("scheduler_options", "#SBATCH -C cpu"),
201
- worker_init=executor_config.get("worker_init", "source .venv/bin/activate"),
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=executor_config.get("overrides", ""),
284
+ overrides=self.slurm_config.overrides,
205
285
  ),
206
- walltime=self.walltime,
207
- cmd_timeout=int(executor_config.get("cmd_timeout", 120)),
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
- executor = HighThroughputExecutor(
234
- label="ref_hpc_executor",
235
- cores_per_worker=self.cores_per_worker if self.cores_per_worker else 1,
236
- mem_per_worker=self.mem_per_worker,
237
- max_workers_per_node=_to_int(executor_config.get("max_workers_per_node", 16)),
238
- cpu_affinity=str(executor_config.get("cpu_affinity")),
239
- provider=provider,
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
- hpc_config = ParslConfig(
243
- run_dir=self.log_dir,
244
- executors=[executor],
245
- retries=int(executor_config.get("retries", 2)),
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
 
@@ -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
- # This will kill the process pool
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
- _copy_file_to_results(
199
- config.paths.scratch,
200
- config.paths.results,
201
- execution.output_fragment,
202
- EXECUTION_LOG_FILENAME,
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
  )
@@ -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, MetricValue
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 _add_metric_value_columns(connection: Connection) -> None:
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 metric_value_table not in tables:
55
- logger.warning(f"No table named {metric_value_table!r} found")
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(metric_value_table)]
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 metric value dimension: {dimension.name!r}")
67
- op.add_column(metric_value_table, MetricValue.build_dimension_column(dimension))
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
- _add_metric_value_columns(connection)
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():
@@ -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 ###
@@ -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 typing import TypeVar
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",
@@ -1,8 +1,7 @@
1
- import datetime
2
- from typing import Any
1
+ from typing import Any, TypeVar
3
2
 
4
- from sqlalchemy import JSON, MetaData, func
5
- from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
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
- class CreatedUpdatedMixin:
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)
@@ -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
 
@@ -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, CreatedUpdatedMixin
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