climate-ref-core 0.16.0__tar.gz → 0.16.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 (85) hide show
  1. {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/.gitignore +6 -2
  2. {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/PKG-INFO +1 -1
  3. {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/pyproject.toml +1 -1
  4. {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/src/climate_ref_core/dataset_registry.py +101 -2
  5. {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/src/climate_ref_core/esgf/__init__.py +2 -1
  6. {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/src/climate_ref_core/esgf/base.py +23 -1
  7. {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/src/climate_ref_core/esgf/cmip7.py +3 -4
  8. {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/src/climate_ref_core/pycmec/cv_cmip7_aft.yaml +5 -0
  9. {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/src/climate_ref_core/regression/_quantise.py +4 -4
  10. {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/src/climate_ref_core/regression/capture.py +11 -1
  11. {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/src/climate_ref_core/regression/compare.py +2 -2
  12. {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/src/climate_ref_core/source_types.py +2 -0
  13. {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/src/climate_ref_core/testing.py +61 -31
  14. {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/tests/unit/esgf/test_base.py +46 -0
  15. {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/tests/unit/esgf/test_cmip7.py +12 -3
  16. {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/tests/unit/regression/test_capture.py +3 -0
  17. {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/tests/unit/regression/test_compare.py +4 -3
  18. {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/tests/unit/regression/test_quantise.py +5 -2
  19. {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/tests/unit/test_dataset_registry/test_dataset_registry.py +76 -1
  20. {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/tests/unit/test_testing.py +84 -35
  21. {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/LICENCE +0 -0
  22. {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/NOTICE +0 -0
  23. {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/README.md +0 -0
  24. {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/src/climate_ref_core/__init__.py +0 -0
  25. {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/src/climate_ref_core/cmip6_to_cmip7.py +0 -0
  26. {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/src/climate_ref_core/constraints.py +0 -0
  27. {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/src/climate_ref_core/data/cmip6_cmip7_variable_map.json +0 -0
  28. {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/src/climate_ref_core/datasets.py +0 -0
  29. {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/src/climate_ref_core/diagnostics.py +0 -0
  30. {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/src/climate_ref_core/env.py +0 -0
  31. {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/src/climate_ref_core/esgf/cmip6.py +0 -0
  32. {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/src/climate_ref_core/esgf/fetcher.py +0 -0
  33. {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/src/climate_ref_core/esgf/obs4mips.py +0 -0
  34. {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/src/climate_ref_core/esgf/registry.py +0 -0
  35. {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/src/climate_ref_core/exceptions.py +0 -0
  36. {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/src/climate_ref_core/executor.py +0 -0
  37. {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/src/climate_ref_core/logging.py +0 -0
  38. {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/src/climate_ref_core/metric_values/__init__.py +0 -0
  39. {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/src/climate_ref_core/metric_values/typing.py +0 -0
  40. {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/src/climate_ref_core/output_files.py +0 -0
  41. {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/src/climate_ref_core/paths.py +0 -0
  42. {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/src/climate_ref_core/providers.py +0 -0
  43. {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/src/climate_ref_core/py.typed +0 -0
  44. {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/src/climate_ref_core/pycmec/README.md +0 -0
  45. {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/src/climate_ref_core/pycmec/__init__.py +0 -0
  46. {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/src/climate_ref_core/pycmec/controlled_vocabulary.py +0 -0
  47. {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/src/climate_ref_core/pycmec/metric.py +0 -0
  48. {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/src/climate_ref_core/pycmec/output.py +0 -0
  49. {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/src/climate_ref_core/regression/__init__.py +0 -0
  50. {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/src/climate_ref_core/regression/gate.py +0 -0
  51. {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/src/climate_ref_core/regression/manifest.py +0 -0
  52. {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/src/climate_ref_core/regression/store.py +0 -0
  53. {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/src/climate_ref_core/summary.py +0 -0
  54. {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/tests/unit/esgf/test_cmip6.py +0 -0
  55. {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/tests/unit/esgf/test_fetcher.py +0 -0
  56. {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/tests/unit/esgf/test_obs4mips.py +0 -0
  57. {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/tests/unit/esgf/test_registry.py +0 -0
  58. {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/tests/unit/metric_values/test_typing.py +0 -0
  59. {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/tests/unit/pycmec/cmec_testdata/cmec_metric_sample.json +0 -0
  60. {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/tests/unit/pycmec/cmec_testdata/cmec_output_sample.json +0 -0
  61. {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/tests/unit/pycmec/cmec_testdata/cv_sample.yaml +0 -0
  62. {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/tests/unit/pycmec/cmec_testdata/test_metric_json_schema.yml +0 -0
  63. {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/tests/unit/pycmec/cmec_testdata/test_output_json_schema.yml +0 -0
  64. {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/tests/unit/pycmec/conftest.py +0 -0
  65. {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/tests/unit/pycmec/test_cmec_metric.py +0 -0
  66. {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/tests/unit/pycmec/test_cmec_output.py +0 -0
  67. {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/tests/unit/pycmec/test_controlled_vocabulary.py +0 -0
  68. {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/tests/unit/regression/test_gate.py +0 -0
  69. {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/tests/unit/regression/test_manifest.py +0 -0
  70. {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/tests/unit/regression/test_store.py +0 -0
  71. {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/tests/unit/test_cmip6_to_cmip7.py +0 -0
  72. {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/tests/unit/test_constraints.py +0 -0
  73. {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/tests/unit/test_datasets/dataset_collection_hash.yml +0 -0
  74. {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/tests/unit/test_datasets/dataset_collection_obs4mips_hash.yml +0 -0
  75. {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/tests/unit/test_datasets/execution_dataset_hash.yml +0 -0
  76. {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/tests/unit/test_datasets.py +0 -0
  77. {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/tests/unit/test_diagnostics.py +0 -0
  78. {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/tests/unit/test_exceptions.py +0 -0
  79. {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/tests/unit/test_executor.py +0 -0
  80. {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/tests/unit/test_logging.py +0 -0
  81. {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/tests/unit/test_output_files.py +0 -0
  82. {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/tests/unit/test_packaging.py +0 -0
  83. {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/tests/unit/test_paths.py +0 -0
  84. {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/tests/unit/test_providers.py +0 -0
  85. {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/tests/unit/test_summary.py +0 -0
@@ -164,11 +164,15 @@ packages/*/NOTICE
164
164
  # Generated SDK
165
165
  /climate_ref_client
166
166
 
167
+ # Test cases
168
+ # Only track the materialised data for the test cases, not the raw output of the providers.
169
+ **/test-data/**/output/
170
+ **/test-data/**/regression/**
171
+ !**/test-data/**/regression/*.json
172
+
167
173
  # User-specific catalog paths (test data)
168
174
  *.paths.yaml
169
175
 
170
- # Materialised test-case output slots (local inspection only; never committed)
171
- **/test-data/**/output/
172
176
 
173
177
  # Debug log files
174
178
  **/main_log_debug.txt
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: climate-ref-core
3
- Version: 0.16.0
3
+ Version: 0.16.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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "climate-ref-core"
3
- version = "0.16.0"
3
+ version = "0.16.2"
4
4
  description = "Core library for the CMIP Rapid Evaluation Framework"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -6,21 +6,67 @@ The CMIP7 Assessment Fas Track REF requires that reference datasets are openly l
6
6
  before it is included in any published data catalogs.
7
7
  """
8
8
 
9
+ import enum
9
10
  import importlib.resources
10
11
  import os
11
12
  import pathlib
12
13
  import shutil
14
+ from collections.abc import Iterator
13
15
 
14
16
  import pooch
15
17
  import pooch.hashes
18
+ from attrs import frozen
16
19
  from loguru import logger
17
20
  from rich.progress import track
18
21
 
19
22
  from climate_ref_core.env import env
23
+ from climate_ref_core.source_types import SourceDatasetType
20
24
 
21
25
  DATASET_URL = env.str("REF_DATASET_URL", default="https://obs4ref.climate-ref.org")
22
26
 
23
27
 
28
+ class RegistryUseCase(enum.Enum):
29
+ """
30
+ Whether a registry's contents are catalog-ingestable or fetch-only.
31
+ """
32
+
33
+ reference = "reference"
34
+ """
35
+ Contents are catalog-ingestable reference data.
36
+
37
+ The registry declares exactly one target source type,
38
+ which is used to select the parsing adapter during ingest.
39
+ """
40
+
41
+ support = "support"
42
+ """
43
+ Contents are fetch-only support files (e.g. region masks, recipes).
44
+
45
+ These are never entered into the catalog.
46
+ """
47
+
48
+
49
+ @frozen
50
+ class RegistryEntry:
51
+ """
52
+ A dataset registry together with its ingest metadata.
53
+ """
54
+
55
+ registry: pooch.Pooch
56
+ """The underlying Pooch registry."""
57
+
58
+ source_type: SourceDatasetType | None
59
+ """
60
+ The target source type used to select the parsing adapter.
61
+
62
+ Required for ``reference`` registries.
63
+ ``None`` for ``support`` registries, since fetch-only content is never parsed.
64
+ """
65
+
66
+ use_case: RegistryUseCase
67
+ """Whether the registry's contents are catalog-ingestable or fetch-only."""
68
+
69
+
24
70
  def resolve_cache_dir(cache_name: str) -> pathlib.Path:
25
71
  """
26
72
  Resolve the cache directory for a registry.
@@ -216,12 +262,18 @@ class DatasetRegistryManager:
216
262
  """
217
263
 
218
264
  def __init__(self) -> None:
219
- self._registries: dict[str, pooch.Pooch] = {}
265
+ self._registries: dict[str, RegistryEntry] = {}
220
266
 
221
267
  def __getitem__(self, item: str) -> pooch.Pooch:
222
268
  """
223
269
  Get a registry by name
224
270
  """
271
+ return self._registries[item].registry
272
+
273
+ def entry(self, item: str) -> RegistryEntry:
274
+ """
275
+ Get the full registry entry (registry, source type and use-case) by name
276
+ """
225
277
  return self._registries[item]
226
278
 
227
279
  def keys(self) -> list[str]:
@@ -275,6 +327,8 @@ class DatasetRegistryManager:
275
327
  cache_name: str | None = None,
276
328
  version: str | None = None,
277
329
  legacy_cache_dirs: list[pathlib.Path] | None = None,
330
+ source_type: SourceDatasetType | None = None,
331
+ use_case: RegistryUseCase = RegistryUseCase.support,
278
332
  ) -> None:
279
333
  """
280
334
  Register a new dataset registry
@@ -310,6 +364,19 @@ class DatasetRegistryManager:
310
364
  If provided, any files that exist in a legacy directory but not in
311
365
  the current cache will be moved to the new location. This avoids
312
366
  re-downloading data after a cache layout change.
367
+ source_type
368
+ The target source type used to select the parsing adapter.
369
+
370
+ Required for ``reference`` registries.
371
+ Leave as ``None`` for ``support`` registries,
372
+ or for registries whose contents span more than one source type
373
+ (which cannot be catalogued by registry-driven ingest at all).
374
+ use_case
375
+ Whether the registry's contents are catalog-ingestable (``reference``)
376
+ or fetch-only (``support``).
377
+
378
+ Defaults to ``support`` so that an unannotated registry is never
379
+ catalogued by registry-driven ingest.
313
380
  """
314
381
  if cache_name is None:
315
382
  cache_name = name
@@ -336,7 +403,39 @@ class DatasetRegistryManager:
336
403
  if cache_path != default_legacy:
337
404
  self._migrate_cache(registry, all_legacy_dirs)
338
405
 
339
- self._registries[name] = registry
406
+ self._registries[name] = RegistryEntry(
407
+ registry=registry,
408
+ source_type=source_type,
409
+ use_case=use_case,
410
+ )
340
411
 
341
412
 
342
413
  dataset_registry_manager = DatasetRegistryManager()
414
+
415
+
416
+ def iter_reference_registries(
417
+ manager: DatasetRegistryManager,
418
+ ) -> Iterator[tuple[pooch.Pooch, SourceDatasetType]]:
419
+ """
420
+ Yield the ``(registry, source_type)`` pairs for all catalog-ingestable registries.
421
+
422
+ Registries whose ``use_case`` is not ``reference``, or whose ``source_type`` is
423
+ ``None`` (e.g. registries that mix source types and so cannot be catalogued as
424
+ a whole), are skipped.
425
+ A later ingest driver can pass the yielded ``source_type`` to
426
+ :func:`climate_ref.datasets.get_dataset_adapter` to select the parsing adapter.
427
+
428
+ Parameters
429
+ ----------
430
+ manager
431
+ The registry manager to read entries from.
432
+
433
+ Returns
434
+ -------
435
+ :
436
+ An iterator of ``(registry, source_type)`` pairs.
437
+ """
438
+ for name in manager.keys():
439
+ entry = manager.entry(name)
440
+ if entry.use_case is RegistryUseCase.reference and entry.source_type is not None:
441
+ yield entry.registry, entry.source_type
@@ -5,7 +5,7 @@ This module provides classes for searching and fetching datasets from ESGF
5
5
  (Earth System Grid Federation) and other data registries.
6
6
  """
7
7
 
8
- from climate_ref_core.esgf.base import ESGFRequest, IntakeESGFMixin
8
+ from climate_ref_core.esgf.base import ESGFRequest, IntakeESGFMixin, enable_ceda_solr_index
9
9
  from climate_ref_core.esgf.cmip6 import CMIP6Request
10
10
  from climate_ref_core.esgf.cmip7 import CMIP7Request
11
11
  from climate_ref_core.esgf.fetcher import ESGFFetcher
@@ -20,4 +20,5 @@ __all__ = [
20
20
  "IntakeESGFMixin",
21
21
  "Obs4MIPsRequest",
22
22
  "RegistryRequest",
23
+ "enable_ceda_solr_index",
23
24
  ]
@@ -7,8 +7,24 @@ using the intake-esgf package.
7
7
 
8
8
  from typing import Any, Protocol, runtime_checkable
9
9
 
10
+ import intake_esgf
10
11
  import pandas as pd
11
12
  from intake_esgf import ESGFCatalog
13
+ from intake_esgf.exceptions import NoSearchResults
14
+
15
+ from climate_ref_core.exceptions import DatasetResolutionError
16
+
17
+
18
+ def enable_ceda_solr_index() -> None:
19
+ """
20
+ Enable the CEDA Solr index as an additional ESGF search index.
21
+
22
+ The obs4MIPs record for C3S-GTO-ECV-9-0 (the ozone reference) is missing
23
+ from the default ESGF2-US-1.5-Catalog index but is still served by the
24
+ CEDA Solr index, so search that index as well.
25
+ Remove once the record returns to the 1.5 catalog.
26
+ """
27
+ intake_esgf.conf["solr_indices"]["esgf.ceda.ac.uk"] = True
12
28
 
13
29
 
14
30
  @runtime_checkable
@@ -107,9 +123,15 @@ class IntakeESGFMixin:
107
123
  # intake-esgf assigns Python lists into individual DataFrame cells, which only works for object dtype.
108
124
  # Pandas >= 3.0 defaults strings to the pyarrow-backed dtype, where that assignment raises,
109
125
  # so pin the legacy object-string behaviour for the intake-esgf interaction.
126
+ enable_ceda_solr_index()
127
+
110
128
  with pd.option_context("future.infer_string", False):
111
129
  cat = ESGFCatalog() # type: ignore[no-untyped-call]
112
- cat.search(**facets)
130
+ try:
131
+ cat.search(**facets)
132
+ except NoSearchResults:
133
+ msg = f"ESGF search returned no results for facets: {facets}"
134
+ raise DatasetResolutionError(msg) from None
113
135
 
114
136
  if self.remove_ensembles:
115
137
  cat.remove_ensembles()
@@ -11,7 +11,6 @@ from pathlib import Path
11
11
  from typing import Any, ClassVar
12
12
 
13
13
  import pandas as pd
14
- import platformdirs
15
14
  import xarray as xr
16
15
  from loguru import logger
17
16
 
@@ -25,14 +24,14 @@ from climate_ref_core.cmip6_to_cmip7 import (
25
24
  shift_time_axis_end,
26
25
  suppress_bounds_coordinates,
27
26
  )
27
+ from climate_ref_core.dataset_registry import resolve_cache_dir
28
28
  from climate_ref_core.esgf.cmip6 import CMIP6Request
29
29
 
30
30
 
31
31
  def _get_cmip7_cache_dir() -> Path:
32
32
  """Get the cache directory for converted CMIP7 files."""
33
- # Use platform-appropriate cache directory for climate-ref
34
- # This avoids polluting the intake-esgf cache with converted files
35
- cache_dir = Path(platformdirs.user_cache_dir("climate_ref")) / "cmip7-converted"
33
+ # Kept out of the intake-esgf cache so converted files don't mix with downloads.
34
+ cache_dir = resolve_cache_dir("cmip7-converted")
36
35
  cache_dir.mkdir(parents=True, exist_ok=True)
37
36
  return cache_dir
38
37
 
@@ -66,6 +66,11 @@ dimensions:
66
66
  description: "Global warming level (e.g., 1.5C, 2C, etc.)"
67
67
  allow_extra_values: true
68
68
  required: false
69
+ - name: level
70
+ long_name: Level
71
+ description: "Vertical level at which the metric is calculated (e.g. pressure level in hPa)"
72
+ allow_extra_values: true
73
+ required: false
69
74
  - name: metric
70
75
  long_name: Metric
71
76
  description: ""
@@ -8,9 +8,9 @@ producing noisy, unreviewable diffs in the committed bundle.
8
8
 
9
9
  Rounding every float to a fixed number of significant figures at write time gives
10
10
  stable, reviewable committed bytes.
11
- We round to seven significant figures: one digit finer than the regression compare tolerance
12
- (``rtol=1e-6`` in :mod:`climate_ref_core.regression.compare`),
13
- so the rounding error stays an order of magnitude under tolerance and can never flip a boundary gate verdict.
11
+ We round to seven significant figures: two digits finer than the regression compare tolerance
12
+ (``rtol=1e-5`` in :mod:`climate_ref_core.regression.compare`),
13
+ so the rounding error stays well under tolerance and can never flip a boundary gate verdict.
14
14
 
15
15
  This affects only the committed bundle.
16
16
  The native blobs (``.nc`` / ``.png``) and their content-addressed digests are never touched.
@@ -21,7 +21,7 @@ from typing import Any
21
21
  DEFAULT_SIG_FIGS: int = 7
22
22
  """Default significant figures for committed-bundle floats.
23
23
 
24
- Deliberately one digit finer than the ``rtol=1e-6`` regression compare tolerance,
24
+ Deliberately two digits finer than the ``rtol=1e-5`` regression compare tolerance,
25
25
  so rounding never flips a boundary gate verdict.
26
26
  """
27
27
 
@@ -68,6 +68,12 @@ _REDACTED_PROVENANCE_PLATFORM_FIELDS: dict[str, str] = {
68
68
  "OS": "<OS>",
69
69
  }
70
70
 
71
+ # ``conda`` sub-block fields, redacted for the same reason
72
+ # (an ``osx-64`` mint must match a ``linux-64`` CI execute).
73
+ _REDACTED_PROVENANCE_CONDA_FIELDS: dict[str, str] = {
74
+ "Platform": "<CONDA_PLATFORM>",
75
+ }
76
+
71
77
  _SOURCE_DIR_PLACEHOLDER = "<SOURCE_DIR>"
72
78
 
73
79
  # Matches the machine-specific prefix before ``/packages/climate-ref-<pkg>/`` in in-repo paths.
@@ -96,7 +102,8 @@ def _redact_provenance_fields(obj: object) -> bool:
96
102
 
97
103
  Walks the parsed bundle and, for each ``PROVENANCE`` / ``provenance`` block,
98
104
  overwrites the fields in :data:`_REDACTED_PROVENANCE_FIELDS` with their placeholders,
99
- and the host fields in :data:`_REDACTED_PROVENANCE_PLATFORM_FIELDS` inside its nested ``platform``.
105
+ the host fields in :data:`_REDACTED_PROVENANCE_PLATFORM_FIELDS` inside its nested ``platform``,
106
+ and the fields in :data:`_REDACTED_PROVENANCE_CONDA_FIELDS` inside its nested ``conda``.
100
107
 
101
108
  Returns
102
109
  -------
@@ -111,6 +118,9 @@ def _redact_provenance_fields(obj: object) -> bool:
111
118
  platform = value.get("platform")
112
119
  if isinstance(platform, dict):
113
120
  changed |= _redact_fields(platform, _REDACTED_PROVENANCE_PLATFORM_FIELDS)
121
+ conda = value.get("conda")
122
+ if isinstance(conda, dict):
123
+ changed |= _redact_fields(conda, _REDACTED_PROVENANCE_CONDA_FIELDS)
114
124
  elif _redact_provenance_fields(value):
115
125
  changed = True
116
126
  elif isinstance(obj, list):
@@ -21,13 +21,13 @@ class Tolerance:
21
21
  ----------
22
22
  rtol
23
23
  Relative tolerance — the allowed proportional difference between expected
24
- and actual float values (default ``1e-6``).
24
+ and actual float values (default ``1e-5``).
25
25
  atol
26
26
  Absolute tolerance — the floor difference always permitted,
27
27
  regardless of magnitude (default ``1e-8``).
28
28
  """
29
29
 
30
- rtol: float = 1e-6
30
+ rtol: float = 1e-5
31
31
  atol: float = 1e-8
32
32
 
33
33
 
@@ -30,6 +30,8 @@ class SourceDatasetType(enum.Enum):
30
30
  CMIP7 = "cmip7"
31
31
  obs4MIPs = "obs4mips"
32
32
  PMPClimatology = "pmp-climatology"
33
+ obs4REF = "obs4ref"
34
+ ESMValToolReference = "esmvaltool-reference"
33
35
 
34
36
  @classmethod
35
37
  @functools.lru_cache(maxsize=1)
@@ -3,7 +3,7 @@ Test infrastructure for diagnostic testing.
3
3
 
4
4
  This module provides:
5
5
  - TestCase and TestDataSpecification for defining test scenarios
6
- - YAML serialization for dataset catalogs (with paths stored separately)
6
+ - YAML serialization for dataset catalogs (with local paths stored separately, under the cache)
7
7
  - Utilities for CMEC bundle and series regression validation
8
8
  """
9
9
 
@@ -20,6 +20,7 @@ import yaml
20
20
  from attrs import field, frozen
21
21
  from loguru import logger
22
22
 
23
+ from climate_ref_core.dataset_registry import resolve_cache_dir
23
24
  from climate_ref_core.datasets import (
24
25
  DatasetCollection,
25
26
  ExecutionDatasetCollection,
@@ -142,17 +143,25 @@ class TestCasePaths:
142
143
 
143
144
  Provides access to all paths within a test case directory:
144
145
  - catalog.yaml: Dataset metadata (tracked in git)
145
- - catalog.paths.yaml: Local file paths (gitignored)
146
146
  - regression/: Regression outputs (tracked in git)
147
147
 
148
+ The local-paths sidecar (``catalog.paths.yaml``) is the exception: it lives under the
149
+ dataset cache rather than in the package tree. The paths it records are machine-specific
150
+ and point into the various caches (the dataset cache, the intake-esgf cache), so the
151
+ sidecar belongs with those caches rather than with a checkout. It is shared by every
152
+ checkout on the machine, and a fresh checkout cannot lose it.
153
+
148
154
  Can be constructed from:
149
155
  - A diagnostic + test case name (auto-resolves provider's test-data dir)
150
- - An explicit test_data_dir + diagnostic slug + test case name
156
+ - An explicit test_data_dir + provider slug + diagnostic slug + test case name
151
157
  """
152
158
 
153
159
  root: Path
154
160
  """The test case directory (test_data_dir / diagnostic_slug / test_case_name)."""
155
161
 
162
+ provider_slug: str
163
+ """Slug of the provider owning the diagnostic, used to namespace the paths sidecar."""
164
+
156
165
  @classmethod
157
166
  def from_diagnostic(cls, diagnostic: Diagnostic, test_case: str) -> TestCasePaths | None:
158
167
  """
@@ -171,7 +180,10 @@ class TestCasePaths:
171
180
  test_data_dir = _get_provider_test_data_dir(diagnostic)
172
181
  if test_data_dir is None:
173
182
  return None
174
- return cls(root=test_data_dir / diagnostic.slug / test_case)
183
+ return cls(
184
+ root=test_data_dir / diagnostic.slug / test_case,
185
+ provider_slug=diagnostic.provider.slug,
186
+ )
175
187
 
176
188
  @classmethod
177
189
  def from_test_data_dir(
@@ -179,6 +191,7 @@ class TestCasePaths:
179
191
  test_data_dir: Path,
180
192
  diagnostic_slug: str,
181
193
  test_case: str,
194
+ provider_slug: str,
182
195
  ) -> TestCasePaths:
183
196
  """
184
197
  Create from an explicit test data directory.
@@ -194,8 +207,10 @@ class TestCasePaths:
194
207
  The diagnostic slug
195
208
  test_case
196
209
  Test case name (e.g., 'default')
210
+ provider_slug
211
+ The provider slug, used to namespace the paths sidecar
197
212
  """
198
- return cls(root=test_data_dir / diagnostic_slug / test_case)
213
+ return cls(root=test_data_dir / diagnostic_slug / test_case, provider_slug=provider_slug)
199
214
 
200
215
  @property
201
216
  def catalog(self) -> Path:
@@ -204,8 +219,20 @@ class TestCasePaths:
204
219
 
205
220
  @property
206
221
  def catalog_paths(self) -> Path:
207
- """Path to catalog.paths.yaml (gitignored, contains local file paths)."""
208
- return self.root / "catalog.paths.yaml"
222
+ """
223
+ Path to catalog.paths.yaml, under the dataset cache rather than the package tree.
224
+
225
+ The sidecar maps each dataset to its local file, so it is a property of the machine,
226
+ not of the checkout. Namespaced by provider because diagnostic slugs are only unique
227
+ within a provider.
228
+ """
229
+ return (
230
+ resolve_cache_dir("test-case-paths")
231
+ / self.provider_slug
232
+ / self.root.parent.name
233
+ / self.root.name
234
+ / "catalog.paths.yaml"
235
+ )
209
236
 
210
237
  @property
211
238
  def regression(self) -> Path:
@@ -298,12 +325,7 @@ def _get_provider_test_data_dir(diag: Diagnostic) -> Path | None:
298
325
  return test_data_dir
299
326
 
300
327
 
301
- def _get_paths_file(catalog_path: Path) -> Path:
302
- """Get the paths file path for a catalog file."""
303
- return catalog_path.with_suffix(".paths.yaml")
304
-
305
-
306
- def load_datasets_from_yaml(path: Path) -> ExecutionDatasetCollection:
328
+ def load_datasets_from_yaml(path: Path, paths_file: Path) -> ExecutionDatasetCollection:
307
329
  """
308
330
  Load ExecutionDatasetCollection from a YAML file.
309
331
 
@@ -321,16 +343,21 @@ def load_datasets_from_yaml(path: Path) -> ExecutionDatasetCollection:
321
343
  # ... other metadata
322
344
  ```
323
345
 
324
- Paths are loaded from a separate `.paths.yaml` file if it exists,
325
- allowing the main catalog to be version-controlled while paths
326
- remain user-specific. Multi-file datasets have multiple rows with
327
- paths keyed by `{instance_id}::{filename}`.
346
+ Paths are loaded from ``paths_file`` if it exists, allowing the main catalog to be
347
+ version-controlled while paths remain machine-specific. Multi-file datasets have
348
+ multiple rows with paths keyed by `{instance_id}::{filename}`.
349
+
350
+ Parameters
351
+ ----------
352
+ path
353
+ Path to the catalog YAML file
354
+ paths_file
355
+ Path to the local-paths sidecar (see :attr:`TestCasePaths.catalog_paths`).
356
+ If it does not exist, the loaded datasets carry no ``path`` column.
328
357
  """
329
358
  with open(path) as f:
330
359
  data = yaml.safe_load(f)
331
360
 
332
- # Load paths from separate file if it exists
333
- paths_file = _get_paths_file(path)
334
361
  paths_map: dict[str, str] = {}
335
362
  if paths_file.exists():
336
363
  with open(paths_file) as f:
@@ -456,7 +483,8 @@ def _serialise_datasets(
456
483
  Returns a ``(data, paths_map)`` tuple: ``data`` is the catalog content (metadata plus
457
484
  per-source records with local paths stripped out), and ``paths_map`` maps
458
485
  ``{instance_id}::{filename}`` to each local file path. The two are written to
459
- ``catalog.yaml`` and the gitignored ``catalog.paths.yaml`` respectively.
486
+ ``catalog.yaml`` (in the package tree) and ``catalog.paths.yaml`` (under the dataset
487
+ cache) respectively.
460
488
  """
461
489
  data: dict[str, Any] = {
462
490
  "_metadata": {"hash": datasets.hash},
@@ -496,7 +524,7 @@ def _serialise_datasets(
496
524
 
497
525
 
498
526
  def _write_paths_file(paths_file: Path, paths_map: dict[str, str]) -> None:
499
- """Write the gitignored local-paths sidecar (``catalog.paths.yaml``)."""
527
+ """Write the local-paths sidecar (``catalog.paths.yaml``, under the dataset cache)."""
500
528
  paths_file.parent.mkdir(parents=True, exist_ok=True)
501
529
  with open(paths_file, "w") as f:
502
530
  yaml.dump(paths_map, f, default_flow_style=False, sort_keys=False)
@@ -505,14 +533,15 @@ def _write_paths_file(paths_file: Path, paths_map: dict[str, str]) -> None:
505
533
  def save_datasets_to_yaml(
506
534
  datasets: ExecutionDatasetCollection,
507
535
  path: Path,
536
+ paths_file: Path,
508
537
  *,
509
538
  force: bool = False,
510
539
  ) -> bool:
511
540
  """
512
541
  Save ExecutionDatasetCollection to a YAML file.
513
542
 
514
- Paths are saved to a separate `.paths.yaml` file to allow the main
515
- catalog to be version-controlled while paths remain user-specific.
543
+ Paths are saved to a separate sidecar file to allow the main catalog to be
544
+ version-controlled while paths remain machine-specific.
516
545
 
517
546
  Multi-file datasets (e.g., time-chunked data) are stored as multiple rows,
518
547
  one per file. Paths are keyed by `{instance_id}::{filename}` to support
@@ -521,11 +550,11 @@ def save_datasets_to_yaml(
521
550
  By default, the catalog is only written if the content has changed
522
551
  (detected via hash comparison). Use `force=True` to always write.
523
552
 
524
- The gitignored `.paths.yaml` sidecar is (re)generated whenever it is **missing**, even
525
- when the catalog content is unchanged: on a fresh checkout the version-controlled
526
- `catalog.yaml` exists but the local paths file does not, and `run`/`mint` need it to
527
- resolve inputs. In that case the catalog itself is left untouched (so a plain
528
- `ref test-cases fetch` is enough — no `--force` required) and only the paths file is written.
553
+ The paths sidecar is (re)generated whenever it is **missing**, even when the catalog
554
+ content is unchanged: on a machine with a cold cache the version-controlled
555
+ `catalog.yaml` exists but the sidecar does not, and `run`/`mint` need it to resolve
556
+ inputs. In that case the catalog itself is left untouched (so a plain
557
+ `ref test-cases fetch` is enough, no `--force` required) and only the sidecar is written.
529
558
 
530
559
  Parameters
531
560
  ----------
@@ -533,6 +562,8 @@ def save_datasets_to_yaml(
533
562
  The datasets to save
534
563
  path
535
564
  Path to write the YAML file
565
+ paths_file
566
+ Path to write the local-paths sidecar (see :attr:`TestCasePaths.catalog_paths`)
536
567
  force
537
568
  If True, always write the catalog even if unchanged
538
569
 
@@ -543,11 +574,10 @@ def save_datasets_to_yaml(
543
574
  (the paths sidecar may still have been regenerated).
544
575
  """
545
576
  new_hash = datasets.hash
546
- paths_file = _get_paths_file(path)
547
577
 
548
578
  if not force and get_catalog_hash(path) == new_hash:
549
- # Catalog content is unchanged. Still regenerate the gitignored paths sidecar if it
550
- # is missing (e.g. a fresh checkout) so run/mint can resolve inputs, but leave the
579
+ # Catalog content is unchanged. Still regenerate the paths sidecar if it is missing
580
+ # (e.g. a cold dataset cache) so run/mint can resolve inputs, but leave the
551
581
  # version-controlled catalog untouched to avoid spurious diffs.
552
582
  if paths_file.exists():
553
583
  logger.info(f"Catalog unchanged, skipping write: {path}")
@@ -2,11 +2,14 @@
2
2
 
3
3
  from unittest.mock import MagicMock, patch
4
4
 
5
+ import intake_esgf
5
6
  import pandas as pd
6
7
  import pytest
8
+ from intake_esgf.exceptions import NoSearchResults
7
9
 
8
10
  from climate_ref_core.esgf import CMIP6Request, ESGFRequest
9
11
  from climate_ref_core.esgf.base import _deduplicate_datasets
12
+ from climate_ref_core.exceptions import DatasetResolutionError
10
13
 
11
14
 
12
15
  class TestESGFRequestProtocol:
@@ -225,6 +228,31 @@ class TestIntakeESGFMixin:
225
228
 
226
229
  mock_cat.remove_ensembles.assert_called_once()
227
230
 
231
+ def test_fetch_datasets_enables_ceda_solr_index(self):
232
+ """
233
+ The CEDA Solr index must be enabled as a fallback while the ozone reference
234
+ C3S-GTO-ECV-9-0 is missing from the default ESGF2-US-1.5-Catalog index.
235
+ """
236
+ request = CMIP6Request(
237
+ slug="test",
238
+ facets={"source_id": "ACCESS-ESM1-5"},
239
+ )
240
+
241
+ mock_cat = MagicMock()
242
+ mock_cat.df = pd.DataFrame(
243
+ {
244
+ "key": ["ds1"],
245
+ "source_id": ["ACCESS-ESM1-5"],
246
+ }
247
+ )
248
+ mock_cat.to_path_dict.return_value = {"ds1": ["/path/to/file.nc"]}
249
+
250
+ intake_esgf.conf["solr_indices"]["esgf.ceda.ac.uk"] = False
251
+ with patch("climate_ref_core.esgf.base.ESGFCatalog", return_value=mock_cat):
252
+ request.fetch_datasets()
253
+
254
+ assert intake_esgf.conf["solr_indices"]["esgf.ceda.ac.uk"] is True
255
+
228
256
  def test_fetch_datasets_empty_result(self):
229
257
  """Test fetch_datasets raises error when no datasets found."""
230
258
  request = CMIP6Request(
@@ -255,6 +283,24 @@ class TestIntakeESGFMixin:
255
283
  with pytest.raises(ValueError, match="No datasets found"):
256
284
  request.fetch_datasets()
257
285
 
286
+ def test_fetch_datasets_no_search_results(self):
287
+ """
288
+ Regression: a dataset that has been de-indexed from ESGF makes the search
289
+ raise NoSearchResults, which must surface as the per-test-case
290
+ DatasetResolutionError instead of crashing the whole prefetch.
291
+ """
292
+ request = CMIP6Request(
293
+ slug="test",
294
+ facets={"source_id": "C3S-GTO-ECV-9-0"},
295
+ )
296
+
297
+ mock_cat = MagicMock()
298
+ mock_cat.search.side_effect = NoSearchResults()
299
+
300
+ with patch("climate_ref_core.esgf.base.ESGFCatalog", return_value=mock_cat):
301
+ with pytest.raises(DatasetResolutionError, match="no results"):
302
+ request.fetch_datasets()
303
+
258
304
  def test_fetch_datasets_converts_tuples_to_lists(self):
259
305
  """Test that tuple facet values are converted to lists for intake-esgf compatibility."""
260
306
  request = CMIP6Request(