climate-ref-core 0.18.0__tar.gz → 0.18.2__tar.gz
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-0.18.0 → climate_ref_core-0.18.2}/PKG-INFO +3 -2
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/pyproject.toml +3 -2
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/esgf/cmip7.py +122 -50
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/providers.py +14 -3
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/testing.py +93 -26
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/esgf/test_cmip7.py +279 -2
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/test_providers.py +27 -1
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/test_testing.py +100 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/.gitignore +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/LICENCE +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/NOTICE +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/README.md +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/__init__.py +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/cmip6_to_cmip7.py +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/cmor.py +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/constraints.py +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/data/cmip6_cmip7_variable_map.json +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/data.py +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/dataset_registry.py +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/datasets.py +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/diagnostics.py +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/env.py +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/esgf/__init__.py +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/esgf/base.py +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/esgf/cmip6.py +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/esgf/fetcher.py +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/esgf/obs4mips.py +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/esgf/registry.py +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/esmvaltool_reference.py +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/exceptions.py +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/executor.py +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/logging.py +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/metric_values/__init__.py +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/metric_values/typing.py +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/output_files.py +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/paths.py +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/py.typed +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/pycmec/README.md +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/pycmec/__init__.py +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/pycmec/controlled_vocabulary.py +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/pycmec/cv_cmip7_aft.yaml +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/pycmec/metric.py +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/pycmec/output.py +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/reference_data.py +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/regression/__init__.py +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/regression/_quantise.py +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/regression/capture.py +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/regression/compare.py +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/regression/gate.py +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/regression/manifest.py +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/regression/report_store.py +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/regression/store.py +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/resources.py +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/serialisation.py +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/source_types.py +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/summary.py +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/integration/test_resource_measurement.py +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/esgf/test_base.py +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/esgf/test_cmip6.py +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/esgf/test_fetcher.py +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/esgf/test_obs4mips.py +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/esgf/test_registry.py +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/metric_values/test_typing.py +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/pycmec/cmec_testdata/cmec_metric_sample.json +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/pycmec/cmec_testdata/cmec_output_sample.json +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/pycmec/cmec_testdata/cv_sample.yaml +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/pycmec/cmec_testdata/test_metric_json_schema.yml +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/pycmec/cmec_testdata/test_output_json_schema.yml +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/pycmec/conftest.py +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/pycmec/test_cmec_metric.py +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/pycmec/test_cmec_output.py +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/pycmec/test_controlled_vocabulary.py +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/regression/test_capture.py +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/regression/test_compare.py +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/regression/test_gate.py +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/regression/test_manifest.py +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/regression/test_quantise.py +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/regression/test_report_store.py +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/regression/test_store.py +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/test_cmip6_to_cmip7.py +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/test_cmor.py +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/test_constraints.py +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/test_data.py +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/test_dataset_registry/test_dataset_registry.py +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/test_datasets/dataset_collection_hash.yml +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/test_datasets/dataset_collection_obs4mips_hash.yml +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/test_datasets/execution_dataset_hash.yml +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/test_datasets.py +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/test_diagnostics.py +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/test_esmvaltool_reference.py +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/test_exceptions.py +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/test_executor.py +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/test_logging.py +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/test_output_files.py +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/test_packaging.py +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/test_paths.py +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/test_reference_data.py +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/test_resources.py +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/test_serialisation.py +0 -0
- {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/test_summary.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: climate-ref-core
|
|
3
|
-
Version: 0.18.
|
|
3
|
+
Version: 0.18.2
|
|
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-Expression: Apache-2.0
|
|
@@ -19,6 +19,7 @@ Classifier: Topic :: Scientific/Engineering
|
|
|
19
19
|
Requires-Python: >=3.12
|
|
20
20
|
Requires-Dist: attrs>=23.2.0
|
|
21
21
|
Requires-Dist: cattrs>=24.1
|
|
22
|
+
Requires-Dist: dask[array]>=2024.10.0
|
|
22
23
|
Requires-Dist: environs>=11
|
|
23
24
|
Requires-Dist: fastprogress==1.0.5
|
|
24
25
|
Requires-Dist: intake-esgf>=2025.7.16
|
|
@@ -32,7 +33,7 @@ Requires-Dist: pydantic>=2.10.6
|
|
|
32
33
|
Requires-Dist: pyyaml>=6.0.2
|
|
33
34
|
Requires-Dist: requests
|
|
34
35
|
Requires-Dist: rich
|
|
35
|
-
Requires-Dist: setuptools<
|
|
36
|
+
Requires-Dist: setuptools<85
|
|
36
37
|
Requires-Dist: typing-extensions
|
|
37
38
|
Provides-Extra: aws
|
|
38
39
|
Requires-Dist: boto3>=1.34; extra == 'aws'
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "climate-ref-core"
|
|
3
|
-
version = "0.18.
|
|
3
|
+
version = "0.18.2"
|
|
4
4
|
description = "Core library for the CMIP Rapid Evaluation Framework"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
authors = [
|
|
@@ -39,11 +39,12 @@ dependencies = [
|
|
|
39
39
|
"pyyaml>=6.0.2",
|
|
40
40
|
"psutil>=5.9.8",
|
|
41
41
|
# Not used directly, but required to support some installations
|
|
42
|
-
"setuptools<
|
|
42
|
+
"setuptools<85",
|
|
43
43
|
|
|
44
44
|
# ESGF data fetching
|
|
45
45
|
"intake-esgf>=2025.7.16",
|
|
46
46
|
"pyarrow>=17.0.0",
|
|
47
|
+
"dask[array]>=2024.10.0",
|
|
47
48
|
|
|
48
49
|
# SPEC 0000 constraints
|
|
49
50
|
# We follow [SPEC-0000](https://scientific-python.org/specs/spec-0000/)
|
|
@@ -8,10 +8,12 @@ a request class that fetches CMIP6 data and converts it to CMIP7 format.
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
10
|
import re
|
|
11
|
+
import uuid
|
|
11
12
|
from pathlib import Path
|
|
12
13
|
from typing import Any, ClassVar, NamedTuple
|
|
13
14
|
|
|
14
15
|
import cftime
|
|
16
|
+
import dask
|
|
15
17
|
import pandas as pd
|
|
16
18
|
import xarray as xr
|
|
17
19
|
from loguru import logger
|
|
@@ -30,6 +32,66 @@ from climate_ref_core.cmip6_to_cmip7 import (
|
|
|
30
32
|
from climate_ref_core.data import resolve_cache_dir
|
|
31
33
|
from climate_ref_core.esgf.cmip6 import CMIP6Request
|
|
32
34
|
|
|
35
|
+
_MANDATORY_CONVERTED_ATTRS = (
|
|
36
|
+
"activity_id",
|
|
37
|
+
"institution_id",
|
|
38
|
+
"source_id",
|
|
39
|
+
"experiment_id",
|
|
40
|
+
"variant_label",
|
|
41
|
+
"variable_id",
|
|
42
|
+
"grid_label",
|
|
43
|
+
"frequency",
|
|
44
|
+
"region",
|
|
45
|
+
"branding_suffix",
|
|
46
|
+
"branded_variable",
|
|
47
|
+
"mip_era",
|
|
48
|
+
"realm",
|
|
49
|
+
"nominal_resolution",
|
|
50
|
+
"license_id",
|
|
51
|
+
"tracking_id",
|
|
52
|
+
)
|
|
53
|
+
_LOSSY_DECIMAL_DIGITS = 3
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _invalid_conversion_reason(path: Path) -> str | None:
|
|
57
|
+
"""Return why a cached conversion cannot satisfy the complete CMIP7 parser."""
|
|
58
|
+
try:
|
|
59
|
+
with xr.open_dataset(path, decode_times=False) as ds:
|
|
60
|
+
missing = [name for name in _MANDATORY_CONVERTED_ATTRS if not ds.attrs.get(name)]
|
|
61
|
+
if missing:
|
|
62
|
+
return f"missing mandatory attributes: {', '.join(missing)}"
|
|
63
|
+
variable_id = str(ds.attrs["variable_id"])
|
|
64
|
+
if variable_id not in ds.variables:
|
|
65
|
+
return f"missing data variable: {variable_id}"
|
|
66
|
+
if variable_id == "o3":
|
|
67
|
+
quantised = [
|
|
68
|
+
str(name)
|
|
69
|
+
for name in ds.data_vars
|
|
70
|
+
if ds[name].encoding.get("least_significant_digit") == _LOSSY_DECIMAL_DIGITS
|
|
71
|
+
]
|
|
72
|
+
if quantised:
|
|
73
|
+
return f"ozone dataset uses destructive decimal quantisation: {', '.join(quantised)}"
|
|
74
|
+
if "time" in ds.variables and ds.sizes.get("time", 0):
|
|
75
|
+
time = ds["time"]
|
|
76
|
+
if not time.attrs.get("units"):
|
|
77
|
+
return "time coordinate has no units"
|
|
78
|
+
# Reading both endpoints catches a truncated file.
|
|
79
|
+
time.isel(time=[0, -1]).load()
|
|
80
|
+
except (OSError, ValueError) as e:
|
|
81
|
+
return f"cannot read metadata: {e}"
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _load_time_coordinates(ds: xr.Dataset) -> None:
|
|
86
|
+
"""Materialise the small CF time variables before a Dask-backed NetCDF write."""
|
|
87
|
+
time_variables = {"time"}
|
|
88
|
+
if "time" in ds:
|
|
89
|
+
bounds_name = ds["time"].attrs.get("bounds")
|
|
90
|
+
if bounds_name:
|
|
91
|
+
time_variables.add(str(bounds_name))
|
|
92
|
+
for name in time_variables.intersection(ds.variables):
|
|
93
|
+
ds[name].load()
|
|
94
|
+
|
|
33
95
|
|
|
34
96
|
def _get_cmip7_cache_dir() -> Path:
|
|
35
97
|
"""Get the cache directory for converted CMIP7 files."""
|
|
@@ -146,57 +208,67 @@ def _convert_file_to_cmip7(
|
|
|
146
208
|
logger.info(f"Converting to CMIP7: {cmip6_path.name}")
|
|
147
209
|
|
|
148
210
|
time_coder = xr.coders.CFDatetimeCoder(use_cftime=True)
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
return output_file
|
|
164
|
-
|
|
165
|
-
ds_cmip7 = convert_cmip6_dataset(source_ds)
|
|
166
|
-
|
|
167
|
-
# Ensure version and sanitized activity_id are in the file attributes
|
|
168
|
-
# so that parse_cmip7_file can extract them for instance_id construction
|
|
169
|
-
ds_cmip7.attrs["version"] = version
|
|
170
|
-
ds_cmip7.attrs["activity_id"] = activity_id
|
|
171
|
-
|
|
172
|
-
try:
|
|
173
|
-
logger.info(f"Writing translated CMIP7 file: {output_file}")
|
|
174
|
-
suppress_bounds_coordinates(ds_cmip7)
|
|
175
|
-
|
|
176
|
-
# Apply lossy compression - these are converted files used for
|
|
177
|
-
# verification only, so precision loss is acceptable.
|
|
178
|
-
# gpp magnitudes (~1e-8 kg m-2 s-1) sit far below the fixed
|
|
179
|
-
# least_significant_digit=3 (~1e-3) precision floor, which would round the entire field to zero.
|
|
180
|
-
# Keep gpp lossless; other variables tolerate it.
|
|
181
|
-
encoding: dict[str, dict[str, Any]] = {}
|
|
182
|
-
for var in ds_cmip7.data_vars:
|
|
183
|
-
var_encoding: dict[str, Any] = {"zlib": True, "complevel": 5}
|
|
184
|
-
|
|
185
|
-
lossless_variables = {"gpp"}
|
|
186
|
-
if str(var) not in lossless_variables:
|
|
187
|
-
var_encoding["least_significant_digit"] = 3
|
|
188
|
-
encoding[str(var)] = var_encoding
|
|
189
|
-
|
|
190
|
-
ds_cmip7.to_netcdf(output_file, encoding=encoding)
|
|
191
|
-
except PermissionError:
|
|
192
|
-
# If we can't write but file exists (race condition or permission issue), use it
|
|
211
|
+
# Some CMIP fields are several GiB, so bound chunk size and write concurrency.
|
|
212
|
+
with dask.config.set({"array.chunk-size": "64MiB", "scheduler": "single-threaded"}):
|
|
213
|
+
with xr.open_dataset(cmip6_path, decode_times=time_coder, chunks="auto") as ds:
|
|
214
|
+
# When fabricating extended historical coverage, pad the series first so both the
|
|
215
|
+
# filename time range and the written data reflect the added months.
|
|
216
|
+
source_ds = ds
|
|
217
|
+
if extend_historical_to is not None:
|
|
218
|
+
end_year, end_month = extend_historical_to
|
|
219
|
+
source_ds = repeat_final_year_to(ds, end_year=end_year, end_month=end_month)
|
|
220
|
+
|
|
221
|
+
frequency = str(cmip7_facets.get("frequency", "mon"))
|
|
222
|
+
time_range = format_cmip7_time_range(source_ds, frequency)
|
|
223
|
+
output_file = drs_path / create_cmip7_filename(cmip7_facets, time_range=time_range)
|
|
224
|
+
|
|
193
225
|
if output_file.exists():
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
226
|
+
invalid_reason = _invalid_conversion_reason(output_file)
|
|
227
|
+
if invalid_reason is None:
|
|
228
|
+
logger.debug(f"Using cached CMIP7 file: {output_file}")
|
|
229
|
+
return output_file
|
|
230
|
+
logger.info(f"Rebuilding invalid CMIP7 conversion ({invalid_reason}): {output_file}")
|
|
231
|
+
|
|
232
|
+
ds_cmip7 = convert_cmip6_dataset(source_ds)
|
|
233
|
+
|
|
234
|
+
# Ensure version and sanitized activity_id are in the file attributes
|
|
235
|
+
# so that parse_cmip7_file can extract them for instance_id construction
|
|
236
|
+
ds_cmip7.attrs["version"] = version
|
|
237
|
+
ds_cmip7.attrs["activity_id"] = activity_id
|
|
238
|
+
|
|
239
|
+
temporary_file = output_file.with_name(
|
|
240
|
+
f".{output_file.stem}.{uuid.uuid4().hex}.tmp{output_file.suffix}"
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
try:
|
|
244
|
+
logger.info(f"Writing translated CMIP7 file: {output_file}")
|
|
245
|
+
# Xarray cannot CF-encode a Dask-backed cftime bounds array.
|
|
246
|
+
_load_time_coordinates(ds_cmip7)
|
|
247
|
+
suppress_bounds_coordinates(ds_cmip7)
|
|
248
|
+
|
|
249
|
+
# gpp and o3 magnitudes sit below the quantisation floor.
|
|
250
|
+
# Ozone also needs lossless hybrid-coordinate coefficients.
|
|
251
|
+
lossless_dataset = ds_cmip7.attrs.get("variable_id") == "o3"
|
|
252
|
+
encoding: dict[str, dict[str, Any]] = {}
|
|
253
|
+
for var in ds_cmip7.data_vars:
|
|
254
|
+
var_encoding: dict[str, Any] = {"zlib": True, "complevel": 5}
|
|
255
|
+
if not lossless_dataset and str(var) != "gpp":
|
|
256
|
+
var_encoding["least_significant_digit"] = _LOSSY_DECIMAL_DIGITS
|
|
257
|
+
encoding[str(var)] = var_encoding
|
|
258
|
+
|
|
259
|
+
ds_cmip7.to_netcdf(temporary_file, encoding=encoding)
|
|
260
|
+
temporary_file.replace(output_file)
|
|
261
|
+
except PermissionError:
|
|
262
|
+
# If we can't write but file exists (race condition or permission issue), use it
|
|
263
|
+
if output_file.exists() and _invalid_conversion_reason(output_file) is None:
|
|
264
|
+
logger.debug(f"Using existing CMIP7 file (could not overwrite): {output_file}")
|
|
265
|
+
return output_file
|
|
266
|
+
# Clear the cache directory hint for the user
|
|
267
|
+
logger.error(f"Permission denied writing to {output_file}")
|
|
268
|
+
logger.error(f"Try clearing the cache: rm -rf {_get_cmip7_cache_dir()}")
|
|
269
|
+
raise
|
|
270
|
+
finally:
|
|
271
|
+
temporary_file.unlink(missing_ok=True)
|
|
200
272
|
|
|
201
273
|
return output_file
|
|
202
274
|
|
|
@@ -534,7 +534,7 @@ class CondaDiagnosticProvider(CommandLineDiagnosticProvider):
|
|
|
534
534
|
Environment variables overriding the process environment
|
|
535
535
|
for commands run in the conda environment.
|
|
536
536
|
pip_packages
|
|
537
|
-
Pip packages to install (as URLs) with ``--no-deps``
|
|
537
|
+
Pip packages to install (as URLs) with ``--no-deps --force-reinstall``
|
|
538
538
|
after creating the conda environment.
|
|
539
539
|
|
|
540
540
|
"""
|
|
@@ -567,7 +567,7 @@ class CondaDiagnosticProvider(CommandLineDiagnosticProvider):
|
|
|
567
567
|
def prefix(self, path: Path) -> None:
|
|
568
568
|
self._prefix = path
|
|
569
569
|
|
|
570
|
-
def _launch_env(self) -> dict[str, str]:
|
|
570
|
+
def _launch_env(self, *, use_lockfiles: bool = True) -> dict[str, str]:
|
|
571
571
|
"""
|
|
572
572
|
Environment for a command launched in the conda environment.
|
|
573
573
|
|
|
@@ -575,10 +575,20 @@ class CondaDiagnosticProvider(CommandLineDiagnosticProvider):
|
|
|
575
575
|
so settings applied to a worker reach every command it launches,
|
|
576
576
|
with the provider's overrides on top.
|
|
577
577
|
HOME falls back to the conda prefix so micromamba has a writable directory.
|
|
578
|
+
|
|
579
|
+
Parameters
|
|
580
|
+
----------
|
|
581
|
+
use_lockfiles
|
|
582
|
+
Whether micromamba should take its file locks.
|
|
583
|
+
Disable this for commands that only read the environment.
|
|
584
|
+
An explicit ``MAMBA_USE_LOCKFILES`` in the process environment
|
|
585
|
+
or in `env_overrides` always wins.
|
|
578
586
|
"""
|
|
579
587
|
env = {**os.environ, **self.env_overrides}
|
|
580
588
|
if "HOME" not in env:
|
|
581
589
|
env["HOME"] = str(self.prefix)
|
|
590
|
+
if not use_lockfiles:
|
|
591
|
+
env.setdefault("MAMBA_USE_LOCKFILES", "false")
|
|
582
592
|
return env
|
|
583
593
|
|
|
584
594
|
@property
|
|
@@ -708,6 +718,7 @@ class CondaDiagnosticProvider(CommandLineDiagnosticProvider):
|
|
|
708
718
|
"pip",
|
|
709
719
|
"install",
|
|
710
720
|
"--no-deps",
|
|
721
|
+
"--force-reinstall",
|
|
711
722
|
pkg,
|
|
712
723
|
]
|
|
713
724
|
logger.debug(f"Running {' '.join(cmd)}")
|
|
@@ -753,7 +764,7 @@ class CondaDiagnosticProvider(CommandLineDiagnosticProvider):
|
|
|
753
764
|
stdout=subprocess.PIPE,
|
|
754
765
|
stderr=subprocess.STDOUT,
|
|
755
766
|
text=True,
|
|
756
|
-
env=self._launch_env(),
|
|
767
|
+
env=self._launch_env(use_lockfiles=False),
|
|
757
768
|
)
|
|
758
769
|
logger.info("Command output: \n" + res.stdout)
|
|
759
770
|
logger.info("Command execution successful")
|
|
@@ -393,6 +393,71 @@ def load_datasets_from_yaml(path: Path, paths_file: Path) -> ExecutionDatasetCol
|
|
|
393
393
|
return ExecutionDatasetCollection(collections)
|
|
394
394
|
|
|
395
395
|
|
|
396
|
+
def validate_catalog_paths(path: Path, paths_file: Path) -> ExecutionDatasetCollection:
|
|
397
|
+
"""Load a catalog and check that every row resolves to a local file.
|
|
398
|
+
|
|
399
|
+
Loading remains permissive because callers also use catalogs for metadata-only
|
|
400
|
+
operations. Fetch and execution entry points can call this function when they
|
|
401
|
+
require usable input files.
|
|
402
|
+
|
|
403
|
+
Parameters
|
|
404
|
+
----------
|
|
405
|
+
path
|
|
406
|
+
Path to the catalog YAML file.
|
|
407
|
+
paths_file
|
|
408
|
+
Path to the machine-local paths sidecar.
|
|
409
|
+
|
|
410
|
+
Returns
|
|
411
|
+
-------
|
|
412
|
+
:
|
|
413
|
+
The loaded datasets, so callers need not parse the catalog a second time.
|
|
414
|
+
|
|
415
|
+
Raises
|
|
416
|
+
------
|
|
417
|
+
DatasetResolutionError
|
|
418
|
+
If the sidecar is absent for a non-empty catalog, incomplete, or points to missing files.
|
|
419
|
+
"""
|
|
420
|
+
from climate_ref_core.exceptions import DatasetResolutionError # noqa: PLC0415
|
|
421
|
+
|
|
422
|
+
try:
|
|
423
|
+
datasets = load_datasets_from_yaml(path, paths_file)
|
|
424
|
+
except Exception as e:
|
|
425
|
+
raise DatasetResolutionError(f"Could not load catalog paths for {path}: {e}") from e
|
|
426
|
+
|
|
427
|
+
has_rows = any(len(collection.datasets) for collection in datasets.values())
|
|
428
|
+
if has_rows and not paths_file.exists():
|
|
429
|
+
raise DatasetResolutionError(
|
|
430
|
+
f"Paths file is missing for catalog {path}: {paths_file}. "
|
|
431
|
+
"Run `ref test-cases fetch` to rebuild it."
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
missing_entries: list[str] = []
|
|
435
|
+
missing_files: list[str] = []
|
|
436
|
+
for source_type, collection in datasets.items():
|
|
437
|
+
for _, dataset in collection.datasets.iterrows():
|
|
438
|
+
instance_id = dataset.get(collection.slug_column)
|
|
439
|
+
filename = dataset.get("filename")
|
|
440
|
+
key = f"{instance_id}::{filename}" if instance_id and filename else instance_id
|
|
441
|
+
resolved_path = dataset.get("path")
|
|
442
|
+
label = f"{source_type.value}:{key or '<unknown dataset>'}"
|
|
443
|
+
if not isinstance(resolved_path, (str, Path)) or not str(resolved_path):
|
|
444
|
+
missing_entries.append(label)
|
|
445
|
+
elif not Path(resolved_path).is_file():
|
|
446
|
+
missing_files.append(f"{label} -> {resolved_path}")
|
|
447
|
+
|
|
448
|
+
if missing_entries or missing_files:
|
|
449
|
+
problems = []
|
|
450
|
+
if missing_entries:
|
|
451
|
+
problems.append(f"missing path entries: {', '.join(missing_entries)}")
|
|
452
|
+
if missing_files:
|
|
453
|
+
problems.append(f"files not found: {', '.join(missing_files)}")
|
|
454
|
+
raise DatasetResolutionError(
|
|
455
|
+
f"Catalog paths are incomplete for {path} ({'. '.join(problems)}). "
|
|
456
|
+
"Run `ref test-cases fetch` to rebuild the paths file."
|
|
457
|
+
)
|
|
458
|
+
return datasets
|
|
459
|
+
|
|
460
|
+
|
|
396
461
|
def get_catalog_hash(path: Path) -> str | None:
|
|
397
462
|
"""
|
|
398
463
|
Get the hash stored in an existing catalog file.
|
|
@@ -493,17 +558,25 @@ def _serialise_datasets(
|
|
|
493
558
|
filtered_records = []
|
|
494
559
|
for record in datasets_records:
|
|
495
560
|
instance_id = record.get(slug_column)
|
|
496
|
-
if instance_id
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
561
|
+
if not isinstance(instance_id, str) or not instance_id:
|
|
562
|
+
raise ValueError(
|
|
563
|
+
f"Dataset is missing the required {slug_column!r} identity field and cannot be saved"
|
|
564
|
+
)
|
|
565
|
+
file_path = record.get("path")
|
|
566
|
+
if not isinstance(file_path, (str, Path)) or not str(file_path):
|
|
567
|
+
raise ValueError(
|
|
568
|
+
f"Dataset {instance_id!r} is missing the required 'path' field and cannot be saved"
|
|
569
|
+
)
|
|
570
|
+
record.pop("path")
|
|
571
|
+
filename = Path(file_path).name
|
|
572
|
+
# Store filename in record for matching when loading
|
|
573
|
+
record["filename"] = filename
|
|
574
|
+
# Use composite key to support multiple files per instance_id
|
|
575
|
+
paths_map[f"{instance_id}::{filename}"] = str(file_path)
|
|
576
|
+
# Sanitize and sort fields within each record alphabetically
|
|
577
|
+
sanitized_record = {k: _sanitize_for_yaml(v) for k, v in record.items()}
|
|
578
|
+
sorted_record = dict(sorted(sanitized_record.items()))
|
|
579
|
+
filtered_records.append(sorted_record)
|
|
507
580
|
|
|
508
581
|
# Sort records by instance_id, then by filename for stability
|
|
509
582
|
filtered_records.sort(key=lambda r: (r.get(slug_column, ""), r.get("filename", "")))
|
|
@@ -544,11 +617,9 @@ def save_datasets_to_yaml(
|
|
|
544
617
|
By default, the catalog is only written if the content has changed
|
|
545
618
|
(detected via hash comparison). Use `force=True` to always write.
|
|
546
619
|
|
|
547
|
-
The paths sidecar is
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
inputs. In that case the catalog itself is left untouched (so a plain
|
|
551
|
-
`ref test-cases fetch` is enough, no `--force` required) and only the sidecar is written.
|
|
620
|
+
The paths sidecar is regenerated on every save, even when the catalog content is
|
|
621
|
+
unchanged. Local cache contents can change independently of the version-controlled
|
|
622
|
+
catalog, so retaining an existing sidecar can leave missing or stale paths behind.
|
|
552
623
|
|
|
553
624
|
Parameters
|
|
554
625
|
----------
|
|
@@ -569,19 +640,15 @@ def save_datasets_to_yaml(
|
|
|
569
640
|
"""
|
|
570
641
|
new_hash = datasets.hash
|
|
571
642
|
|
|
643
|
+
data, paths_map = _serialise_datasets(datasets)
|
|
644
|
+
|
|
572
645
|
if not force and get_catalog_hash(path) == new_hash:
|
|
573
|
-
#
|
|
574
|
-
#
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
logger.info(f"Catalog unchanged, skipping write: {path}")
|
|
578
|
-
else:
|
|
579
|
-
_, paths_map = _serialise_datasets(datasets)
|
|
580
|
-
_write_paths_file(paths_file, paths_map)
|
|
581
|
-
logger.info(f"Catalog unchanged; regenerated missing paths file: {paths_file}")
|
|
646
|
+
# Keep the tracked catalog byte-identical, but always refresh its machine-local
|
|
647
|
+
# paths. An existing sidecar may be partial or point at an old cache location.
|
|
648
|
+
_write_paths_file(paths_file, paths_map)
|
|
649
|
+
logger.info(f"Catalog unchanged, refreshed paths file: {paths_file}")
|
|
582
650
|
return False
|
|
583
651
|
|
|
584
|
-
data, paths_map = _serialise_datasets(datasets)
|
|
585
652
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
586
653
|
with open(path, "w") as f:
|
|
587
654
|
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|