Skip to content

Commit 27bac07

Browse files
authored
Merge pull request #503 from Climate-REF/cmip7-database-v2
Add CMIP7 database support
2 parents af1cb64 + 7aa967c commit 27bac07

13 files changed

Lines changed: 899 additions & 52 deletions

File tree

changelog/503.feature.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Added database support for CMIP7 datasets based on the CMIP7 Global Attributes v1.0 specification.

packages/climate-ref/src/climate_ref/datasets/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from climate_ref.database import Database, ModelState
1414
from climate_ref.datasets.base import DatasetAdapter
1515
from climate_ref.datasets.cmip6 import CMIP6DatasetAdapter
16+
from climate_ref.datasets.cmip7 import CMIP7DatasetAdapter
1617
from climate_ref.datasets.obs4mips import Obs4MIPsDatasetAdapter
1718
from climate_ref.datasets.pmp_climatology import PMPClimatologyDatasetAdapter
1819
from climate_ref_core.datasets import SourceDatasetType
@@ -142,6 +143,8 @@ def get_dataset_adapter(source_type: str, **kwargs: Any) -> DatasetAdapter:
142143
"""
143144
if source_type.lower() == SourceDatasetType.CMIP6.value:
144145
return CMIP6DatasetAdapter(**kwargs)
146+
elif source_type.lower() == SourceDatasetType.CMIP7.value:
147+
return CMIP7DatasetAdapter(**kwargs)
145148
elif source_type.lower() == SourceDatasetType.obs4MIPs.value.lower():
146149
return Obs4MIPsDatasetAdapter(**kwargs)
147150
elif source_type.lower() == SourceDatasetType.PMPClimatology.value.lower():
@@ -152,6 +155,7 @@ def get_dataset_adapter(source_type: str, **kwargs: Any) -> DatasetAdapter:
152155

153156
__all__ = [
154157
"CMIP6DatasetAdapter",
158+
"CMIP7DatasetAdapter",
155159
"DatasetAdapter",
156160
"IngestionStats",
157161
"Obs4MIPsDatasetAdapter",

packages/climate-ref/src/climate_ref/datasets/cmip6.py

Lines changed: 5 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
11
from __future__ import annotations
22

33
import warnings
4-
from datetime import datetime
54
from pathlib import Path
6-
from typing import Any
75

86
import pandas as pd
97
from ecgtools import Builder
@@ -12,36 +10,10 @@
1210
from climate_ref.config import Config
1311
from climate_ref.datasets.base import DatasetAdapter, DatasetParsingFunction
1412
from climate_ref.datasets.cmip6_parsers import parse_cmip6_complete, parse_cmip6_drs
13+
from climate_ref.datasets.utils import clean_branch_time, parse_datetime
1514
from climate_ref.models.dataset import CMIP6Dataset
1615

1716

18-
def _parse_datetime(dt_str: pd.Series[str]) -> pd.Series[datetime | Any]:
19-
"""
20-
Pandas tries to coerce everything to their own datetime format, which is not what we want here.
21-
"""
22-
23-
def _inner(date_string: str | None) -> datetime | None:
24-
if not date_string or pd.isnull(date_string):
25-
return None
26-
27-
# Try to parse the date string with and without milliseconds
28-
for fmt in ("%Y-%m-%d", "%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M:%S.%f"):
29-
try:
30-
return datetime.strptime(date_string, fmt)
31-
except ValueError:
32-
continue
33-
34-
# If all parsing attempts fail, log an error and return None
35-
logger.error(f"Failed to parse date string: {date_string}")
36-
return None
37-
38-
return pd.Series(
39-
[_inner(dt) for dt in dt_str],
40-
index=dt_str.index,
41-
dtype="object",
42-
)
43-
44-
4517
def _apply_fixes(data_catalog: pd.DataFrame) -> pd.DataFrame:
4618
def _fix_parent_variant_label(group: pd.DataFrame) -> pd.DataFrame:
4719
if group["parent_variant_label"].nunique() == 1:
@@ -58,19 +30,13 @@ def _fix_parent_variant_label(group: pd.DataFrame) -> pd.DataFrame:
5830
)
5931

6032
if "branch_time_in_child" in data_catalog:
61-
data_catalog["branch_time_in_child"] = _clean_branch_time(data_catalog["branch_time_in_child"])
33+
data_catalog["branch_time_in_child"] = clean_branch_time(data_catalog["branch_time_in_child"])
6234
if "branch_time_in_parent" in data_catalog:
63-
data_catalog["branch_time_in_parent"] = _clean_branch_time(data_catalog["branch_time_in_parent"])
35+
data_catalog["branch_time_in_parent"] = clean_branch_time(data_catalog["branch_time_in_parent"])
6436

6537
return data_catalog
6638

6739

68-
def _clean_branch_time(branch_time: pd.Series[str]) -> pd.Series[float]:
69-
# EC-Earth3 uses "D" as a suffix for the branch_time_in_child and branch_time_in_parent columns
70-
# Handle missing values (these result in nan values)
71-
return pd.to_numeric(branch_time.astype(str).str.replace("D", ""), errors="coerce")
72-
73-
7440
class CMIP6DatasetAdapter(DatasetAdapter):
7541
"""
7642
Adapter for CMIP6 datasets
@@ -191,8 +157,8 @@ def find_local_datasets(self, file_or_directory: Path) -> pd.DataFrame:
191157

192158
# Convert the start_time and end_time columns to datetime objects
193159
# We don't know the calendar used in the dataset (TODO: Check what ecgtools does)
194-
datasets["start_time"] = _parse_datetime(datasets["start_time"])
195-
datasets["end_time"] = _parse_datetime(datasets["end_time"])
160+
datasets["start_time"] = parse_datetime(datasets["start_time"])
161+
datasets["end_time"] = parse_datetime(datasets["end_time"])
196162

197163
drs_items = [
198164
*self.dataset_id_metadata,
Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
"""
2+
CMIP7 Dataset Adapter
3+
4+
Adapter for parsing and registering CMIP7 datasets based on CMIP7 Global Attributes v1.0.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import traceback
10+
import warnings
11+
from pathlib import Path
12+
from typing import Any
13+
14+
import pandas as pd
15+
import xarray as xr
16+
from ecgtools import Builder
17+
18+
from climate_ref.config import Config
19+
from climate_ref.datasets.base import DatasetAdapter
20+
from climate_ref.datasets.utils import clean_branch_time, parse_datetime
21+
from climate_ref.models.dataset import CMIP7Dataset
22+
23+
24+
def parse_cmip7_file(file: str, **kwargs: Any) -> dict[str, Any]:
25+
"""
26+
Parse metadata from a CMIP7 netCDF file.
27+
28+
Parameters
29+
----------
30+
file
31+
Path to the CMIP7 netCDF file
32+
33+
Returns
34+
-------
35+
:
36+
Dictionary of metadata extracted from the file
37+
"""
38+
try:
39+
with xr.open_dataset(file, use_cftime=True) as ds:
40+
attrs = ds.attrs
41+
42+
# Extract time bounds if available
43+
start_time = None
44+
end_time = None
45+
if "time" in ds:
46+
time = ds["time"]
47+
if len(time) > 0:
48+
start_time = str(time.values[0])
49+
end_time = str(time.values[-1])
50+
51+
# Get variable metadata from the data variable
52+
variable_id = attrs.get("variable_id", "")
53+
standard_name = None
54+
long_name = None
55+
units = None
56+
if variable_id and variable_id in ds:
57+
var = ds[variable_id]
58+
standard_name = var.attrs.get("standard_name")
59+
long_name = var.attrs.get("long_name")
60+
units = var.attrs.get("units")
61+
62+
return {
63+
# Core DRS attributes
64+
"activity_id": attrs.get("activity_id", ""),
65+
"institution_id": attrs.get("institution_id", ""),
66+
"source_id": attrs.get("source_id", ""),
67+
"experiment_id": attrs.get("experiment_id", ""),
68+
"variant_label": attrs.get("variant_label", ""),
69+
"variable_id": variable_id,
70+
"grid_label": attrs.get("grid_label", ""),
71+
"frequency": attrs.get("frequency", ""),
72+
"region": attrs.get("region", "glb"),
73+
"branding_suffix": attrs.get("branding_suffix", ""),
74+
"version": attrs.get("version", ""),
75+
# Additional mandatory attributes
76+
"mip_era": attrs.get("mip_era", "CMIP7"),
77+
"realm": attrs.get("realm"),
78+
"nominal_resolution": attrs.get("nominal_resolution"),
79+
# Parent info (nullable)
80+
"branch_time_in_child": attrs.get("branch_time_in_child"),
81+
"branch_time_in_parent": attrs.get("branch_time_in_parent"),
82+
"parent_activity_id": attrs.get("parent_activity_id"),
83+
"parent_experiment_id": attrs.get("parent_experiment_id"),
84+
"parent_mip_era": attrs.get("parent_mip_era"),
85+
"parent_source_id": attrs.get("parent_source_id"),
86+
"parent_time_units": attrs.get("parent_time_units"),
87+
"parent_variant_label": attrs.get("parent_variant_label"),
88+
# Additional mandatory attributes
89+
"license_id": attrs.get("license_id"),
90+
# Conditionally required attributes
91+
"external_variables": attrs.get("external_variables"),
92+
# Variable metadata
93+
"standard_name": standard_name,
94+
"long_name": long_name,
95+
"units": units,
96+
# File-level metadata
97+
"tracking_id": attrs.get("tracking_id"),
98+
# Time information
99+
"start_time": start_time,
100+
"end_time": end_time,
101+
"time_range": f"{start_time}-{end_time}" if start_time and end_time else None,
102+
# Path
103+
"path": file,
104+
}
105+
except Exception:
106+
return {
107+
"INVALID_ASSET": file,
108+
"TRACEBACK": traceback.format_exc(),
109+
}
110+
111+
112+
class CMIP7DatasetAdapter(DatasetAdapter):
113+
"""
114+
Adapter for CMIP7 datasets
115+
116+
Based on CMIP7 Global Attributes v1.0 (DOI: 10.5281/zenodo.17250297).
117+
"""
118+
119+
dataset_cls = CMIP7Dataset
120+
slug_column = "instance_id"
121+
122+
dataset_specific_metadata = (
123+
# Core DRS attributes
124+
"activity_id",
125+
"institution_id",
126+
"source_id",
127+
"experiment_id",
128+
"variant_label",
129+
"variable_id",
130+
"grid_label",
131+
"frequency",
132+
"region",
133+
"branding_suffix",
134+
"version",
135+
# Additional mandatory attributes
136+
"mip_era",
137+
"realm",
138+
"nominal_resolution",
139+
# Additional mandatory attributes
140+
"license_id",
141+
# Conditionally required attributes
142+
"external_variables",
143+
# Parent info
144+
"branch_time_in_child",
145+
"branch_time_in_parent",
146+
"parent_activity_id",
147+
"parent_experiment_id",
148+
"parent_mip_era",
149+
"parent_source_id",
150+
"parent_time_units",
151+
"parent_variant_label",
152+
# Variable metadata
153+
"standard_name",
154+
"long_name",
155+
"units",
156+
# Unique identifier
157+
slug_column,
158+
)
159+
160+
file_specific_metadata = ("start_time", "end_time", "path", "tracking_id")
161+
162+
version_metadata = "version"
163+
164+
# CMIP7 DRS directory structure (MIP-DRS7 spec):
165+
# <drs_specs>/<mip_era>/<activity_id>/<institution_id>/.../<grid_label>/<version>
166+
# The leading drs_specs and mip_era are fixed values ("MIP-DRS7" and "CMIP7")
167+
# and are omitted here. They are added as the "CMIP7." prefix when building instance_id.
168+
dataset_id_metadata = (
169+
"activity_id",
170+
"institution_id",
171+
"source_id",
172+
"experiment_id",
173+
"variant_label",
174+
"region",
175+
"frequency",
176+
"variable_id",
177+
"branding_suffix",
178+
"grid_label",
179+
)
180+
181+
def __init__(self, n_jobs: int = 1, config: Config | None = None):
182+
self.n_jobs = n_jobs
183+
self.config = config or Config.default()
184+
185+
def find_local_datasets(self, file_or_directory: Path) -> pd.DataFrame:
186+
"""
187+
Generate a data catalog from the specified file or directory.
188+
189+
Each dataset may contain multiple files, which are represented as rows in the data catalog.
190+
Each dataset has a unique identifier, which is in `slug_column`.
191+
192+
Parameters
193+
----------
194+
file_or_directory
195+
File or directory containing the datasets
196+
197+
Returns
198+
-------
199+
:
200+
Data catalog containing the metadata for the dataset
201+
"""
202+
with warnings.catch_warnings():
203+
# Ignore the DeprecationWarning from xarray
204+
warnings.simplefilter("ignore", DeprecationWarning)
205+
206+
builder = Builder(
207+
paths=[str(file_or_directory)],
208+
depth=10,
209+
include_patterns=["*.nc"],
210+
joblib_parallel_kwargs={"n_jobs": self.n_jobs},
211+
).build(parsing_func=parse_cmip7_file)
212+
213+
datasets: pd.DataFrame = builder.df
214+
215+
# Convert the start_time and end_time columns to datetime objects
216+
if "start_time" in datasets.columns:
217+
datasets["start_time"] = parse_datetime(datasets["start_time"])
218+
if "end_time" in datasets.columns:
219+
datasets["end_time"] = parse_datetime(datasets["end_time"])
220+
221+
# Clean branch times
222+
if "branch_time_in_child" in datasets.columns:
223+
datasets["branch_time_in_child"] = clean_branch_time(datasets["branch_time_in_child"])
224+
if "branch_time_in_parent" in datasets.columns:
225+
datasets["branch_time_in_parent"] = clean_branch_time(datasets["branch_time_in_parent"])
226+
227+
# Build instance_id following CMIP7 DRS format
228+
# CMIP7.<activity_id>.<institution_id>.<source_id>.<experiment_id>.<variant_label>.
229+
# <region>.<frequency>.<variable_id>.<branding_suffix>.<grid_label>.<version>
230+
drs_items = [
231+
*self.dataset_id_metadata,
232+
self.version_metadata,
233+
]
234+
datasets["instance_id"] = datasets.apply(
235+
lambda row: "CMIP7." + ".".join([str(row[item]) for item in drs_items]), axis=1
236+
)
237+
238+
# Add in any missing metadata columns
239+
missing_columns = set(self.dataset_specific_metadata + self.file_specific_metadata) - set(
240+
datasets.columns
241+
)
242+
if missing_columns:
243+
for column in missing_columns:
244+
datasets[column] = pd.NA
245+
246+
return datasets

packages/climate-ref/src/climate_ref/datasets/obs4mips.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from loguru import logger
1111

1212
from climate_ref.datasets.base import DatasetAdapter
13-
from climate_ref.datasets.cmip6 import _parse_datetime
13+
from climate_ref.datasets.utils import parse_datetime
1414
from climate_ref.models.dataset import Dataset, Obs4MIPsDataset
1515

1616

@@ -182,8 +182,8 @@ def find_local_datasets(self, file_or_directory: Path) -> pd.DataFrame:
182182

183183
# Convert the start_time and end_time columns to datetime objects
184184
# We don't know the calendar used in the dataset (TODO: Check what ecgtools does)
185-
datasets["start_time"] = _parse_datetime(datasets["start_time"])
186-
datasets["end_time"] = _parse_datetime(datasets["end_time"])
185+
datasets["start_time"] = parse_datetime(datasets["start_time"])
186+
datasets["end_time"] = parse_datetime(datasets["end_time"])
187187

188188
drs_items = [
189189
*self.dataset_id_metadata,

0 commit comments

Comments
 (0)