climate-ref-core 0.18.2__tar.gz → 0.18.3__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 (103) hide show
  1. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/PKG-INFO +1 -1
  2. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/pyproject.toml +1 -1
  3. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/src/climate_ref_core/cmip6_to_cmip7.py +19 -2
  4. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/src/climate_ref_core/esgf/__init__.py +5 -1
  5. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/src/climate_ref_core/esgf/base.py +121 -2
  6. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/src/climate_ref_core/esgf/cmip7.py +80 -33
  7. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/src/climate_ref_core/esgf/registry.py +78 -9
  8. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/src/climate_ref_core/testing.py +54 -7
  9. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/tests/unit/esgf/test_base.py +61 -1
  10. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/tests/unit/esgf/test_cmip7.py +109 -4
  11. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/tests/unit/esgf/test_registry.py +80 -5
  12. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/tests/unit/test_cmip6_to_cmip7.py +27 -0
  13. climate_ref_core-0.18.3/tests/unit/test_datasets/dataset_collection_hash.yml +2 -0
  14. climate_ref_core-0.18.3/tests/unit/test_datasets/dataset_collection_obs4mips_hash.yml +2 -0
  15. climate_ref_core-0.18.3/tests/unit/test_datasets/execution_dataset_hash.yml +2 -0
  16. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/tests/unit/test_testing.py +102 -0
  17. climate_ref_core-0.18.2/tests/unit/test_datasets/dataset_collection_hash.yml +0 -2
  18. climate_ref_core-0.18.2/tests/unit/test_datasets/dataset_collection_obs4mips_hash.yml +0 -2
  19. climate_ref_core-0.18.2/tests/unit/test_datasets/execution_dataset_hash.yml +0 -2
  20. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/.gitignore +0 -0
  21. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/LICENCE +0 -0
  22. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/NOTICE +0 -0
  23. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/README.md +0 -0
  24. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/src/climate_ref_core/__init__.py +0 -0
  25. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/src/climate_ref_core/cmor.py +0 -0
  26. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/src/climate_ref_core/constraints.py +0 -0
  27. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/src/climate_ref_core/data/cmip6_cmip7_variable_map.json +0 -0
  28. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/src/climate_ref_core/data.py +0 -0
  29. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/src/climate_ref_core/dataset_registry.py +0 -0
  30. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/src/climate_ref_core/datasets.py +0 -0
  31. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/src/climate_ref_core/diagnostics.py +0 -0
  32. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/src/climate_ref_core/env.py +0 -0
  33. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/src/climate_ref_core/esgf/cmip6.py +0 -0
  34. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/src/climate_ref_core/esgf/fetcher.py +0 -0
  35. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/src/climate_ref_core/esgf/obs4mips.py +0 -0
  36. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/src/climate_ref_core/esmvaltool_reference.py +0 -0
  37. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/src/climate_ref_core/exceptions.py +0 -0
  38. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/src/climate_ref_core/executor.py +0 -0
  39. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/src/climate_ref_core/logging.py +0 -0
  40. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/src/climate_ref_core/metric_values/__init__.py +0 -0
  41. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/src/climate_ref_core/metric_values/typing.py +0 -0
  42. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/src/climate_ref_core/output_files.py +0 -0
  43. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/src/climate_ref_core/paths.py +0 -0
  44. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/src/climate_ref_core/providers.py +0 -0
  45. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/src/climate_ref_core/py.typed +0 -0
  46. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/src/climate_ref_core/pycmec/README.md +0 -0
  47. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/src/climate_ref_core/pycmec/__init__.py +0 -0
  48. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/src/climate_ref_core/pycmec/controlled_vocabulary.py +0 -0
  49. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/src/climate_ref_core/pycmec/cv_cmip7_aft.yaml +0 -0
  50. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/src/climate_ref_core/pycmec/metric.py +0 -0
  51. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/src/climate_ref_core/pycmec/output.py +0 -0
  52. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/src/climate_ref_core/reference_data.py +0 -0
  53. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/src/climate_ref_core/regression/__init__.py +0 -0
  54. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/src/climate_ref_core/regression/_quantise.py +0 -0
  55. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/src/climate_ref_core/regression/capture.py +0 -0
  56. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/src/climate_ref_core/regression/compare.py +0 -0
  57. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/src/climate_ref_core/regression/gate.py +0 -0
  58. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/src/climate_ref_core/regression/manifest.py +0 -0
  59. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/src/climate_ref_core/regression/report_store.py +0 -0
  60. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/src/climate_ref_core/regression/store.py +0 -0
  61. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/src/climate_ref_core/resources.py +0 -0
  62. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/src/climate_ref_core/serialisation.py +0 -0
  63. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/src/climate_ref_core/source_types.py +0 -0
  64. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/src/climate_ref_core/summary.py +0 -0
  65. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/tests/integration/test_resource_measurement.py +0 -0
  66. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/tests/unit/esgf/test_cmip6.py +0 -0
  67. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/tests/unit/esgf/test_fetcher.py +0 -0
  68. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/tests/unit/esgf/test_obs4mips.py +0 -0
  69. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/tests/unit/metric_values/test_typing.py +0 -0
  70. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/tests/unit/pycmec/cmec_testdata/cmec_metric_sample.json +0 -0
  71. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/tests/unit/pycmec/cmec_testdata/cmec_output_sample.json +0 -0
  72. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/tests/unit/pycmec/cmec_testdata/cv_sample.yaml +0 -0
  73. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/tests/unit/pycmec/cmec_testdata/test_metric_json_schema.yml +0 -0
  74. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/tests/unit/pycmec/cmec_testdata/test_output_json_schema.yml +0 -0
  75. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/tests/unit/pycmec/conftest.py +0 -0
  76. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/tests/unit/pycmec/test_cmec_metric.py +0 -0
  77. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/tests/unit/pycmec/test_cmec_output.py +0 -0
  78. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/tests/unit/pycmec/test_controlled_vocabulary.py +0 -0
  79. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/tests/unit/regression/test_capture.py +0 -0
  80. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/tests/unit/regression/test_compare.py +0 -0
  81. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/tests/unit/regression/test_gate.py +0 -0
  82. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/tests/unit/regression/test_manifest.py +0 -0
  83. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/tests/unit/regression/test_quantise.py +0 -0
  84. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/tests/unit/regression/test_report_store.py +0 -0
  85. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/tests/unit/regression/test_store.py +0 -0
  86. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/tests/unit/test_cmor.py +0 -0
  87. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/tests/unit/test_constraints.py +0 -0
  88. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/tests/unit/test_data.py +0 -0
  89. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/tests/unit/test_dataset_registry/test_dataset_registry.py +0 -0
  90. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/tests/unit/test_datasets.py +0 -0
  91. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/tests/unit/test_diagnostics.py +0 -0
  92. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/tests/unit/test_esmvaltool_reference.py +0 -0
  93. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/tests/unit/test_exceptions.py +0 -0
  94. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/tests/unit/test_executor.py +0 -0
  95. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/tests/unit/test_logging.py +0 -0
  96. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/tests/unit/test_output_files.py +0 -0
  97. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/tests/unit/test_packaging.py +0 -0
  98. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/tests/unit/test_paths.py +0 -0
  99. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/tests/unit/test_providers.py +0 -0
  100. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/tests/unit/test_reference_data.py +0 -0
  101. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/tests/unit/test_resources.py +0 -0
  102. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/tests/unit/test_serialisation.py +0 -0
  103. {climate_ref_core-0.18.2 → climate_ref_core-0.18.3}/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.2
3
+ Version: 0.18.3
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.18.2"
3
+ version = "0.18.3"
4
4
  description = "Core library for the CMIP Rapid Evaluation Framework"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -403,6 +403,7 @@ def make_tracking_id(file_id: str) -> str:
403
403
 
404
404
  def convert_cmip6_to_cmip7_attrs(
405
405
  cmip6_attrs: dict[str, Any],
406
+ file_id: str | None = None,
406
407
  ) -> dict[str, Any]:
407
408
  """
408
409
  Convert CMIP6 global attributes to CMIP7 format.
@@ -413,6 +414,9 @@ def convert_cmip6_to_cmip7_attrs(
413
414
  ----------
414
415
  cmip6_attrs
415
416
  Dictionary of CMIP6 global attributes. Must contain ``table_id``.
417
+ file_id
418
+ What identifies the file being written, used to derive its ``tracking_id`` (see
419
+ :func:`make_tracking_id`).
416
420
 
417
421
  Returns
418
422
  -------
@@ -490,7 +494,7 @@ def convert_cmip6_to_cmip7_attrs(
490
494
  attrs["cmip6_compound_name"] = dreq_entry.cmip6_compound_name
491
495
 
492
496
  # Add tracking_id with CMIP7 handle prefix
493
- attrs["tracking_id"] = f"hdl:21.14107/{uuid.uuid4()}"
497
+ attrs["tracking_id"] = make_tracking_id(file_id) if file_id else f"hdl:21.14107/{uuid.uuid4()}"
494
498
 
495
499
  # Add creation_date in ISO format
496
500
  attrs["creation_date"] = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
@@ -581,6 +585,15 @@ def repeat_final_year_to(ds: xr.Dataset, end_year: int, end_month: int = 12) ->
581
585
  raise ValueError(f"The series must end in December to repeat whole years, ends {last}")
582
586
 
583
587
  bounds_name = ds["time"].attrs.get("bounds")
588
+ if bounds_name and bounds_name in ds.variables:
589
+ # The repeated blocks below carry their restamped bounds as a NumPy array of cftime
590
+ # objects. If the source bounds arrived lazily -- a file opened with ``chunks=`` gets
591
+ # Dask-backed ones -- the concat at the end mixes the two, and Dask refuses to chunk
592
+ # the NumPy side because it cannot size object dtype. The array is one row per
593
+ # timestep, so materialise it here rather than make every caller open the file a
594
+ # particular way. Assigned to a shallow copy, leaving the caller's dataset lazy.
595
+ ds = ds.copy()
596
+ ds[bounds_name] = ds[bounds_name].compute()
584
597
 
585
598
  def _restamp(t: cftime.datetime, shift: int) -> cftime.datetime:
586
599
  year = t.year + shift
@@ -621,6 +634,7 @@ def convert_cmip6_dataset(
621
634
  ds: xr.Dataset,
622
635
  inplace: bool = False,
623
636
  extend_historical_to: tuple[int, int] | None = None,
637
+ file_id: str | None = None,
624
638
  ) -> xr.Dataset:
625
639
  """
626
640
  Convert a CMIP6 xarray Dataset to CMIP7 format in-memory.
@@ -644,6 +658,9 @@ def convert_cmip6_dataset(
644
658
  via :func:`repeat_final_year_to`, fabricating CMIP7 coverage for years without real
645
659
  data. Defaults to ``None`` (time axis untouched), so existing conversions are
646
660
  byte-identical.
661
+ file_id
662
+ What identifies the file being written, so that converting it again gives it the
663
+ same ``tracking_id``. See :func:`convert_cmip6_to_cmip7_attrs`.
647
664
 
648
665
  Returns
649
666
  -------
@@ -673,7 +690,7 @@ def convert_cmip6_dataset(
673
690
  ds = ds.rename({variable_id: dreq_entry.variable_id})
674
691
 
675
692
  # Convert global attributes
676
- ds.attrs = convert_cmip6_to_cmip7_attrs(ds.attrs)
693
+ ds.attrs = convert_cmip6_to_cmip7_attrs(ds.attrs, file_id=file_id)
677
694
 
678
695
  return ds
679
696
 
@@ -5,7 +5,11 @@ 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, enable_ceda_solr_index
8
+ from climate_ref_core.esgf.base import (
9
+ ESGFRequest,
10
+ IntakeESGFMixin,
11
+ enable_ceda_solr_index,
12
+ )
9
13
  from climate_ref_core.esgf.cmip6 import CMIP6Request
10
14
  from climate_ref_core.esgf.cmip7 import CMIP7Request
11
15
  from climate_ref_core.esgf.fetcher import ESGFFetcher
@@ -5,12 +5,15 @@ This module provides the infrastructure for fetching datasets from ESGF
5
5
  using the intake-esgf package.
6
6
  """
7
7
 
8
- from typing import Any, Protocol, runtime_checkable
8
+ import copy
9
+ from collections.abc import Mapping, Sequence
10
+ from typing import Any, Protocol, Self, cast, runtime_checkable
9
11
 
10
12
  import intake_esgf
11
13
  import pandas as pd
12
14
  from intake_esgf import ESGFCatalog
13
15
  from intake_esgf.exceptions import NoSearchResults
16
+ from loguru import logger
14
17
 
15
18
  from climate_ref_core.exceptions import DatasetResolutionError
16
19
 
@@ -59,6 +62,69 @@ class ESGFRequest(Protocol):
59
62
  """
60
63
  ...
61
64
 
65
+ def pin_to_datasets(self, datasets: Sequence[Mapping[str, Any]]) -> "ESGFRequest":
66
+ """
67
+ Return a copy of this request that limits its resolution to the given datasets.
68
+
69
+ Parameters
70
+ ----------
71
+ datasets
72
+ The catalog records of the datasets recorded for this request's source type.
73
+
74
+ Returns
75
+ -------
76
+ :
77
+ The pinned request, or this request unchanged when the records do not carry
78
+ what the pin needs. Pinning is all-or-nothing, so a pinned request never
79
+ silently resolves a subset of what was recorded.
80
+ """
81
+ ...
82
+
83
+
84
+ def facet_pins_from_datasets(
85
+ datasets: Sequence[Mapping[str, Any]],
86
+ fields: Mapping[str, str],
87
+ slug: str,
88
+ ) -> tuple[dict[str, str], ...] | None:
89
+ """
90
+ Read one facet set per recorded dataset, for pins that a search cannot ask for by id.
91
+
92
+ Parameters
93
+ ----------
94
+ datasets
95
+ The catalog records to pin to.
96
+ fields
97
+ The record field each search facet is spelled as, keyed by facet name.
98
+ slug
99
+ Slug of the request being pinned, for the warning when a record falls short.
100
+
101
+ Returns
102
+ -------
103
+ :
104
+ The facet sets, deduplicated because a dataset is recorded once per file it
105
+ holds, or ``None`` if any record is missing one of the facets.
106
+ """
107
+ pins = []
108
+ for dataset in datasets:
109
+ pin = {
110
+ facet: str(dataset[field])
111
+ for facet, field in fields.items()
112
+ if dataset.get(field) not in (None, "")
113
+ }
114
+ if len(pin) != len(fields):
115
+ logger.warning(
116
+ "Recorded datasets are missing facets needed to identify them; "
117
+ f"resolving request {slug} from its declared facets instead"
118
+ )
119
+ return None
120
+ pins.append(pin)
121
+
122
+ if not pins:
123
+ return None
124
+
125
+ unique = dict.fromkeys(tuple(sorted(pin.items())) for pin in pins)
126
+ return tuple(dict(facets) for facets in unique)
127
+
62
128
 
63
129
  def _deduplicate_datasets(datasets: pd.DataFrame) -> pd.DataFrame:
64
130
  """
@@ -102,15 +168,67 @@ class IntakeESGFMixin:
102
168
  - facets: dict[str, str | tuple[str, ...]]
103
169
  - remove_ensembles: bool
104
170
  - time_span: tuple[str, str] | None
171
+
172
+ A request can also be pinned to the datasets an existing test case catalog records,
173
+ see :meth:`pin_to_datasets`.
105
174
  """
106
175
 
176
+ slug: str
107
177
  facets: dict[str, str | tuple[str, ...]]
108
178
  remove_ensembles: bool
109
179
  time_span: tuple[str, str] | None
110
180
 
181
+ pinned_instance_ids: tuple[str, ...] | None = None
182
+ """Ids of the datasets this request is pinned to."""
183
+
184
+ def pin_to_datasets(self, datasets: Sequence[Mapping[str, Any]]) -> Self:
185
+ """
186
+ Return a copy of this request that only resolves the given datasets.
187
+
188
+ The ids a catalog records are the ones ESGF publishes the datasets under, so they
189
+ are used in the search instead of the facets if possible.
190
+
191
+ Parameters
192
+ ----------
193
+ datasets
194
+ The catalog records of the datasets recorded for this request's source type.
195
+
196
+ Returns
197
+ -------
198
+ :
199
+ The pinned request, or this request unchanged if any record has no id.
200
+ Pinning is all-or-nothing, so a pinned request never silently resolves a
201
+ subset of what was recorded.
202
+ """
203
+ instance_ids = [dataset.get("instance_id") for dataset in datasets]
204
+ if not instance_ids or None in instance_ids:
205
+ logger.warning(
206
+ f"Recorded datasets have no instance_id; "
207
+ f"resolving request {self.slug} from its declared facets instead"
208
+ )
209
+ return self
210
+
211
+ pinned = copy.copy(self)
212
+ # A dataset is recorded once per file it holds
213
+ pinned.pinned_instance_ids = tuple(dict.fromkeys(cast(list[str], instance_ids)))
214
+ return pinned
215
+
111
216
  def fetch_datasets(self) -> pd.DataFrame:
112
217
  """Fetch dataset metadata from ESGF."""
113
218
  facets: dict[str, Any] = dict(self.facets)
219
+
220
+ if self.pinned_instance_ids:
221
+ # Ask for the pinned datasets by id as the declared facets may now
222
+ # resolve to something else. The project is kept because it selects how the
223
+ # index is read rather than which datasets match.
224
+ logger.info(f"Resolving {len(self.pinned_instance_ids)} pinned datasets for {self.slug}")
225
+ facets = {
226
+ **({"project": facets["project"]} if "project" in facets else {}),
227
+ "instance_id": list(self.pinned_instance_ids),
228
+ # A pin holds even once a newer version of the dataset has been published
229
+ "latest": [True, False],
230
+ }
231
+
114
232
  if self.time_span:
115
233
  facets["file_start"] = self.time_span[0]
116
234
  facets["file_end"] = self.time_span[1]
@@ -133,7 +251,8 @@ class IntakeESGFMixin:
133
251
  msg = f"ESGF search returned no results for facets: {facets}"
134
252
  raise DatasetResolutionError(msg) from None
135
253
 
136
- if self.remove_ensembles:
254
+ if self.remove_ensembles and not self.pinned_instance_ids:
255
+ # Pinned ids already name one ensemble member each
137
256
  cat.remove_ensembles()
138
257
 
139
258
  path_dict = cat.to_path_dict(prefer_streaming=False, minimal_keys=False, quiet=True)
@@ -9,6 +9,7 @@ from __future__ import annotations
9
9
 
10
10
  import re
11
11
  import uuid
12
+ from collections.abc import Mapping, Sequence
12
13
  from pathlib import Path
13
14
  from typing import Any, ClassVar, NamedTuple
14
15
 
@@ -53,32 +54,61 @@ _MANDATORY_CONVERTED_ATTRS = (
53
54
  _LOSSY_DECIMAL_DIGITS = 3
54
55
 
55
56
 
57
+ def _write_encoding(ds: xr.Dataset) -> dict[str, dict[str, Any]]:
58
+ """
59
+ Per-variable NetCDF encoding for a converted dataset.
60
+
61
+ Everything is compressed. Values are also rounded to :data:`_LOSSY_DECIMAL_DIGITS`,
62
+ except where that would quantise the data away: gpp and o3 magnitudes sit below the
63
+ rounding floor, and ozone additionally needs lossless hybrid-coordinate coefficients.
64
+ """
65
+ lossless_dataset = ds.attrs.get("variable_id") == "o3"
66
+
67
+ encoding: dict[str, dict[str, Any]] = {}
68
+ for var in ds.data_vars:
69
+ var_encoding: dict[str, Any] = {"zlib": True, "complevel": 5}
70
+ if not lossless_dataset and str(var) != "gpp":
71
+ var_encoding["least_significant_digit"] = _LOSSY_DECIMAL_DIGITS
72
+ encoding[str(var)] = var_encoding
73
+ return encoding
74
+
75
+
76
+ def _quantised_variables(ds: xr.Dataset) -> list[str]:
77
+ """Name the variables stored with the lossy decimal quantisation the writer applies."""
78
+ return [
79
+ str(name)
80
+ for name in ds.data_vars
81
+ if ds[name].encoding.get("least_significant_digit") == _LOSSY_DECIMAL_DIGITS
82
+ ]
83
+
84
+
56
85
  def _invalid_conversion_reason(path: Path) -> str | None:
57
- """Return why a cached conversion cannot satisfy the complete CMIP7 parser."""
86
+ """Return why a cached conversion cannot be reused, or ``None`` if it can."""
58
87
  try:
59
88
  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()
89
+ return _conversion_defect(ds)
80
90
  except (OSError, ValueError) as e:
81
91
  return f"cannot read metadata: {e}"
92
+
93
+
94
+ def _conversion_defect(ds: xr.Dataset) -> str | None:
95
+ """Return what is wrong with an opened cached conversion, or ``None`` if nothing is."""
96
+ missing = [name for name in _MANDATORY_CONVERTED_ATTRS if not ds.attrs.get(name)]
97
+ if missing:
98
+ return f"missing mandatory attributes: {', '.join(missing)}"
99
+
100
+ variable_id = str(ds.attrs["variable_id"])
101
+ if variable_id not in ds.variables:
102
+ return f"missing data variable: {variable_id}"
103
+ if variable_id == "o3" and (quantised := _quantised_variables(ds)):
104
+ return f"ozone dataset uses destructive decimal quantisation: {', '.join(quantised)}"
105
+
106
+ if "time" in ds.variables and ds.sizes.get("time", 0):
107
+ time = ds["time"]
108
+ if not time.attrs.get("units"):
109
+ return "time coordinate has no units"
110
+ # Reading both endpoints catches a truncated file.
111
+ time.isel(time=[0, -1]).load()
82
112
  return None
83
113
 
84
114
 
@@ -229,7 +259,7 @@ def _convert_file_to_cmip7(
229
259
  return output_file
230
260
  logger.info(f"Rebuilding invalid CMIP7 conversion ({invalid_reason}): {output_file}")
231
261
 
232
- ds_cmip7 = convert_cmip6_dataset(source_ds)
262
+ ds_cmip7 = convert_cmip6_dataset(source_ds, file_id=str(output_file.relative_to(cache_dir)))
233
263
 
234
264
  # Ensure version and sanitized activity_id are in the file attributes
235
265
  # so that parse_cmip7_file can extract them for instance_id construction
@@ -246,17 +276,7 @@ def _convert_file_to_cmip7(
246
276
  _load_time_coordinates(ds_cmip7)
247
277
  suppress_bounds_coordinates(ds_cmip7)
248
278
 
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)
279
+ ds_cmip7.to_netcdf(temporary_file, encoding=_write_encoding(ds_cmip7))
260
280
  temporary_file.replace(output_file)
261
281
  except PermissionError:
262
282
  # If we can't write but file exists (race condition or permission issue), use it
@@ -348,6 +368,24 @@ class CMIP7Request:
348
368
  # Create corresponding CMIP6 facets
349
369
  self._cmip6_facets = self._convert_to_cmip6_facets(facets)
350
370
 
371
+ def pin_to_datasets(self, datasets: Sequence[Mapping[str, Any]]) -> CMIP7Request:
372
+ """
373
+ Return this request unchanged: CMIP7 requests are not pinned yet.
374
+
375
+ CMIP7 data cannot be fetched from ESGF at the moment.
376
+
377
+ Parameters
378
+ ----------
379
+ datasets
380
+ The catalog records of the datasets recorded for this request's source type.
381
+
382
+ Returns
383
+ -------
384
+ :
385
+ This request, unchanged.
386
+ """
387
+ return self
388
+
351
389
  def _convert_to_cmip6_facets(self, cmip7_facets: dict[str, Any]) -> dict[str, Any]:
352
390
  """Convert CMIP7 facets to CMIP6 facets for fetching."""
353
391
  cmip6_facets = {}
@@ -464,6 +502,15 @@ class CMIP7Request:
464
502
  if converted_files:
465
503
  cmip7_row["files"] = converted_files
466
504
  converted_rows.append(cmip7_row)
505
+ elif paths:
506
+ # Every file failed to convert above, so this dataset drops out of the
507
+ # result. Say so here, while the reason is still next to it in the log:
508
+ # downstream all that can be seen is a dataset that never arrived.
509
+ dataset = row_dict.get("key") or cmip7_row.get("variable_id", "<unknown dataset>")
510
+ logger.error(
511
+ f"None of the {len(paths)} file(s) for {dataset} could be converted, "
512
+ f"so it is missing from the data fetched for {self.slug}"
513
+ )
467
514
 
468
515
  if not converted_rows:
469
516
  logger.warning(f"No files converted for request: {self.slug}")
@@ -7,15 +7,17 @@ This module provides request classes for fetching datasets from pooch registries
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
+ import copy
10
11
  import re
11
- from collections.abc import Callable, Collection, Mapping
12
- from typing import Any
12
+ from collections.abc import Callable, Collection, Mapping, Sequence
13
+ from typing import Any, ClassVar
13
14
 
14
15
  import pandas as pd
15
16
  from loguru import logger
16
17
 
17
18
  from climate_ref_core.dataset_registry import dataset_registry_manager
18
19
  from climate_ref_core.datasets import select_latest_version
20
+ from climate_ref_core.esgf.base import facet_pins_from_datasets
19
21
 
20
22
  type FacetValue = str | int | Collection[str | int]
21
23
  """
@@ -40,6 +42,10 @@ def _parse_obs4ref_key(key: str) -> dict[str, Any]:
40
42
  Where filename is:
41
43
  {variable_id}_{frequency}_{source_id}_{inst_short}_{grid_label}_{time_range}.nc
42
44
 
45
+ ``inst_short`` is the short name the file is published under (e.g. ``PCMDI``) and is
46
+ reported as ``institution_short``; ``institution_id`` is the institution directory of
47
+ the key, which is the dataset's own. Some keys carry a placeholder (``NR``) there.
48
+
43
49
  Parameters
44
50
  ----------
45
51
  key
@@ -101,7 +107,10 @@ def _parse_pmp_climatology_key(key: str) -> dict[str, Any]:
101
107
  PMP_obs4MIPsClims/{variable_id}/{grid_label}/{version}/{filename}
102
108
 
103
109
  Where filename is:
104
- {variable_id}_mon_{source_id}_{institution_id}_{grid_label}_{time_range}_AC_{version}_{resolution}.nc
110
+ {variable_id}_mon_{source_id}_{institution_short}_{grid_label}_{time_range}_AC_{version}_{resolution}.nc
111
+
112
+ ``institution_short`` is the short name the file is published under (e.g. ``PCMDI``), which
113
+ is not the dataset's ``institution_id`` (``ECMWF`` for that example).
105
114
 
106
115
  Parameters
107
116
  ----------
@@ -121,13 +130,13 @@ def _parse_pmp_climatology_key(key: str) -> dict[str, Any]:
121
130
 
122
131
  _, _variable_id_dir, _grid_label, _version, filename = parts
123
132
 
124
- # Parse filename: {var}_mon_{source_id}_{inst_id}_{grid}_{time}_AC_{ver}_{res}.nc
125
- # source_id and institution_id may both contain hyphens (e.g. "GPCP-3-3", "NASA-GISS");
133
+ # Parse filename: {var}_mon_{source_id}_{inst_short}_{grid}_{time}_AC_{ver}_{res}.nc
134
+ # source_id and inst_short may both contain hyphens (e.g. "GPCP-3-3", "NASA-GISS");
126
135
  # the literal "_" separators keep tokenisation unambiguous.
127
136
  filename_pattern = re.compile(
128
137
  r"^(?P<variable_id>[a-z]+)_mon_"
129
138
  r"(?P<source_id>[A-Za-z0-9-]+)_"
130
- r"(?P<institution_id>[A-Za-z0-9-]+)_"
139
+ r"(?P<institution_short>[A-Za-z0-9-]+)_"
131
140
  r"(?P<grid_label>[a-z]+)_"
132
141
  r"(?P<time_range>\d+-\d+)_AC_"
133
142
  r"(?P<version>v\d+)_"
@@ -227,6 +236,34 @@ class RegistryRequest:
227
236
  ```
228
237
  """
229
238
 
239
+ pinned_facet_fields: ClassVar[dict[str, str]] = {
240
+ "source_id": "source_id",
241
+ "variable_id": "variable_id",
242
+ "version": "version",
243
+ }
244
+ """
245
+ Facets a pin matches registry keys on, and the recorded facet each is spelled as.
246
+
247
+ A registry key carries no ``instance_id`` to ask for, so a pin is matched against the
248
+ facets parsed out of the key. A catalog, though, records the facets read from the
249
+ *file*, and the two disagree wherever a registry describes a dataset differently from
250
+ how the data describes itself. Only facets that agree can be pinned on, which rules
251
+ out three that otherwise look like obvious candidates:
252
+
253
+ * ``grid_label``: PMP climatologies are published under ``gr`` because they are
254
+ regridded, while the files themselves still report the ``gn`` they were derived from.
255
+ * ``institution_id``: PMP keys do not carry one (only the short name the file is
256
+ published under, e.g. ``PCMDI`` for data whose institution is ``ECMWF``), and some
257
+ obs4REF keys carry a placeholder (``NR``) in its place.
258
+ * ``frequency``: the PMP key parser does not report it at all.
259
+
260
+ The three that remain agree across both registries, and identify a dataset within
261
+ one: no two keys in either registry share them.
262
+ """
263
+
264
+ pinned_facets: tuple[dict[str, str], ...] | None = None
265
+ """Facet sets to keep, one per pinned dataset."""
266
+
230
267
  def __init__( # noqa: PLR0913
231
268
  self,
232
269
  slug: str,
@@ -244,6 +281,33 @@ class RegistryRequest:
244
281
  self.time_span = time_span
245
282
  self.key_parser = key_parser
246
283
 
284
+ def pin_to_datasets(self, datasets: Sequence[Mapping[str, Any]]) -> RegistryRequest:
285
+ """
286
+ Return a copy of this request that only resolves the given datasets.
287
+
288
+ Registry data drifts like published data does: a newly added version of a dataset
289
+ would otherwise be picked up by :func:`select_latest_version`. A pinned request
290
+ keeps the recorded version instead.
291
+
292
+ Parameters
293
+ ----------
294
+ datasets
295
+ The catalog records of the datasets recorded for this request's source type.
296
+
297
+ Returns
298
+ -------
299
+ :
300
+ The pinned request, or this request unchanged if any record is missing one of
301
+ the facets a pin needs.
302
+ """
303
+ pins = facet_pins_from_datasets(datasets, self.pinned_facet_fields, self.slug)
304
+ if pins is None:
305
+ return self
306
+
307
+ pinned = copy.copy(self)
308
+ pinned.pinned_facets = pins
309
+ return pinned
310
+
247
311
  def __repr__(self) -> str:
248
312
  return (
249
313
  f"RegistryRequest(slug={self.slug!r}, registry_name={self.registry_name!r}, "
@@ -292,8 +356,12 @@ class RegistryRequest:
292
356
  if not metadata:
293
357
  continue
294
358
 
295
- # Check if it matches the requested facets
296
- if not _matches_facets(metadata, self.facets):
359
+ if self.pinned_facets is not None:
360
+ # The pinned datasets, not the declared facets, decide what is fetched.
361
+ # Matching before the fetch below keeps superseded versions from downloading.
362
+ if not any(_matches_facets(metadata, pin) for pin in self.pinned_facets):
363
+ continue
364
+ elif not _matches_facets(metadata, self.facets):
297
365
  continue
298
366
 
299
367
  # Fetch the file (downloads if not cached)
@@ -321,7 +389,8 @@ class RegistryRequest:
321
389
  # Filter to only the latest version for each unique dataset.
322
390
  # Datasets are identified by source_id, variable_id, and grid_label.
323
391
  # Versions are compared numerically (so v10 > v2) via select_latest_version.
324
- if "version" in result.columns:
392
+ # A pinned request skips this: its pins already name the version of each dataset.
393
+ if self.pinned_facets is None and "version" in result.columns:
325
394
  group_by_cols = [
326
395
  col for col in ("source_id", "variable_id", "grid_label") if col in result.columns
327
396
  ]
@@ -393,6 +393,50 @@ def load_datasets_from_yaml(path: Path, paths_file: Path) -> ExecutionDatasetCol
393
393
  return ExecutionDatasetCollection(collections)
394
394
 
395
395
 
396
+ def load_catalog_datasets(path: Path) -> dict[str, tuple[dict[str, Any], ...]]:
397
+ """
398
+ Read the datasets recorded in a catalog YAML file.
399
+
400
+ These are the exact datasets a test case's regression baseline was built from, so a
401
+ request can be pinned to them and fetch that data again instead of re-resolving the
402
+ facets it declares (see
403
+ :meth:`~climate_ref_core.esgf.PinnableRequest.pin_to_datasets`).
404
+
405
+ Parameters
406
+ ----------
407
+ path
408
+ Path to the catalog YAML file
409
+
410
+ Returns
411
+ -------
412
+ :
413
+ The records of each source type, keyed by :class:`SourceDatasetType` name (e.g.
414
+ ``"CMIP6"``), which is how a request names its source type. A dataset spanning
415
+ several files is returned once. Source types without any recorded datasets are
416
+ left out.
417
+ """
418
+ with open(path) as f:
419
+ data = yaml.safe_load(f) or {}
420
+
421
+ datasets: dict[str, tuple[dict[str, Any], ...]] = {}
422
+ for source_type_str, source_data in data.items():
423
+ if source_type_str == "_metadata":
424
+ continue
425
+
426
+ slug_column = source_data.get("slug_column", "instance_id")
427
+ # A dataset spanning several files is recorded once per file
428
+ recorded: dict[str, dict[str, Any]] = {}
429
+ for dataset in source_data.get("datasets", []):
430
+ slug = dataset.get(slug_column)
431
+ if slug:
432
+ recorded.setdefault(slug, dataset)
433
+
434
+ if recorded:
435
+ datasets[SourceDatasetType(source_type_str).name] = tuple(recorded.values())
436
+
437
+ return datasets
438
+
439
+
396
440
  def validate_catalog_paths(path: Path, paths_file: Path) -> ExecutionDatasetCollection:
397
441
  """Load a catalog and check that every row resolves to a local file.
398
442
 
@@ -614,8 +658,9 @@ def save_datasets_to_yaml(
614
658
  one per file. Paths are keyed by `{instance_id}::{filename}` to support
615
659
  multiple files per dataset.
616
660
 
617
- By default, the catalog is only written if the content has changed
618
- (detected via hash comparison). Use `force=True` to always write.
661
+ By default, the catalog is only written if its content would change -- which covers
662
+ the datasets selected and the metadata recorded for them. Use `force=True` to always
663
+ write.
619
664
 
620
665
  The paths sidecar is regenerated on every save, even when the catalog content is
621
666
  unchanged. Local cache contents can change independently of the version-controlled
@@ -638,11 +683,14 @@ def save_datasets_to_yaml(
638
683
  True if the catalog was (re)written, False if the catalog was left unchanged
639
684
  (the paths sidecar may still have been regenerated).
640
685
  """
641
- new_hash = datasets.hash
642
-
643
686
  data, paths_map = _serialise_datasets(datasets)
687
+ content = yaml.dump(data, default_flow_style=False, sort_keys=False)
644
688
 
645
- if not force and get_catalog_hash(path) == new_hash:
689
+ # Compared as content rather than by the stored hash: that hash identifies which
690
+ # datasets were selected, deliberately ignoring their metadata, so going by it alone
691
+ # would leave a catalog whose datasets are unchanged but whose recorded metadata is
692
+ # stale. The hash is part of the content, so a different selection still writes.
693
+ if not force and path.exists() and path.read_text() == content:
646
694
  # Keep the tracked catalog byte-identical, but always refresh its machine-local
647
695
  # paths. An existing sidecar may be partial or point at an old cache location.
648
696
  _write_paths_file(paths_file, paths_map)
@@ -650,8 +698,7 @@ def save_datasets_to_yaml(
650
698
  return False
651
699
 
652
700
  path.parent.mkdir(parents=True, exist_ok=True)
653
- with open(path, "w") as f:
654
- yaml.dump(data, f, default_flow_style=False, sort_keys=False)
701
+ path.write_text(content)
655
702
  _write_paths_file(paths_file, paths_map)
656
703
  logger.info(f"Saved catalog to {path} (paths: {paths_file})")
657
704
  return True