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.
- {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/.gitignore +6 -2
- {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/PKG-INFO +1 -1
- {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/pyproject.toml +1 -1
- {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/src/climate_ref_core/dataset_registry.py +101 -2
- {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/src/climate_ref_core/esgf/__init__.py +2 -1
- {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/src/climate_ref_core/esgf/base.py +23 -1
- {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/src/climate_ref_core/esgf/cmip7.py +3 -4
- {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/src/climate_ref_core/pycmec/cv_cmip7_aft.yaml +5 -0
- {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/src/climate_ref_core/regression/_quantise.py +4 -4
- {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/src/climate_ref_core/regression/capture.py +11 -1
- {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/src/climate_ref_core/regression/compare.py +2 -2
- {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/src/climate_ref_core/source_types.py +2 -0
- {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/src/climate_ref_core/testing.py +61 -31
- {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/tests/unit/esgf/test_base.py +46 -0
- {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/tests/unit/esgf/test_cmip7.py +12 -3
- {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/tests/unit/regression/test_capture.py +3 -0
- {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/tests/unit/regression/test_compare.py +4 -3
- {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/tests/unit/regression/test_quantise.py +5 -2
- {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/tests/unit/test_dataset_registry/test_dataset_registry.py +76 -1
- {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/tests/unit/test_testing.py +84 -35
- {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/LICENCE +0 -0
- {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/NOTICE +0 -0
- {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/README.md +0 -0
- {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/src/climate_ref_core/__init__.py +0 -0
- {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/src/climate_ref_core/cmip6_to_cmip7.py +0 -0
- {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/src/climate_ref_core/constraints.py +0 -0
- {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/src/climate_ref_core/data/cmip6_cmip7_variable_map.json +0 -0
- {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/src/climate_ref_core/datasets.py +0 -0
- {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/src/climate_ref_core/diagnostics.py +0 -0
- {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/src/climate_ref_core/env.py +0 -0
- {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/src/climate_ref_core/esgf/cmip6.py +0 -0
- {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/src/climate_ref_core/esgf/fetcher.py +0 -0
- {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/src/climate_ref_core/esgf/obs4mips.py +0 -0
- {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/src/climate_ref_core/esgf/registry.py +0 -0
- {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/src/climate_ref_core/exceptions.py +0 -0
- {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/src/climate_ref_core/executor.py +0 -0
- {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/src/climate_ref_core/logging.py +0 -0
- {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/src/climate_ref_core/metric_values/__init__.py +0 -0
- {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/src/climate_ref_core/metric_values/typing.py +0 -0
- {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/src/climate_ref_core/output_files.py +0 -0
- {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/src/climate_ref_core/paths.py +0 -0
- {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/src/climate_ref_core/providers.py +0 -0
- {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/src/climate_ref_core/py.typed +0 -0
- {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/src/climate_ref_core/pycmec/README.md +0 -0
- {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/src/climate_ref_core/pycmec/__init__.py +0 -0
- {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/src/climate_ref_core/pycmec/controlled_vocabulary.py +0 -0
- {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/src/climate_ref_core/pycmec/metric.py +0 -0
- {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/src/climate_ref_core/pycmec/output.py +0 -0
- {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/src/climate_ref_core/regression/__init__.py +0 -0
- {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/src/climate_ref_core/regression/gate.py +0 -0
- {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/src/climate_ref_core/regression/manifest.py +0 -0
- {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/src/climate_ref_core/regression/store.py +0 -0
- {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/src/climate_ref_core/summary.py +0 -0
- {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/tests/unit/esgf/test_cmip6.py +0 -0
- {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/tests/unit/esgf/test_fetcher.py +0 -0
- {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/tests/unit/esgf/test_obs4mips.py +0 -0
- {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/tests/unit/esgf/test_registry.py +0 -0
- {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/tests/unit/metric_values/test_typing.py +0 -0
- {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/tests/unit/pycmec/cmec_testdata/cmec_metric_sample.json +0 -0
- {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/tests/unit/pycmec/cmec_testdata/cmec_output_sample.json +0 -0
- {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/tests/unit/pycmec/cmec_testdata/cv_sample.yaml +0 -0
- {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/tests/unit/pycmec/cmec_testdata/test_metric_json_schema.yml +0 -0
- {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/tests/unit/pycmec/cmec_testdata/test_output_json_schema.yml +0 -0
- {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/tests/unit/pycmec/conftest.py +0 -0
- {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/tests/unit/pycmec/test_cmec_metric.py +0 -0
- {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/tests/unit/pycmec/test_cmec_output.py +0 -0
- {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/tests/unit/pycmec/test_controlled_vocabulary.py +0 -0
- {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/tests/unit/regression/test_gate.py +0 -0
- {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/tests/unit/regression/test_manifest.py +0 -0
- {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/tests/unit/regression/test_store.py +0 -0
- {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/tests/unit/test_cmip6_to_cmip7.py +0 -0
- {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/tests/unit/test_constraints.py +0 -0
- {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/tests/unit/test_datasets/dataset_collection_hash.yml +0 -0
- {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/tests/unit/test_datasets/dataset_collection_obs4mips_hash.yml +0 -0
- {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/tests/unit/test_datasets/execution_dataset_hash.yml +0 -0
- {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/tests/unit/test_datasets.py +0 -0
- {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/tests/unit/test_diagnostics.py +0 -0
- {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/tests/unit/test_exceptions.py +0 -0
- {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/tests/unit/test_executor.py +0 -0
- {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/tests/unit/test_logging.py +0 -0
- {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/tests/unit/test_output_files.py +0 -0
- {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/tests/unit/test_packaging.py +0 -0
- {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/tests/unit/test_paths.py +0 -0
- {climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/tests/unit/test_providers.py +0 -0
- {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.
|
|
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
|
{climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/src/climate_ref_core/dataset_registry.py
RENAMED
|
@@ -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,
|
|
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] =
|
|
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
|
-
|
|
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
|
-
#
|
|
34
|
-
|
|
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
|
|
{climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/src/climate_ref_core/pycmec/cv_cmip7_aft.yaml
RENAMED
|
@@ -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: ""
|
{climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/src/climate_ref_core/regression/_quantise.py
RENAMED
|
@@ -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:
|
|
12
|
-
(``rtol=1e-
|
|
13
|
-
so the rounding error stays
|
|
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
|
|
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
|
|
{climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/src/climate_ref_core/regression/capture.py
RENAMED
|
@@ -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
|
-
|
|
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):
|
{climate_ref_core-0.16.0 → climate_ref_core-0.16.2}/src/climate_ref_core/regression/compare.py
RENAMED
|
@@ -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-
|
|
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-
|
|
30
|
+
rtol: float = 1e-5
|
|
31
31
|
atol: float = 1e-8
|
|
32
32
|
|
|
33
33
|
|
|
@@ -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(
|
|
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
|
-
"""
|
|
208
|
-
|
|
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
|
|
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
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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``
|
|
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
|
|
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
|
|
515
|
-
|
|
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
|
|
525
|
-
|
|
526
|
-
`catalog.yaml` exists but the
|
|
527
|
-
|
|
528
|
-
`ref test-cases fetch` is enough
|
|
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
|
|
550
|
-
#
|
|
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(
|