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.
- {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/PKG-INFO +4 -3
- {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/pyproject.toml +4 -2
- {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/src/climate_ref_core/cmip6_to_cmip7.py +3 -5
- climate_ref_core-0.17.0/src/climate_ref_core/cmor.py +118 -0
- {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/src/climate_ref_core/constraints.py +59 -0
- climate_ref_core-0.17.0/src/climate_ref_core/data.py +367 -0
- {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/src/climate_ref_core/dataset_registry.py +38 -35
- {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/src/climate_ref_core/diagnostics.py +69 -3
- {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/src/climate_ref_core/esgf/cmip7.py +1 -1
- {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/src/climate_ref_core/esgf/registry.py +29 -7
- climate_ref_core-0.17.0/src/climate_ref_core/esmvaltool_reference.py +237 -0
- {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/src/climate_ref_core/exceptions.py +0 -6
- {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/src/climate_ref_core/executor.py +47 -32
- {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/src/climate_ref_core/providers.py +137 -20
- climate_ref_core-0.17.0/src/climate_ref_core/pycmec/__init__.py +14 -0
- {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/src/climate_ref_core/pycmec/controlled_vocabulary.py +42 -6
- {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/src/climate_ref_core/regression/__init__.py +2 -8
- {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/src/climate_ref_core/regression/capture.py +3 -70
- {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/src/climate_ref_core/regression/gate.py +29 -58
- {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/src/climate_ref_core/regression/manifest.py +2 -37
- climate_ref_core-0.17.0/src/climate_ref_core/regression/store.py +564 -0
- climate_ref_core-0.17.0/src/climate_ref_core/resources.py +597 -0
- climate_ref_core-0.17.0/src/climate_ref_core/serialisation.py +328 -0
- {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/src/climate_ref_core/testing.py +3 -109
- climate_ref_core-0.17.0/tests/integration/test_resource_measurement.py +301 -0
- {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/tests/unit/esgf/test_registry.py +58 -1
- {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/tests/unit/pycmec/test_controlled_vocabulary.py +9 -0
- {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/tests/unit/regression/test_capture.py +3 -87
- {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/tests/unit/regression/test_gate.py +16 -25
- {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/tests/unit/regression/test_manifest.py +7 -28
- {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/tests/unit/regression/test_store.py +259 -285
- climate_ref_core-0.17.0/tests/unit/test_cmor.py +67 -0
- {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/tests/unit/test_constraints.py +85 -0
- climate_ref_core-0.17.0/tests/unit/test_data.py +164 -0
- {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/tests/unit/test_dataset_registry/test_dataset_registry.py +88 -4
- climate_ref_core-0.17.0/tests/unit/test_esmvaltool_reference.py +183 -0
- {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/tests/unit/test_executor.py +80 -2
- climate_ref_core-0.17.0/tests/unit/test_packaging.py +73 -0
- {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/tests/unit/test_providers.py +210 -23
- climate_ref_core-0.17.0/tests/unit/test_resources.py +288 -0
- climate_ref_core-0.17.0/tests/unit/test_serialisation.py +451 -0
- {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/tests/unit/test_testing.py +9 -219
- climate_ref_core-0.16.2/src/climate_ref_core/pycmec/__init__.py +0 -3
- climate_ref_core-0.16.2/src/climate_ref_core/regression/store.py +0 -759
- climate_ref_core-0.16.2/tests/unit/test_packaging.py +0 -29
- {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/.gitignore +0 -0
- {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/LICENCE +0 -0
- {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/NOTICE +0 -0
- {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/README.md +0 -0
- {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/src/climate_ref_core/__init__.py +0 -0
- {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/src/climate_ref_core/data/cmip6_cmip7_variable_map.json +0 -0
- {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/src/climate_ref_core/datasets.py +0 -0
- {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/src/climate_ref_core/env.py +0 -0
- {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/src/climate_ref_core/esgf/__init__.py +0 -0
- {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/src/climate_ref_core/esgf/base.py +0 -0
- {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/src/climate_ref_core/esgf/cmip6.py +0 -0
- {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/src/climate_ref_core/esgf/fetcher.py +0 -0
- {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/src/climate_ref_core/esgf/obs4mips.py +0 -0
- {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/src/climate_ref_core/logging.py +0 -0
- {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/src/climate_ref_core/metric_values/__init__.py +0 -0
- {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/src/climate_ref_core/metric_values/typing.py +0 -0
- {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/src/climate_ref_core/output_files.py +0 -0
- {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/src/climate_ref_core/paths.py +0 -0
- {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/src/climate_ref_core/py.typed +0 -0
- {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/src/climate_ref_core/pycmec/README.md +0 -0
- {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/src/climate_ref_core/pycmec/cv_cmip7_aft.yaml +0 -0
- {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/src/climate_ref_core/pycmec/metric.py +0 -0
- {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/src/climate_ref_core/pycmec/output.py +0 -0
- {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/src/climate_ref_core/regression/_quantise.py +0 -0
- {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/src/climate_ref_core/regression/compare.py +0 -0
- {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/src/climate_ref_core/source_types.py +0 -0
- {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/src/climate_ref_core/summary.py +0 -0
- {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/tests/unit/esgf/test_base.py +0 -0
- {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/tests/unit/esgf/test_cmip6.py +0 -0
- {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/tests/unit/esgf/test_cmip7.py +0 -0
- {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/tests/unit/esgf/test_fetcher.py +0 -0
- {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/tests/unit/esgf/test_obs4mips.py +0 -0
- {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/tests/unit/metric_values/test_typing.py +0 -0
- {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/tests/unit/pycmec/cmec_testdata/cmec_metric_sample.json +0 -0
- {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/tests/unit/pycmec/cmec_testdata/cmec_output_sample.json +0 -0
- {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/tests/unit/pycmec/cmec_testdata/cv_sample.yaml +0 -0
- {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/tests/unit/pycmec/cmec_testdata/test_metric_json_schema.yml +0 -0
- {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/tests/unit/pycmec/cmec_testdata/test_output_json_schema.yml +0 -0
- {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/tests/unit/pycmec/conftest.py +0 -0
- {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/tests/unit/pycmec/test_cmec_metric.py +0 -0
- {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/tests/unit/pycmec/test_cmec_output.py +0 -0
- {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/tests/unit/regression/test_compare.py +0 -0
- {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/tests/unit/regression/test_quantise.py +0 -0
- {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/tests/unit/test_cmip6_to_cmip7.py +0 -0
- {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/tests/unit/test_datasets/dataset_collection_hash.yml +0 -0
- {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/tests/unit/test_datasets/dataset_collection_obs4mips_hash.yml +0 -0
- {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/tests/unit/test_datasets/execution_dataset_hash.yml +0 -0
- {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/tests/unit/test_datasets.py +0 -0
- {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/tests/unit/test_diagnostics.py +0 -0
- {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/tests/unit/test_exceptions.py +0 -0
- {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/tests/unit/test_logging.py +0 -0
- {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/tests/unit/test_output_files.py +0 -0
- {climate_ref_core-0.16.2 → climate_ref_core-0.17.0}/tests/unit/test_paths.py +0 -0
- {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.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: climate-ref-core
|
|
3
|
-
Version: 0.
|
|
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<
|
|
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.
|
|
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<
|
|
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
|
|
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)
|
|
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(
|
|
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)
|