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.
Files changed (100) hide show
  1. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/PKG-INFO +3 -2
  2. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/pyproject.toml +3 -2
  3. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/esgf/cmip7.py +122 -50
  4. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/providers.py +14 -3
  5. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/testing.py +93 -26
  6. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/esgf/test_cmip7.py +279 -2
  7. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/test_providers.py +27 -1
  8. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/test_testing.py +100 -0
  9. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/.gitignore +0 -0
  10. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/LICENCE +0 -0
  11. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/NOTICE +0 -0
  12. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/README.md +0 -0
  13. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/__init__.py +0 -0
  14. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/cmip6_to_cmip7.py +0 -0
  15. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/cmor.py +0 -0
  16. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/constraints.py +0 -0
  17. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/data/cmip6_cmip7_variable_map.json +0 -0
  18. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/data.py +0 -0
  19. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/dataset_registry.py +0 -0
  20. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/datasets.py +0 -0
  21. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/diagnostics.py +0 -0
  22. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/env.py +0 -0
  23. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/esgf/__init__.py +0 -0
  24. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/esgf/base.py +0 -0
  25. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/esgf/cmip6.py +0 -0
  26. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/esgf/fetcher.py +0 -0
  27. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/esgf/obs4mips.py +0 -0
  28. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/esgf/registry.py +0 -0
  29. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/esmvaltool_reference.py +0 -0
  30. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/exceptions.py +0 -0
  31. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/executor.py +0 -0
  32. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/logging.py +0 -0
  33. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/metric_values/__init__.py +0 -0
  34. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/metric_values/typing.py +0 -0
  35. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/output_files.py +0 -0
  36. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/paths.py +0 -0
  37. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/py.typed +0 -0
  38. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/pycmec/README.md +0 -0
  39. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/pycmec/__init__.py +0 -0
  40. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/pycmec/controlled_vocabulary.py +0 -0
  41. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/pycmec/cv_cmip7_aft.yaml +0 -0
  42. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/pycmec/metric.py +0 -0
  43. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/pycmec/output.py +0 -0
  44. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/reference_data.py +0 -0
  45. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/regression/__init__.py +0 -0
  46. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/regression/_quantise.py +0 -0
  47. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/regression/capture.py +0 -0
  48. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/regression/compare.py +0 -0
  49. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/regression/gate.py +0 -0
  50. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/regression/manifest.py +0 -0
  51. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/regression/report_store.py +0 -0
  52. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/regression/store.py +0 -0
  53. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/resources.py +0 -0
  54. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/serialisation.py +0 -0
  55. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/source_types.py +0 -0
  56. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/src/climate_ref_core/summary.py +0 -0
  57. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/integration/test_resource_measurement.py +0 -0
  58. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/esgf/test_base.py +0 -0
  59. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/esgf/test_cmip6.py +0 -0
  60. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/esgf/test_fetcher.py +0 -0
  61. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/esgf/test_obs4mips.py +0 -0
  62. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/esgf/test_registry.py +0 -0
  63. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/metric_values/test_typing.py +0 -0
  64. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/pycmec/cmec_testdata/cmec_metric_sample.json +0 -0
  65. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/pycmec/cmec_testdata/cmec_output_sample.json +0 -0
  66. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/pycmec/cmec_testdata/cv_sample.yaml +0 -0
  67. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/pycmec/cmec_testdata/test_metric_json_schema.yml +0 -0
  68. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/pycmec/cmec_testdata/test_output_json_schema.yml +0 -0
  69. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/pycmec/conftest.py +0 -0
  70. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/pycmec/test_cmec_metric.py +0 -0
  71. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/pycmec/test_cmec_output.py +0 -0
  72. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/pycmec/test_controlled_vocabulary.py +0 -0
  73. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/regression/test_capture.py +0 -0
  74. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/regression/test_compare.py +0 -0
  75. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/regression/test_gate.py +0 -0
  76. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/regression/test_manifest.py +0 -0
  77. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/regression/test_quantise.py +0 -0
  78. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/regression/test_report_store.py +0 -0
  79. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/regression/test_store.py +0 -0
  80. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/test_cmip6_to_cmip7.py +0 -0
  81. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/test_cmor.py +0 -0
  82. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/test_constraints.py +0 -0
  83. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/test_data.py +0 -0
  84. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/test_dataset_registry/test_dataset_registry.py +0 -0
  85. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/test_datasets/dataset_collection_hash.yml +0 -0
  86. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/test_datasets/dataset_collection_obs4mips_hash.yml +0 -0
  87. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/test_datasets/execution_dataset_hash.yml +0 -0
  88. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/test_datasets.py +0 -0
  89. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/test_diagnostics.py +0 -0
  90. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/test_esmvaltool_reference.py +0 -0
  91. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/test_exceptions.py +0 -0
  92. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/test_executor.py +0 -0
  93. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/test_logging.py +0 -0
  94. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/test_output_files.py +0 -0
  95. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/test_packaging.py +0 -0
  96. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/test_paths.py +0 -0
  97. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/test_reference_data.py +0 -0
  98. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/test_resources.py +0 -0
  99. {climate_ref_core-0.18.0 → climate_ref_core-0.18.2}/tests/unit/test_serialisation.py +0 -0
  100. {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.0
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<84
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.0"
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<84",
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
- with xr.open_dataset(cmip6_path, decode_times=time_coder) as ds:
150
- # When fabricating extended historical coverage, pad the series first so both the
151
- # filename time range and the written data reflect the added months.
152
- source_ds = ds
153
- if extend_historical_to is not None:
154
- end_year, end_month = extend_historical_to
155
- source_ds = repeat_final_year_to(ds, end_year=end_year, end_month=end_month)
156
-
157
- frequency = str(cmip7_facets.get("frequency", "mon"))
158
- time_range = format_cmip7_time_range(source_ds, frequency)
159
- output_file = drs_path / create_cmip7_filename(cmip7_facets, time_range=time_range)
160
-
161
- if output_file.exists():
162
- logger.debug(f"Using cached CMIP7 file: {output_file}")
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
- logger.debug(f"Using existing CMIP7 file (could not overwrite): {output_file}")
195
- return output_file
196
- # Clear the cache directory hint for the user
197
- logger.error(f"Permission denied writing to {output_file}")
198
- logger.error(f"Try clearing the cache: rm -rf {_get_cmip7_cache_dir()}")
199
- raise
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 and "path" in record: # pragma: no branch
497
- file_path = record.pop("path")
498
- filename = Path(file_path).name
499
- # Store filename in record for matching when loading
500
- record["filename"] = filename
501
- # Use composite key to support multiple files per instance_id
502
- paths_map[f"{instance_id}::{filename}"] = file_path
503
- # Sanitize and sort fields within each record alphabetically
504
- sanitized_record = {k: _sanitize_for_yaml(v) for k, v in record.items()}
505
- sorted_record = dict(sorted(sanitized_record.items()))
506
- filtered_records.append(sorted_record)
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 (re)generated whenever it is **missing**, even when the catalog
548
- content is unchanged: on a machine with a cold cache the version-controlled
549
- `catalog.yaml` exists but the sidecar does not, and `run`/`mint` need it to resolve
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
- # Catalog content is unchanged. Still regenerate the paths sidecar if it is missing
574
- # (e.g. a cold dataset cache) so run/mint can resolve inputs, but leave the
575
- # version-controlled catalog untouched to avoid spurious diffs.
576
- if paths_file.exists():
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)