climate-ref-core 0.16.2__tar.gz → 0.17.0__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 (99) hide show
  1. {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/PKG-INFO +4 -3
  2. {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/pyproject.toml +4 -2
  3. {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/src/climate_ref_core/cmip6_to_cmip7.py +3 -5
  4. climate_ref_core-0.17.0/src/climate_ref_core/cmor.py +118 -0
  5. {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/src/climate_ref_core/constraints.py +59 -0
  6. climate_ref_core-0.17.0/src/climate_ref_core/data.py +367 -0
  7. {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/src/climate_ref_core/dataset_registry.py +38 -35
  8. {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/src/climate_ref_core/diagnostics.py +69 -3
  9. {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/src/climate_ref_core/esgf/cmip7.py +1 -1
  10. {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/src/climate_ref_core/esgf/registry.py +29 -7
  11. climate_ref_core-0.17.0/src/climate_ref_core/esmvaltool_reference.py +237 -0
  12. {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/src/climate_ref_core/exceptions.py +0 -6
  13. {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/src/climate_ref_core/executor.py +47 -32
  14. {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/src/climate_ref_core/providers.py +137 -20
  15. climate_ref_core-0.17.0/src/climate_ref_core/pycmec/__init__.py +14 -0
  16. {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/src/climate_ref_core/pycmec/controlled_vocabulary.py +42 -6
  17. {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/src/climate_ref_core/regression/__init__.py +2 -8
  18. {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/src/climate_ref_core/regression/capture.py +3 -70
  19. {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/src/climate_ref_core/regression/gate.py +29 -58
  20. {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/src/climate_ref_core/regression/manifest.py +2 -37
  21. climate_ref_core-0.17.0/src/climate_ref_core/regression/store.py +564 -0
  22. climate_ref_core-0.17.0/src/climate_ref_core/resources.py +597 -0
  23. climate_ref_core-0.17.0/src/climate_ref_core/serialisation.py +328 -0
  24. {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/src/climate_ref_core/testing.py +3 -109
  25. climate_ref_core-0.17.0/tests/integration/test_resource_measurement.py +301 -0
  26. {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/tests/unit/esgf/test_registry.py +58 -1
  27. {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/tests/unit/pycmec/test_controlled_vocabulary.py +9 -0
  28. {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/tests/unit/regression/test_capture.py +3 -87
  29. {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/tests/unit/regression/test_gate.py +16 -25
  30. {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/tests/unit/regression/test_manifest.py +7 -28
  31. {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/tests/unit/regression/test_store.py +259 -285
  32. climate_ref_core-0.17.0/tests/unit/test_cmor.py +67 -0
  33. {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/tests/unit/test_constraints.py +85 -0
  34. climate_ref_core-0.17.0/tests/unit/test_data.py +164 -0
  35. {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/tests/unit/test_dataset_registry/test_dataset_registry.py +88 -4
  36. climate_ref_core-0.17.0/tests/unit/test_esmvaltool_reference.py +183 -0
  37. {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/tests/unit/test_executor.py +80 -2
  38. climate_ref_core-0.17.0/tests/unit/test_packaging.py +73 -0
  39. {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/tests/unit/test_providers.py +210 -23
  40. climate_ref_core-0.17.0/tests/unit/test_resources.py +288 -0
  41. climate_ref_core-0.17.0/tests/unit/test_serialisation.py +451 -0
  42. {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/tests/unit/test_testing.py +9 -219
  43. climate_ref_core-0.16.2/src/climate_ref_core/pycmec/__init__.py +0 -3
  44. climate_ref_core-0.16.2/src/climate_ref_core/regression/store.py +0 -759
  45. climate_ref_core-0.16.2/tests/unit/test_packaging.py +0 -29
  46. {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/.gitignore +0 -0
  47. {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/LICENCE +0 -0
  48. {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/NOTICE +0 -0
  49. {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/README.md +0 -0
  50. {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/src/climate_ref_core/__init__.py +0 -0
  51. {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/src/climate_ref_core/data/cmip6_cmip7_variable_map.json +0 -0
  52. {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/src/climate_ref_core/datasets.py +0 -0
  53. {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/src/climate_ref_core/env.py +0 -0
  54. {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/src/climate_ref_core/esgf/__init__.py +0 -0
  55. {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/src/climate_ref_core/esgf/base.py +0 -0
  56. {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/src/climate_ref_core/esgf/cmip6.py +0 -0
  57. {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/src/climate_ref_core/esgf/fetcher.py +0 -0
  58. {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/src/climate_ref_core/esgf/obs4mips.py +0 -0
  59. {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/src/climate_ref_core/logging.py +0 -0
  60. {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/src/climate_ref_core/metric_values/__init__.py +0 -0
  61. {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/src/climate_ref_core/metric_values/typing.py +0 -0
  62. {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/src/climate_ref_core/output_files.py +0 -0
  63. {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/src/climate_ref_core/paths.py +0 -0
  64. {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/src/climate_ref_core/py.typed +0 -0
  65. {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/src/climate_ref_core/pycmec/README.md +0 -0
  66. {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/src/climate_ref_core/pycmec/cv_cmip7_aft.yaml +0 -0
  67. {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/src/climate_ref_core/pycmec/metric.py +0 -0
  68. {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/src/climate_ref_core/pycmec/output.py +0 -0
  69. {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/src/climate_ref_core/regression/_quantise.py +0 -0
  70. {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/src/climate_ref_core/regression/compare.py +0 -0
  71. {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/src/climate_ref_core/source_types.py +0 -0
  72. {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/src/climate_ref_core/summary.py +0 -0
  73. {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/tests/unit/esgf/test_base.py +0 -0
  74. {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/tests/unit/esgf/test_cmip6.py +0 -0
  75. {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/tests/unit/esgf/test_cmip7.py +0 -0
  76. {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/tests/unit/esgf/test_fetcher.py +0 -0
  77. {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/tests/unit/esgf/test_obs4mips.py +0 -0
  78. {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/tests/unit/metric_values/test_typing.py +0 -0
  79. {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/tests/unit/pycmec/cmec_testdata/cmec_metric_sample.json +0 -0
  80. {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/tests/unit/pycmec/cmec_testdata/cmec_output_sample.json +0 -0
  81. {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/tests/unit/pycmec/cmec_testdata/cv_sample.yaml +0 -0
  82. {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/tests/unit/pycmec/cmec_testdata/test_metric_json_schema.yml +0 -0
  83. {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/tests/unit/pycmec/cmec_testdata/test_output_json_schema.yml +0 -0
  84. {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/tests/unit/pycmec/conftest.py +0 -0
  85. {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/tests/unit/pycmec/test_cmec_metric.py +0 -0
  86. {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/tests/unit/pycmec/test_cmec_output.py +0 -0
  87. {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/tests/unit/regression/test_compare.py +0 -0
  88. {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/tests/unit/regression/test_quantise.py +0 -0
  89. {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/tests/unit/test_cmip6_to_cmip7.py +0 -0
  90. {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/tests/unit/test_datasets/dataset_collection_hash.yml +0 -0
  91. {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/tests/unit/test_datasets/dataset_collection_obs4mips_hash.yml +0 -0
  92. {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/tests/unit/test_datasets/execution_dataset_hash.yml +0 -0
  93. {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/tests/unit/test_datasets.py +0 -0
  94. {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/tests/unit/test_diagnostics.py +0 -0
  95. {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/tests/unit/test_exceptions.py +0 -0
  96. {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/tests/unit/test_logging.py +0 -0
  97. {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/tests/unit/test_output_files.py +0 -0
  98. {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/tests/unit/test_paths.py +0 -0
  99. {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/tests/unit/test_summary.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: climate-ref-core
3
- Version: 0.16.2
3
+ Version: 0.17.0
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
@@ -26,12 +26,13 @@ Requires-Dist: loguru>=0.7.0
26
26
  Requires-Dist: numpy>=2.0.0
27
27
  Requires-Dist: pandas>=2.1.0
28
28
  Requires-Dist: pooch<2,>=1.8.0
29
+ Requires-Dist: psutil>=5.9.8
29
30
  Requires-Dist: pyarrow>=17.0.0
30
31
  Requires-Dist: pydantic>=2.10.6
31
32
  Requires-Dist: pyyaml>=6.0.2
32
33
  Requires-Dist: requests
33
34
  Requires-Dist: rich
34
- Requires-Dist: setuptools<81
35
+ Requires-Dist: setuptools<84
35
36
  Requires-Dist: typing-extensions
36
37
  Provides-Extra: aws
37
38
  Requires-Dist: boto3>=1.34; extra == 'aws'
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "climate-ref-core"
3
- version = "0.16.2"
3
+ version = "0.17.0"
4
4
  description = "Core library for the CMIP Rapid Evaluation Framework"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -37,8 +37,9 @@ dependencies = [
37
37
  "pooch>=1.8.0,<2",
38
38
  "environs>=11",
39
39
  "pyyaml>=6.0.2",
40
+ "psutil>=5.9.8",
40
41
  # Not used directly, but required to support some installations
41
- "setuptools<81",
42
+ "setuptools<84",
42
43
 
43
44
  # ESGF data fetching
44
45
  "intake-esgf>=2025.7.16",
@@ -65,6 +66,7 @@ aws = [
65
66
  [dependency-groups]
66
67
  dev = [
67
68
  "types-requests",
69
+ "types-psutil",
68
70
  ]
69
71
 
70
72
  [build-system]
@@ -543,14 +543,14 @@ def shift_time_axis_end(ds: xr.Dataset, end_year: int, end_month: int = 12) -> x
543
543
  if offset_months == 0:
544
544
  return ds
545
545
 
546
- calendar = last.calendar # type: ignore[attr-defined]
546
+ calendar = last.calendar
547
547
 
548
548
  def _days_in_month(year: int, month: int) -> int:
549
549
  # Last day of ``month`` = day before the first of the following month,
550
550
  # computed via cftime arithmetic so it respects the dataset's calendar
551
551
  # (noleap, 360_day, etc.).
552
552
  next_year, next_month = (year + 1, 1) if month == _MONTHS_PER_YEAR else (year, month + 1)
553
- first_of_next = cftime.datetime(next_year, next_month, 1, calendar=calendar) # type: ignore[call-arg]
553
+ first_of_next = cftime.datetime(next_year, next_month, 1, calendar=calendar)
554
554
  last_of_month = first_of_next - timedelta(days=1) # type: ignore[operator]
555
555
  return int(last_of_month.day) # type: ignore[attr-defined]
556
556
 
@@ -562,9 +562,7 @@ def shift_time_axis_end(ds: xr.Dataset, end_year: int, end_month: int = 12) -> x
562
562
  # can land a day-31 (or leap Feb-29) label on a shorter month, which cftime
563
563
  # would reject. Our monthly data is mid-month so this is normally a no-op.
564
564
  day = min(t.day, _days_in_month(year, month))
565
- return cftime.datetime( # type: ignore[call-arg]
566
- year, month, day, t.hour, t.minute, t.second, t.microsecond, calendar=calendar
567
- )
565
+ return cftime.datetime(year, month, day, t.hour, t.minute, t.second, t.microsecond, calendar=calendar)
568
566
 
569
567
  ds = ds.copy(deep=False)
570
568
  shifted = np.array([_shift(t) for t in time_values])
@@ -0,0 +1,118 @@
1
+ """
2
+ CMOR controlled vocabularies shared across source types.
3
+ """
4
+
5
+ # CMIP6 CMOR table -> CMIP6 ``frequency`` CV value.
6
+ #
7
+ # A CMOR table name is a realm prefix plus a frequency suffix, but the reduction is not a plain
8
+ # suffix strip: ``Oclim`` is a monthly climatology (``monC``), ``E1hrClimMon`` is ``1hrCM``, the
9
+ # zonal-mean tables (``AERmonZ``, ``EmonZ``, ``EdayZ``, ``E6hrZ``) keep the frequency of their
10
+ # non-zonal counterpart, and the ``Pt`` (point-sampled) tables map to distinct ``*Pt``
11
+ # frequencies. So the mapping is enumerated rather than derived.
12
+ _MIP_TABLE_FREQUENCIES: dict[str, str] = {
13
+ "3hr": "3hr",
14
+ "6hrLev": "6hr",
15
+ "6hrPlev": "6hr",
16
+ "6hrPlevPt": "6hrPt",
17
+ "AERday": "day",
18
+ "AERhr": "1hr",
19
+ "AERmon": "mon",
20
+ "AERmonZ": "mon",
21
+ "Amon": "mon",
22
+ "CF3hr": "3hr",
23
+ "CFday": "day",
24
+ "CFmon": "mon",
25
+ "CFsubhr": "subhrPt",
26
+ "day": "day",
27
+ "E1hr": "1hr",
28
+ "E1hrClimMon": "1hrCM",
29
+ "E3hr": "3hr",
30
+ "E3hrPt": "3hrPt",
31
+ "E6hrZ": "6hr",
32
+ "Eday": "day",
33
+ "EdayZ": "day",
34
+ "Efx": "fx",
35
+ "Emon": "mon",
36
+ "EmonZ": "mon",
37
+ "Esubhr": "subhrPt",
38
+ "Eyr": "yr",
39
+ "IfxAnt": "fx",
40
+ "IfxGre": "fx",
41
+ "ImonAnt": "mon",
42
+ "ImonGre": "mon",
43
+ "IyrAnt": "yr",
44
+ "IyrGre": "yr",
45
+ "LImon": "mon",
46
+ "Lmon": "mon",
47
+ "OIclim": "monC",
48
+ "OIday": "day",
49
+ "OImon": "mon",
50
+ "Oclim": "monC",
51
+ "Oday": "day",
52
+ "Odec": "dec",
53
+ "Ofx": "fx",
54
+ "Omon": "mon",
55
+ "Oyr": "yr",
56
+ "SIday": "day",
57
+ "SImon": "mon",
58
+ "fx": "fx",
59
+ }
60
+
61
+ # The CMIP6 ``frequency`` CV. Values already in this set pass through ``frequency_from_mip_table``
62
+ # untouched, which is what lets a layout that stores a frequency rather than a table
63
+ # use the same call site.
64
+ _FREQUENCIES: frozenset[str] = frozenset(
65
+ {
66
+ "1hr",
67
+ "1hrCM",
68
+ "1hrPt",
69
+ "3hr",
70
+ "3hrPt",
71
+ "6hr",
72
+ "6hrPt",
73
+ "day",
74
+ "dec",
75
+ "fx",
76
+ "mon",
77
+ "monC",
78
+ "monPt",
79
+ "subhrPt",
80
+ "yr",
81
+ "yrPt",
82
+ }
83
+ )
84
+
85
+
86
+ def frequency_from_mip_table(value: str) -> str:
87
+ """
88
+ Reduce a CMOR MIP table name to its CMIP6 ``frequency`` CV value.
89
+
90
+ Some datasets record ``frequency`` and not the MIP table.
91
+ This maps a table (e.g. ``Amon`` -> ``mon``) onto the same axis.
92
+
93
+ A value that is already a valid frequency is returned unchanged,
94
+ so a caller reading a layout that stores ``mon`` or ``day`` can use this without branching.
95
+ ``day`` and ``fx`` are both a table name and a frequency, and map to themselves either way.
96
+
97
+ Parameters
98
+ ----------
99
+ value
100
+ A CMOR MIP table name (``Amon``, ``Omon``, ``SIday``) or an existing frequency (``mon``).
101
+
102
+ Returns
103
+ -------
104
+ :
105
+ The corresponding CMIP6 frequency.
106
+
107
+ Raises
108
+ ------
109
+ ValueError
110
+ If the value is neither a known MIP table nor a known frequency. Failing loudly is
111
+ deliberate: silently defaulting would let a mis-parsed path collapse two datasets that
112
+ differ only by frequency onto one ``instance_id``.
113
+ """
114
+ if value in _MIP_TABLE_FREQUENCIES:
115
+ return _MIP_TABLE_FREQUENCIES[value]
116
+ if value in _FREQUENCIES:
117
+ return value
118
+ raise ValueError(f"Unknown MIP table or frequency: {value!r}")
@@ -2,6 +2,7 @@
2
2
  Dataset selection constraints
3
3
  """
4
4
 
5
+ import re
5
6
  from collections import defaultdict
6
7
  from collections.abc import Mapping
7
8
  from functools import total_ordering
@@ -632,3 +633,61 @@ class AddParentDataset:
632
633
  },
633
634
  }
634
635
  return cls(parent_facet_map=parent_facet_options[source_type])
636
+
637
+
638
+ _MEMBER_RE = re.compile(r"r(\d+)i(\d+)p(\d+)f(\d+)")
639
+
640
+
641
+ def _member_sort_key(member: str) -> tuple[int, tuple[int, ...] | str]:
642
+ """
643
+ Sort key that orders CMIP ensemble members numerically.
644
+
645
+ Members matching ``r<r>i<i>p<p>f<f>`` sort first, ordered by their numeric
646
+ (realization, initialization, physics, forcing) indices, so ``r1i1p1f1`` precedes
647
+ both ``r2i1p1f1`` and ``r10i1p1f1``. Members that do not match sort afterwards by
648
+ their raw string, keeping the ordering deterministic.
649
+ """
650
+ match = _MEMBER_RE.fullmatch(str(member))
651
+ if match:
652
+ return (0, tuple(int(value) for value in match.groups()))
653
+ return (1, str(member))
654
+
655
+
656
+ @frozen
657
+ class SelectFirstMember:
658
+ """
659
+ Keep a single ensemble member per group.
660
+
661
+ Within each group defined by ``group_by``, only the datasets belonging to a single
662
+ ensemble member are retained: the member with the lowest CMIP indices (``r1i1p1f1``
663
+ when present, see :func:`_member_sort_key`).
664
+
665
+ Some diagnostic scripts expect exactly one member per model and error when given
666
+ several (e.g. ``None or too many matching files``). The REF otherwise passes every
667
+ ingested member of a model into a single execution, so this constraint reduces each
668
+ model to one representative member.
669
+ """
670
+
671
+ member_facet: str = "member_id"
672
+ """The facet identifying the ensemble member (``member_id`` for CMIP6, ``variant_label`` for CMIP7)."""
673
+
674
+ group_by: tuple[str, ...] = field(converter=_to_tuple, default=("source_id",))
675
+ """The facets defining each model within which a single member is kept."""
676
+
677
+ def apply(self, group: pd.DataFrame, data_catalog: pd.DataFrame) -> pd.DataFrame:
678
+ """
679
+ Keep only the first ensemble member within each group.
680
+ """
681
+ if group.empty or self.member_facet not in group.columns:
682
+ return group
683
+
684
+ keep = pd.Series(False, index=group.index)
685
+ for _, subgroup in group.groupby(list(self.group_by)):
686
+ members = subgroup[self.member_facet].dropna().unique()
687
+ if len(members) == 0:
688
+ # No member information to select on; keep the subgroup unchanged.
689
+ keep.loc[subgroup.index] = True
690
+ continue
691
+ first = min(members, key=_member_sort_key)
692
+ keep.loc[subgroup.index[subgroup[self.member_facet] == first]] = True
693
+ return group[keep]
@@ -0,0 +1,367 @@
1
+ """
2
+ Resolution of the data files that are distributed with the REF
3
+
4
+ Several files the REF depends on are shipped inside the wheels:
5
+ the controlled vocabulary, the grey list, and the dataset registry manifests.
6
+ Some of them also have a newer copy available over the network.
7
+
8
+ `LayeredResource` resolves one logical file across three layers:
9
+ an operator override, a cached download, and the copy shipped in the package.
10
+ The packaged copy is always present and always readable,
11
+ so resolution never requires the network or a writable filesystem.
12
+
13
+ Packaged files are read through `importlib.resources` rather than being converted
14
+ to a filesystem path, so an installation that is not unpacked on disk still works.
15
+ """
16
+
17
+ import enum
18
+ import importlib.resources
19
+ import os
20
+ import pathlib
21
+ from collections.abc import Generator
22
+ from contextlib import ExitStack, contextmanager
23
+ from typing import Protocol
24
+
25
+ import platformdirs
26
+ from attrs import field, frozen
27
+
28
+ from climate_ref_core.exceptions import RefException
29
+
30
+ _RESOURCE_ERRORS = (ModuleNotFoundError, FileNotFoundError, OSError)
31
+
32
+
33
+ class DataResourceError(RefException):
34
+ """Raised when a data file cannot be resolved or read."""
35
+
36
+
37
+ def resolve_cache_dir(cache_name: str) -> pathlib.Path:
38
+ """
39
+ Resolve a cache directory used to hold downloaded data
40
+
41
+ If the ``REF_DATASET_CACHE_DIR`` environment variable is set, use that as the root.
42
+ Otherwise, fall back to the OS cache under ``climate_ref``.
43
+
44
+ Parameters
45
+ ----------
46
+ cache_name
47
+ Subdirectory name within the cache root.
48
+
49
+ Returns
50
+ -------
51
+ The resolved cache directory path.
52
+ """
53
+ if env_cache_dir := os.environ.get("REF_DATASET_CACHE_DIR"):
54
+ cache_dir = pathlib.Path(os.path.expandvars(env_cache_dir))
55
+ try:
56
+ cache_dir = cache_dir.expanduser()
57
+ except RuntimeError:
58
+ # An unresolvable ``~user`` is left as-is rather than failing the caller.
59
+ pass
60
+ else:
61
+ cache_dir = platformdirs.user_cache_path("climate_ref")
62
+
63
+ return cache_dir / cache_name
64
+
65
+
66
+ class Resource(Protocol):
67
+ """
68
+ Something a data file can be read from
69
+
70
+ This is the contract `LayeredResource` resolves to,
71
+ and the narrowest one its consumers need.
72
+ """
73
+
74
+ def read_text(self, encoding: str = "utf-8") -> str:
75
+ """Read the file as text."""
76
+
77
+ def describe(self) -> str:
78
+ """Describe where the file is read from, for a log or error message."""
79
+
80
+
81
+ @frozen
82
+ class PackagedResource:
83
+ """
84
+ A data file shipped inside an installed package
85
+
86
+ The file is always accessed through `importlib.resources`.
87
+ It is never converted to a filesystem path unless a caller explicitly asks for one
88
+ via `as_path`, which extracts it to a temporary location when needed.
89
+ """
90
+
91
+ package: str
92
+ """Import name of the package that contains the file, for example ``climate_ref_core.pycmec``."""
93
+
94
+ resource: str
95
+ """Name of the file within that package, for example ``cv_cmip7_aft.yaml``."""
96
+
97
+ def _traversable(self) -> importlib.resources.abc.Traversable:
98
+ """
99
+ Locate the file within the installed package.
100
+
101
+ Existence is checked here rather than at each accessor,
102
+ because `importlib.resources.as_file` happily hands back a path that does not exist.
103
+
104
+ Returns
105
+ -------
106
+ :
107
+ The located file.
108
+
109
+ Raises
110
+ ------
111
+ DataResourceError
112
+ If the file is not present in the installed package.
113
+ """
114
+ missing = DataResourceError(
115
+ f"Could not read {self} from the installed package. "
116
+ "This usually means the package was built without its data files."
117
+ )
118
+ try:
119
+ located = importlib.resources.files(self.package) / self.resource
120
+ if not located.is_file():
121
+ raise missing
122
+ except _RESOURCE_ERRORS as exc:
123
+ raise missing from exc
124
+ return located
125
+
126
+ def exists(self) -> bool:
127
+ """
128
+ Check whether the file is present in the installed package
129
+
130
+ Returns
131
+ -------
132
+ :
133
+ True if the file can be read.
134
+ """
135
+ try:
136
+ self._traversable()
137
+ except DataResourceError:
138
+ return False
139
+ return True
140
+
141
+ def read_text(self, encoding: str = "utf-8") -> str:
142
+ """
143
+ Read the file as text
144
+
145
+ Parameters
146
+ ----------
147
+ encoding
148
+ Text encoding of the file.
149
+
150
+ Returns
151
+ -------
152
+ :
153
+ The contents of the file.
154
+ """
155
+ traversable = self._traversable()
156
+ try:
157
+ return traversable.read_text(encoding=encoding)
158
+ except _RESOURCE_ERRORS as exc:
159
+ raise DataResourceError(f"Could not read {self} from the installed package.") from exc
160
+
161
+ @contextmanager
162
+ def as_path(self) -> Generator[pathlib.Path]:
163
+ """
164
+ Expose the file as a filesystem path for the duration of the context
165
+
166
+ Prefer `read_text`.
167
+ Use this only for third-party APIs that insist on a path.
168
+ The path may point at a temporary file that is removed on exit,
169
+ so it must not be retained beyond the context.
170
+
171
+ Yields
172
+ ------
173
+ :
174
+ A path that exists for the duration of the context.
175
+ """
176
+ traversable = self._traversable()
177
+ with ExitStack() as stack:
178
+ try:
179
+ path = stack.enter_context(importlib.resources.as_file(traversable))
180
+ except _RESOURCE_ERRORS as exc:
181
+ raise DataResourceError(f"Could not materialise {self} as a filesystem path.") from exc
182
+ # Only the lookup is guarded. An exception raised in the caller's `with`
183
+ # block propagates back in through this yield and must not be relabelled.
184
+ yield path
185
+
186
+ def describe(self) -> str:
187
+ """
188
+ Describe where the file is read from
189
+
190
+ Returns
191
+ -------
192
+ :
193
+ The package and file name.
194
+ """
195
+ return str(self)
196
+
197
+ def __str__(self) -> str:
198
+ return f"{self.package}/{self.resource}"
199
+
200
+
201
+ @frozen
202
+ class FileResource:
203
+ """
204
+ A data file at a plain filesystem path
205
+ """
206
+
207
+ path: pathlib.Path
208
+
209
+ def exists(self) -> bool:
210
+ """
211
+ Check whether the file exists
212
+
213
+ Returns
214
+ -------
215
+ :
216
+ True if the path points at a readable file.
217
+ """
218
+ return self.path.is_file()
219
+
220
+ def read_text(self, encoding: str = "utf-8") -> str:
221
+ """
222
+ Read the file as text
223
+
224
+ Parameters
225
+ ----------
226
+ encoding
227
+ Text encoding of the file.
228
+
229
+ Returns
230
+ -------
231
+ :
232
+ The contents of the file.
233
+ """
234
+ try:
235
+ return self.path.read_text(encoding=encoding)
236
+ except OSError as exc:
237
+ raise DataResourceError(f"Could not read {self}.") from exc
238
+
239
+ @contextmanager
240
+ def as_path(self) -> Generator[pathlib.Path]:
241
+ """
242
+ Expose the file as a filesystem path
243
+
244
+ Yields
245
+ ------
246
+ :
247
+ The path itself, which already exists on disk.
248
+ """
249
+ yield self.path
250
+
251
+ def describe(self) -> str:
252
+ """
253
+ Describe where the file is read from
254
+
255
+ Returns
256
+ -------
257
+ :
258
+ The path.
259
+ """
260
+ return str(self)
261
+
262
+ def __str__(self) -> str:
263
+ return str(self.path)
264
+
265
+
266
+ class ResourceOrigin(enum.Enum):
267
+ """
268
+ Which layer a resolved data file came from
269
+ """
270
+
271
+ override = "override"
272
+ """An explicit path supplied by the operator via configuration or an environment variable."""
273
+
274
+ cache = "cache"
275
+ """A copy downloaded into the local cache."""
276
+
277
+ package = "package"
278
+ """The copy shipped inside the installed package."""
279
+
280
+
281
+ @frozen
282
+ class LayeredResource:
283
+ """
284
+ A data file resolved across an override, a cache, and the packaged copy
285
+
286
+ Resolution happens on every access rather than being fixed at construction,
287
+ so a cache that is populated after the object is built is picked up
288
+ without the object being rebuilt.
289
+ """
290
+
291
+ packaged: PackagedResource
292
+ """The copy shipped in the package. This is the floor, and is always available."""
293
+
294
+ override: pathlib.Path | None = field(default=None)
295
+ """An explicit path supplied by the operator. Takes precedence over everything else."""
296
+
297
+ cache: pathlib.Path | None = field(default=None)
298
+ """A local cache location. Used only when the file is actually present there."""
299
+
300
+ def resolve(self) -> tuple[ResourceOrigin, Resource]:
301
+ """
302
+ Determine which layer supplies the file
303
+
304
+ Returns
305
+ -------
306
+ :
307
+ The origin, and the resource that supplies the file.
308
+
309
+ Raises
310
+ ------
311
+ DataResourceError
312
+ If an override was supplied but does not point at a readable file.
313
+ """
314
+ if self.override is not None:
315
+ if not self.override.is_file():
316
+ raise DataResourceError(
317
+ f"The configured file {self.override} does not exist. "
318
+ "Point it at an existing file, or remove the setting to use the copy "
319
+ f"shipped with the REF ({self.packaged})."
320
+ )
321
+ return ResourceOrigin.override, FileResource(self.override)
322
+
323
+ if self.cache is not None and self.cache.is_file():
324
+ return ResourceOrigin.cache, FileResource(self.cache)
325
+
326
+ return ResourceOrigin.package, self.packaged
327
+
328
+ @property
329
+ def origin(self) -> ResourceOrigin:
330
+ """
331
+ The layer that currently supplies the file
332
+ """
333
+ return self.resolve()[0]
334
+
335
+ def describe(self) -> str:
336
+ """
337
+ Describe where the file is being read from
338
+
339
+ This never raises, so it is safe to use when building a log message
340
+ for a resolution that has itself failed.
341
+
342
+ Returns
343
+ -------
344
+ :
345
+ A short description suitable for a log message.
346
+ """
347
+ try:
348
+ origin, source = self.resolve()
349
+ except DataResourceError:
350
+ return f"{self.override} (missing)"
351
+ return f"{source} ({origin.value})"
352
+
353
+ def read_text(self, encoding: str = "utf-8") -> str:
354
+ """
355
+ Read the file as text from whichever layer supplies it
356
+
357
+ Parameters
358
+ ----------
359
+ encoding
360
+ Text encoding of the file.
361
+
362
+ Returns
363
+ -------
364
+ :
365
+ The contents of the file.
366
+ """
367
+ return self.resolve()[1].read_text(encoding=encoding)