climate-ref-core 0.17.2__tar.gz → 0.18.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.17.2 → climate_ref_core-0.18.0}/PKG-INFO +1 -1
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/pyproject.toml +1 -1
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/cmip6_to_cmip7.py +99 -63
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/diagnostics.py +8 -0
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/esgf/cmip7.py +89 -18
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/reference_data.py +16 -12
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/regression/__init__.py +8 -0
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/regression/manifest.py +4 -0
- climate_ref_core-0.18.0/src/climate_ref_core/regression/report_store.py +223 -0
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/regression/store.py +127 -78
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/summary.py +11 -2
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/testing.py +2 -2
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/unit/esgf/test_cmip7.py +149 -1
- climate_ref_core-0.18.0/tests/unit/regression/test_report_store.py +227 -0
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/unit/test_cmip6_to_cmip7.py +119 -58
- climate_ref_core-0.18.0/tests/unit/test_datasets/dataset_collection_obs4mips_hash.yml +2 -0
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/unit/test_datasets.py +2 -2
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/unit/test_reference_data.py +36 -8
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/unit/test_summary.py +5 -5
- climate_ref_core-0.17.2/tests/unit/test_datasets/dataset_collection_obs4mips_hash.yml +0 -2
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/.gitignore +0 -0
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/LICENCE +0 -0
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/NOTICE +0 -0
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/README.md +0 -0
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/__init__.py +0 -0
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/cmor.py +0 -0
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/constraints.py +0 -0
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/data/cmip6_cmip7_variable_map.json +0 -0
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/data.py +0 -0
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/dataset_registry.py +0 -0
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/datasets.py +0 -0
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/env.py +0 -0
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/esgf/__init__.py +0 -0
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/esgf/base.py +0 -0
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/esgf/cmip6.py +0 -0
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/esgf/fetcher.py +0 -0
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/esgf/obs4mips.py +0 -0
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/esgf/registry.py +0 -0
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/esmvaltool_reference.py +0 -0
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/exceptions.py +0 -0
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/executor.py +0 -0
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/logging.py +0 -0
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/metric_values/__init__.py +0 -0
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/metric_values/typing.py +0 -0
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/output_files.py +0 -0
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/paths.py +0 -0
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/providers.py +0 -0
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/py.typed +0 -0
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/pycmec/README.md +0 -0
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/pycmec/__init__.py +0 -0
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/pycmec/controlled_vocabulary.py +0 -0
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/pycmec/cv_cmip7_aft.yaml +0 -0
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/pycmec/metric.py +0 -0
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/pycmec/output.py +0 -0
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/regression/_quantise.py +0 -0
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/regression/capture.py +0 -0
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/regression/compare.py +0 -0
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/regression/gate.py +0 -0
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/resources.py +0 -0
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/serialisation.py +0 -0
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/source_types.py +0 -0
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/integration/test_resource_measurement.py +0 -0
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/unit/esgf/test_base.py +0 -0
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/unit/esgf/test_cmip6.py +0 -0
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/unit/esgf/test_fetcher.py +0 -0
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/unit/esgf/test_obs4mips.py +0 -0
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/unit/esgf/test_registry.py +0 -0
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/unit/metric_values/test_typing.py +0 -0
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/unit/pycmec/cmec_testdata/cmec_metric_sample.json +0 -0
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/unit/pycmec/cmec_testdata/cmec_output_sample.json +0 -0
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/unit/pycmec/cmec_testdata/cv_sample.yaml +0 -0
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/unit/pycmec/cmec_testdata/test_metric_json_schema.yml +0 -0
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/unit/pycmec/cmec_testdata/test_output_json_schema.yml +0 -0
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/unit/pycmec/conftest.py +0 -0
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/unit/pycmec/test_cmec_metric.py +0 -0
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/unit/pycmec/test_cmec_output.py +0 -0
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/unit/pycmec/test_controlled_vocabulary.py +0 -0
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/unit/regression/test_capture.py +0 -0
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/unit/regression/test_compare.py +0 -0
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/unit/regression/test_gate.py +0 -0
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/unit/regression/test_manifest.py +0 -0
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/unit/regression/test_quantise.py +0 -0
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/unit/regression/test_store.py +0 -0
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/unit/test_cmor.py +0 -0
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/unit/test_constraints.py +0 -0
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/unit/test_data.py +0 -0
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/unit/test_dataset_registry/test_dataset_registry.py +0 -0
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/unit/test_datasets/dataset_collection_hash.yml +0 -0
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/unit/test_datasets/execution_dataset_hash.yml +0 -0
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/unit/test_diagnostics.py +0 -0
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/unit/test_esmvaltool_reference.py +0 -0
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/unit/test_exceptions.py +0 -0
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/unit/test_executor.py +0 -0
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/unit/test_logging.py +0 -0
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/unit/test_output_files.py +0 -0
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/unit/test_packaging.py +0 -0
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/unit/test_paths.py +0 -0
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/unit/test_providers.py +0 -0
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/unit/test_resources.py +0 -0
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/unit/test_serialisation.py +0 -0
- {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/unit/test_testing.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: climate-ref-core
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.18.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
|
|
@@ -12,6 +12,8 @@ Regenerate with:
|
|
|
12
12
|
The variable_id and table_id attributes are used to map the CMIP6 variable to its CMIP7 equivalent,
|
|
13
13
|
and to determine the appropriate branding suffix and realm for the CMIP7 format.
|
|
14
14
|
Grid information is not converted so the grid_labels may not be valid for CMIP7.
|
|
15
|
+
|
|
16
|
+
This is a temporary work around until we have better CMIP7 coverage.
|
|
15
17
|
"""
|
|
16
18
|
|
|
17
19
|
from __future__ import annotations
|
|
@@ -20,19 +22,17 @@ import json
|
|
|
20
22
|
import re
|
|
21
23
|
import uuid
|
|
22
24
|
from dataclasses import dataclass
|
|
23
|
-
from datetime import UTC, datetime
|
|
25
|
+
from datetime import UTC, datetime
|
|
24
26
|
from importlib import resources
|
|
25
|
-
from typing import
|
|
27
|
+
from typing import Any
|
|
26
28
|
|
|
27
29
|
import attrs
|
|
28
30
|
import cftime
|
|
29
31
|
import numpy as np
|
|
30
32
|
import pandas as pd
|
|
33
|
+
import xarray as xr
|
|
31
34
|
from loguru import logger
|
|
32
35
|
|
|
33
|
-
if TYPE_CHECKING:
|
|
34
|
-
import xarray as xr
|
|
35
|
-
|
|
36
36
|
|
|
37
37
|
def suppress_bounds_coordinates(ds: xr.Dataset) -> xr.Dataset:
|
|
38
38
|
"""
|
|
@@ -381,6 +381,26 @@ class CMIP7Metadata:
|
|
|
381
381
|
license_id: str = "CC-BY-4.0"
|
|
382
382
|
|
|
383
383
|
|
|
384
|
+
def make_tracking_id(file_id: str) -> str:
|
|
385
|
+
"""
|
|
386
|
+
Return the CMIP7 handle for a file, derived from ``file_id`` rather than minted at random.
|
|
387
|
+
|
|
388
|
+
A file therefore keeps its handle when a catalog is regenerated,
|
|
389
|
+
so the regeneration diff shows what actually moved.
|
|
390
|
+
|
|
391
|
+
Parameters
|
|
392
|
+
----------
|
|
393
|
+
file_id
|
|
394
|
+
Something that identifies the file, such as its ``instance_id`` and filename.
|
|
395
|
+
|
|
396
|
+
Returns
|
|
397
|
+
-------
|
|
398
|
+
str
|
|
399
|
+
The handle.
|
|
400
|
+
"""
|
|
401
|
+
return f"hdl:21.14107/{uuid.uuid5(uuid.NAMESPACE_URL, f'hdl:21.14107/{file_id}')}"
|
|
402
|
+
|
|
403
|
+
|
|
384
404
|
def convert_cmip6_to_cmip7_attrs(
|
|
385
405
|
cmip6_attrs: dict[str, Any],
|
|
386
406
|
) -> dict[str, Any]:
|
|
@@ -485,29 +505,37 @@ def convert_cmip6_to_cmip7_attrs(
|
|
|
485
505
|
return attrs
|
|
486
506
|
|
|
487
507
|
|
|
508
|
+
_MONTHS_PER_YEAR = 12
|
|
509
|
+
|
|
510
|
+
|
|
488
511
|
def _month_index(t: Any) -> int:
|
|
489
512
|
"""Return the absolute month index (``year * 12 + month - 1``) of a time value."""
|
|
490
513
|
if isinstance(t, cftime.datetime):
|
|
491
|
-
return t.year *
|
|
514
|
+
return t.year * _MONTHS_PER_YEAR + t.month - 1
|
|
492
515
|
ts = pd.Timestamp(t)
|
|
493
|
-
return ts.year *
|
|
516
|
+
return ts.year * _MONTHS_PER_YEAR + ts.month - 1
|
|
494
517
|
|
|
495
518
|
|
|
496
|
-
|
|
519
|
+
def months_to_extend(last: Any, end_year: int, end_month: int) -> int:
|
|
520
|
+
"""
|
|
521
|
+
Return how many months short of ``end_year``-``end_month`` a series ending at ``last`` falls.
|
|
522
|
+
|
|
523
|
+
Zero or less means the series already reaches that month.
|
|
524
|
+
"""
|
|
525
|
+
return end_year * _MONTHS_PER_YEAR + (end_month - 1) - _month_index(last)
|
|
497
526
|
|
|
498
527
|
|
|
499
|
-
def
|
|
528
|
+
def repeat_final_year_to(ds: xr.Dataset, end_year: int, end_month: int = 12) -> xr.Dataset:
|
|
500
529
|
"""
|
|
501
|
-
|
|
530
|
+
Extend a monthly series to ``end_year``-``end_month`` by repeating its final year.
|
|
502
531
|
|
|
503
|
-
The
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
diagnostic), without altering the underlying data values.
|
|
532
|
+
The real timesteps keep their real dates and values.
|
|
533
|
+
Any missing years are tiled from the last year.
|
|
534
|
+
This gives CMIP7 ``historical`` coverage for years the CMIP6 source never ran (e.g. 2015-2021)
|
|
535
|
+
without moving the years that did.
|
|
508
536
|
|
|
509
|
-
Only monthly data with a ``cftime`` time axis is supported
|
|
510
|
-
``time`` coordinate (fixed-frequency, e.g. ``sftlf``) are returned unchanged.
|
|
537
|
+
Only monthly data with a ``cftime`` time axis is supported.
|
|
538
|
+
Datasets without a ``time`` coordinate (fixed-frequency, e.g. ``sftlf``) are returned unchanged.
|
|
511
539
|
|
|
512
540
|
Parameters
|
|
513
541
|
----------
|
|
@@ -521,7 +549,15 @@ def shift_time_axis_end(ds: xr.Dataset, end_year: int, end_month: int = 12) -> x
|
|
|
521
549
|
Returns
|
|
522
550
|
-------
|
|
523
551
|
xr.Dataset
|
|
524
|
-
A
|
|
552
|
+
A dataset whose time axis runs to the requested end.
|
|
553
|
+
|
|
554
|
+
Raises
|
|
555
|
+
------
|
|
556
|
+
TypeError
|
|
557
|
+
If the time axis is not ``cftime``.
|
|
558
|
+
ValueError
|
|
559
|
+
If ``end_month`` is outside 1-12, the series does not end in December,
|
|
560
|
+
or its final calendar year is incomplete.
|
|
525
561
|
"""
|
|
526
562
|
if not 1 <= end_month <= _MONTHS_PER_YEAR:
|
|
527
563
|
raise ValueError(f"end_month must be in 1..12, got {end_month}")
|
|
@@ -533,52 +569,52 @@ def shift_time_axis_end(ds: xr.Dataset, end_year: int, end_month: int = 12) -> x
|
|
|
533
569
|
last = time_values[-1]
|
|
534
570
|
if not isinstance(last, cftime.datetime):
|
|
535
571
|
raise TypeError(
|
|
536
|
-
"
|
|
537
|
-
f"
|
|
572
|
+
"repeat_final_year_to requires a cftime time axis. "
|
|
573
|
+
f"Got {type(last).__name__}. Decode with use_cftime=True."
|
|
538
574
|
)
|
|
539
575
|
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
offset_months = target_index - _month_index(last)
|
|
543
|
-
if offset_months == 0:
|
|
576
|
+
months_to_add = months_to_extend(last, end_year, end_month)
|
|
577
|
+
if months_to_add <= 0:
|
|
544
578
|
return ds
|
|
545
579
|
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
# Last day of ``month`` = day before the first of the following month,
|
|
550
|
-
# computed via cftime arithmetic so it respects the dataset's calendar
|
|
551
|
-
# (noleap, 360_day, etc.).
|
|
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)
|
|
554
|
-
last_of_month = first_of_next - timedelta(days=1) # type: ignore[operator]
|
|
555
|
-
return int(last_of_month.day) # type: ignore[attr-defined]
|
|
556
|
-
|
|
557
|
-
def _shift(t: cftime.datetime) -> cftime.datetime:
|
|
558
|
-
total = _month_index(t) + offset_months
|
|
559
|
-
year, month = divmod(total, 12)
|
|
560
|
-
month += 1
|
|
561
|
-
# Clamp the day to the target month/calendar: a non-multiple-of-12 offset
|
|
562
|
-
# can land a day-31 (or leap Feb-29) label on a shorter month, which cftime
|
|
563
|
-
# would reject. Our monthly data is mid-month so this is normally a no-op.
|
|
564
|
-
day = min(t.day, _days_in_month(year, month))
|
|
565
|
-
return cftime.datetime(year, month, day, t.hour, t.minute, t.second, t.microsecond, calendar=calendar)
|
|
566
|
-
|
|
567
|
-
ds = ds.copy(deep=False)
|
|
568
|
-
shifted = np.array([_shift(t) for t in time_values])
|
|
569
|
-
new_time = ds["time"].copy(data=shifted)
|
|
570
|
-
new_time.encoding = dict(ds["time"].encoding)
|
|
571
|
-
ds = ds.assign_coords(time=new_time)
|
|
572
|
-
|
|
573
|
-
# Shift the matching time bounds, if present, so the axis stays self-consistent.
|
|
580
|
+
if last.month != _MONTHS_PER_YEAR:
|
|
581
|
+
raise ValueError(f"The series must end in December to repeat whole years, ends {last}")
|
|
582
|
+
|
|
574
583
|
bounds_name = ds["time"].attrs.get("bounds")
|
|
575
|
-
if bounds_name and bounds_name in ds:
|
|
576
|
-
bnds_values = ds[bounds_name].values
|
|
577
|
-
shifted_bnds = np.array([[_shift(v) for v in row] for row in bnds_values])
|
|
578
|
-
ds[bounds_name] = ds[bounds_name].copy(data=shifted_bnds)
|
|
579
584
|
|
|
580
|
-
|
|
581
|
-
|
|
585
|
+
def _restamp(t: cftime.datetime, shift: int) -> cftime.datetime:
|
|
586
|
+
year = t.year + shift
|
|
587
|
+
try:
|
|
588
|
+
return t.replace(year=year) # type: ignore[attr-defined,no-any-return]
|
|
589
|
+
except ValueError:
|
|
590
|
+
# A leap day has no counterpart in a common year, so take the day before.
|
|
591
|
+
return t.replace(year=year, day=t.day - 1) # type: ignore[attr-defined,no-any-return]
|
|
592
|
+
|
|
593
|
+
def _relabel(block: xr.Dataset, shift: int) -> xr.Dataset:
|
|
594
|
+
relabelled = block.copy()
|
|
595
|
+
if bounds_name and bounds_name in relabelled:
|
|
596
|
+
bnds = [[_restamp(b, shift) for b in row] for row in block[bounds_name].values]
|
|
597
|
+
# Assign as (dims, data) so the new bounds are not aligned against the old time axis.
|
|
598
|
+
relabelled[bounds_name] = (block[bounds_name].dims, np.array(bnds))
|
|
599
|
+
return relabelled.assign_coords(time=[_restamp(t, shift) for t in block["time"].values])
|
|
600
|
+
|
|
601
|
+
final_year = ds.sel(time=str(last.year))
|
|
602
|
+
if len(final_year["time"]) != _MONTHS_PER_YEAR:
|
|
603
|
+
raise ValueError(f"A full final year is needed to repeat, got {len(final_year['time'])} months")
|
|
604
|
+
|
|
605
|
+
repeats = [_relabel(final_year, year - last.year) for year in range(last.year + 1, end_year + 1)]
|
|
606
|
+
|
|
607
|
+
extended = xr.concat([ds, *repeats], dim="time", data_vars="minimal", coords="minimal")
|
|
608
|
+
extended["time"].attrs = dict(ds["time"].attrs)
|
|
609
|
+
extended["time"].encoding = dict(ds["time"].encoding)
|
|
610
|
+
|
|
611
|
+
# The repeats are whole years, so drop anything past the requested month.
|
|
612
|
+
extended = extended.sel(time=slice(None, f"{end_year:04d}-{end_month:02d}"))
|
|
613
|
+
|
|
614
|
+
logger.debug(
|
|
615
|
+
f"Repeated {last.year} for {months_to_add} months so the series ends {end_year:04d}-{end_month:02d}"
|
|
616
|
+
)
|
|
617
|
+
return extended
|
|
582
618
|
|
|
583
619
|
|
|
584
620
|
def convert_cmip6_dataset(
|
|
@@ -604,10 +640,10 @@ def convert_cmip6_dataset(
|
|
|
604
640
|
inplace
|
|
605
641
|
If True, modify the dataset in place; otherwise return a copy
|
|
606
642
|
extend_historical_to
|
|
607
|
-
Opt-in ``(end_year, end_month)``. When set, the
|
|
608
|
-
via :func:`
|
|
609
|
-
|
|
610
|
-
|
|
643
|
+
Opt-in ``(end_year, end_month)``. When set, the series is padded out to that month
|
|
644
|
+
via :func:`repeat_final_year_to`, fabricating CMIP7 coverage for years without real
|
|
645
|
+
data. Defaults to ``None`` (time axis untouched), so existing conversions are
|
|
646
|
+
byte-identical.
|
|
611
647
|
|
|
612
648
|
Returns
|
|
613
649
|
-------
|
|
@@ -619,7 +655,7 @@ def convert_cmip6_dataset(
|
|
|
619
655
|
|
|
620
656
|
if extend_historical_to is not None:
|
|
621
657
|
end_year, end_month = extend_historical_to
|
|
622
|
-
ds =
|
|
658
|
+
ds = repeat_final_year_to(ds, end_year=end_year, end_month=end_month)
|
|
623
659
|
|
|
624
660
|
# Determine the primary variable (skip coordinates/bounds)
|
|
625
661
|
data_vars = [str(v) for v in ds.data_vars if not str(v).endswith("_bnds") and v not in ds.coords]
|
|
@@ -443,6 +443,14 @@ class DataRequirement:
|
|
|
443
443
|
This is effectively an AND operation.
|
|
444
444
|
"""
|
|
445
445
|
|
|
446
|
+
fallback_source_types: tuple[SourceDatasetType, ...] = field(factory=tuple)
|
|
447
|
+
"""
|
|
448
|
+
Source types that may supply this requirement when ``source_type`` does not hold the dataset.
|
|
449
|
+
|
|
450
|
+
A dataset present under ``source_type`` always wins,
|
|
451
|
+
and the data is delivered under ``source_type`` whichever collection it came from.
|
|
452
|
+
"""
|
|
453
|
+
|
|
446
454
|
def apply_filters(self, data_catalog: pd.DataFrame) -> pd.DataFrame:
|
|
447
455
|
"""
|
|
448
456
|
Apply filters to a DataFrame-based data catalog.
|
|
@@ -7,9 +7,11 @@ a request class that fetches CMIP6 data and converts it to CMIP7 format.
|
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
+
import re
|
|
10
11
|
from pathlib import Path
|
|
11
|
-
from typing import Any, ClassVar
|
|
12
|
+
from typing import Any, ClassVar, NamedTuple
|
|
12
13
|
|
|
14
|
+
import cftime
|
|
13
15
|
import pandas as pd
|
|
14
16
|
import xarray as xr
|
|
15
17
|
from loguru import logger
|
|
@@ -21,7 +23,8 @@ from climate_ref_core.cmip6_to_cmip7 import (
|
|
|
21
23
|
format_cmip7_time_range,
|
|
22
24
|
get_dreq_entry,
|
|
23
25
|
get_frequency_from_table,
|
|
24
|
-
|
|
26
|
+
months_to_extend,
|
|
27
|
+
repeat_final_year_to,
|
|
25
28
|
suppress_bounds_coordinates,
|
|
26
29
|
)
|
|
27
30
|
from climate_ref_core.data import resolve_cache_dir
|
|
@@ -36,6 +39,65 @@ def _get_cmip7_cache_dir() -> Path:
|
|
|
36
39
|
return cache_dir
|
|
37
40
|
|
|
38
41
|
|
|
42
|
+
class LatestFile(NamedTuple):
|
|
43
|
+
"""The file holding a dataset's final timestep, and that timestep."""
|
|
44
|
+
|
|
45
|
+
path: Path
|
|
46
|
+
end: cftime.datetime
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _latest_file(files: list[Path]) -> LatestFile | None:
|
|
50
|
+
"""
|
|
51
|
+
Find the file holding a dataset's final timestep.
|
|
52
|
+
|
|
53
|
+
Only that file is extended, so a dataset split across several files keeps its earlier
|
|
54
|
+
chunks where they are instead of every chunk being padded out to the same end.
|
|
55
|
+
|
|
56
|
+
Parameters
|
|
57
|
+
----------
|
|
58
|
+
files
|
|
59
|
+
The dataset's CMIP6 source files. Files without a time axis are skipped.
|
|
60
|
+
|
|
61
|
+
Returns
|
|
62
|
+
-------
|
|
63
|
+
LatestFile | None
|
|
64
|
+
The file ending last and its final timestep, or ``None`` when none of them carry a time axis.
|
|
65
|
+
"""
|
|
66
|
+
time_coder = xr.coders.CFDatetimeCoder(use_cftime=True)
|
|
67
|
+
|
|
68
|
+
latest: LatestFile | None = None
|
|
69
|
+
for path in files:
|
|
70
|
+
with xr.open_dataset(path, decode_times=time_coder) as ds:
|
|
71
|
+
if "time" not in ds.coords or len(ds["time"]) == 0:
|
|
72
|
+
continue
|
|
73
|
+
end = ds["time"].values[-1]
|
|
74
|
+
if latest is None or end > latest.end:
|
|
75
|
+
latest = LatestFile(path, end)
|
|
76
|
+
return latest
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _bump_version(version: str) -> str:
|
|
80
|
+
"""
|
|
81
|
+
Bump a DRS version so fabricated data does not share an ``instance_id`` with the real thing.
|
|
82
|
+
|
|
83
|
+
A version that is not already ``v<digits>`` is read as ``v0``,
|
|
84
|
+
which is what the ingest falls back to when it cannot parse one out of the path.
|
|
85
|
+
|
|
86
|
+
Parameters
|
|
87
|
+
----------
|
|
88
|
+
version
|
|
89
|
+
The version the dataset would otherwise be published under.
|
|
90
|
+
|
|
91
|
+
Returns
|
|
92
|
+
-------
|
|
93
|
+
str
|
|
94
|
+
The next version.
|
|
95
|
+
"""
|
|
96
|
+
match = re.fullmatch(r"v(\d+)", version)
|
|
97
|
+
current = int(match.group(1)) if match else 0
|
|
98
|
+
return f"v{current + 1}"
|
|
99
|
+
|
|
100
|
+
|
|
39
101
|
def _convert_file_to_cmip7(
|
|
40
102
|
cmip6_path: Path,
|
|
41
103
|
cmip7_facets: dict[str, Any],
|
|
@@ -51,8 +113,8 @@ def _convert_file_to_cmip7(
|
|
|
51
113
|
cmip7_facets
|
|
52
114
|
CMIP7 facets for the output path
|
|
53
115
|
extend_historical_to
|
|
54
|
-
Opt-in ``(end_year, end_month)
|
|
55
|
-
to
|
|
116
|
+
Opt-in ``(end_year, end_month)``.
|
|
117
|
+
The series is padded out to that month by repeating its final year, so historical coverage reaches it.
|
|
56
118
|
Defaults to ``None`` (time axis untouched).
|
|
57
119
|
|
|
58
120
|
Returns
|
|
@@ -65,7 +127,7 @@ def _convert_file_to_cmip7(
|
|
|
65
127
|
# CMIP6 activity_id can contain multiple activities separated by spaces
|
|
66
128
|
# (e.g. "C4MIP CDRMIP"). Use only the first activity for the DRS path.
|
|
67
129
|
activity_id = str(cmip7_facets.get("activity_id", "CMIP")).split()[0]
|
|
68
|
-
version = str(cmip7_facets.get("version", "
|
|
130
|
+
version = str(cmip7_facets.get("version", "v0"))
|
|
69
131
|
|
|
70
132
|
# Build CMIP7 DRS path using the standard MIP-DRS7 path builder.
|
|
71
133
|
# Provide defaults for fields that may not be in facets.
|
|
@@ -85,12 +147,12 @@ def _convert_file_to_cmip7(
|
|
|
85
147
|
|
|
86
148
|
time_coder = xr.coders.CFDatetimeCoder(use_cftime=True)
|
|
87
149
|
with xr.open_dataset(cmip6_path, decode_times=time_coder) as ds:
|
|
88
|
-
# When fabricating extended historical coverage,
|
|
89
|
-
#
|
|
150
|
+
# When fabricating extended historical coverage, pad the series first so both the
|
|
151
|
+
# filename time range and the written data reflect the added months.
|
|
90
152
|
source_ds = ds
|
|
91
153
|
if extend_historical_to is not None:
|
|
92
154
|
end_year, end_month = extend_historical_to
|
|
93
|
-
source_ds =
|
|
155
|
+
source_ds = repeat_final_year_to(ds, end_year=end_year, end_month=end_month)
|
|
94
156
|
|
|
95
157
|
frequency = str(cmip7_facets.get("frequency", "mon"))
|
|
96
158
|
time_range = format_cmip7_time_range(source_ds, frequency)
|
|
@@ -199,11 +261,11 @@ class CMIP7Request:
|
|
|
199
261
|
time_span
|
|
200
262
|
Optional time range filter (start, end) in YYYY-MM format
|
|
201
263
|
extend_historical_to
|
|
202
|
-
Opt-in ``(end_year, end_month)``. When set,
|
|
203
|
-
its
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
264
|
+
Opt-in ``(end_year, end_month)``. When set, a converted CMIP7 file that stops
|
|
265
|
+
short has its final year repeated until it ends on that month, letting us
|
|
266
|
+
fabricate CMIP7 data for years without real CMIP6 source data (e.g. the fire
|
|
267
|
+
diagnostic's 2002-2021 window). Files that already reach it are untouched, as
|
|
268
|
+
are all conversions when this defaults to ``None``.
|
|
207
269
|
"""
|
|
208
270
|
self.slug = slug
|
|
209
271
|
self.facets = facets
|
|
@@ -302,21 +364,30 @@ class CMIP7Request:
|
|
|
302
364
|
cmip7_row = self._convert_to_cmip7_metadata(row_dict)
|
|
303
365
|
|
|
304
366
|
# Get file paths and convert them
|
|
305
|
-
|
|
367
|
+
paths = [Path(file_path) for file_path in row_dict.get("files", [])]
|
|
368
|
+
latest = None
|
|
369
|
+
if self.extend_historical_to is not None:
|
|
370
|
+
found = _latest_file([path for path in paths if path.exists()])
|
|
371
|
+
if found is not None and months_to_extend(found.end, *self.extend_historical_to) > 0:
|
|
372
|
+
# if modified bump the version so they don't collide
|
|
373
|
+
latest = found.path
|
|
374
|
+
cmip7_row["version"] = _bump_version(str(cmip7_row.get("version", "")))
|
|
375
|
+
|
|
306
376
|
converted_files = []
|
|
307
|
-
for
|
|
308
|
-
cmip6_path = Path(file_path)
|
|
377
|
+
for cmip6_path in paths:
|
|
309
378
|
if cmip6_path.exists():
|
|
310
379
|
try:
|
|
311
380
|
cmip7_path = _convert_file_to_cmip7(
|
|
312
|
-
cmip6_path,
|
|
381
|
+
cmip6_path,
|
|
382
|
+
cmip7_row,
|
|
383
|
+
extend_historical_to=self.extend_historical_to if cmip6_path == latest else None,
|
|
313
384
|
)
|
|
314
385
|
converted_files.append(str(cmip7_path))
|
|
315
386
|
except Exception as e:
|
|
316
387
|
logger.exception(f"Failed to convert {cmip6_path.name}: {e}")
|
|
317
388
|
continue
|
|
318
389
|
else:
|
|
319
|
-
logger.warning(f"CMIP6 file not found: {
|
|
390
|
+
logger.warning(f"CMIP6 file not found: {cmip6_path}")
|
|
320
391
|
|
|
321
392
|
if converted_files:
|
|
322
393
|
cmip7_row["files"] = converted_files
|
|
@@ -141,21 +141,14 @@ def source_ids_by_registry(
|
|
|
141
141
|
parser = _registry_key_parser(entry.source_type)
|
|
142
142
|
if parser is None:
|
|
143
143
|
continue
|
|
144
|
-
# obs4REF data is ingested under the obs4MIPs source type, so a registry declaring
|
|
145
|
-
# obs4REF supplies requirements written against either.
|
|
146
|
-
source_types = {entry.source_type.value}
|
|
147
|
-
if entry.source_type is SourceDatasetType.obs4REF:
|
|
148
|
-
source_types.add(SourceDatasetType.obs4MIPs.value)
|
|
149
|
-
|
|
150
144
|
for key in entry.registry.registry:
|
|
151
145
|
metadata: Mapping[str, Any] = parser(key)
|
|
152
146
|
source_id = metadata.get("source_id")
|
|
153
147
|
if not source_id:
|
|
154
148
|
continue
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
names.append(name)
|
|
149
|
+
names = found[(entry.source_type.value, source_id)]
|
|
150
|
+
if name not in names:
|
|
151
|
+
names.append(name)
|
|
159
152
|
return dict(found)
|
|
160
153
|
|
|
161
154
|
|
|
@@ -182,6 +175,8 @@ def collect_required_reference_data(
|
|
|
182
175
|
|
|
183
176
|
variables: dict[tuple[str, str], set[str]] = defaultdict(set)
|
|
184
177
|
diagnostics: dict[tuple[str, str], set[DiagnosticReference]] = defaultdict(set)
|
|
178
|
+
# Source types a requirement may be met from, its own first
|
|
179
|
+
suppliers: dict[tuple[str, str], list[str]] = defaultdict(list)
|
|
185
180
|
|
|
186
181
|
for provider in providers:
|
|
187
182
|
for diagnostic in summarize_provider(provider).diagnostics:
|
|
@@ -198,11 +193,20 @@ def collect_required_reference_data(
|
|
|
198
193
|
key = (requirement.source_type, source_id)
|
|
199
194
|
variables[key].update(requirement.variables)
|
|
200
195
|
diagnostics[key].add(reference)
|
|
196
|
+
for source_type in (requirement.source_type, *requirement.fallback_source_types):
|
|
197
|
+
if source_type not in suppliers[key]:
|
|
198
|
+
suppliers[key].append(source_type)
|
|
201
199
|
|
|
202
200
|
datasets = []
|
|
203
201
|
for (source_type, source_id), variable_ids in variables.items():
|
|
204
|
-
|
|
205
|
-
|
|
202
|
+
registry_name = next(
|
|
203
|
+
(
|
|
204
|
+
registry_of[(supplier, source_id)][0]
|
|
205
|
+
for supplier in suppliers[(source_type, source_id)]
|
|
206
|
+
if registry_of.get((supplier, source_id))
|
|
207
|
+
),
|
|
208
|
+
None,
|
|
209
|
+
)
|
|
206
210
|
datasets.append(
|
|
207
211
|
ReferenceDataset(
|
|
208
212
|
source_type=source_type,
|
{climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/regression/__init__.py
RENAMED
|
@@ -29,6 +29,7 @@ from climate_ref_core.regression.gate import (
|
|
|
29
29
|
)
|
|
30
30
|
from climate_ref_core.regression.manifest import (
|
|
31
31
|
COMMITTED_BUNDLE_FILES,
|
|
32
|
+
COMMITTED_DIRNAME,
|
|
32
33
|
SCHEMA_VERSION,
|
|
33
34
|
Manifest,
|
|
34
35
|
NativeEntry,
|
|
@@ -37,6 +38,10 @@ from climate_ref_core.regression.manifest import (
|
|
|
37
38
|
sha256_file,
|
|
38
39
|
verify_committed_integrity,
|
|
39
40
|
)
|
|
41
|
+
from climate_ref_core.regression.report_store import (
|
|
42
|
+
ReportStore,
|
|
43
|
+
build_report_store,
|
|
44
|
+
)
|
|
40
45
|
from climate_ref_core.regression.store import (
|
|
41
46
|
NativeStore,
|
|
42
47
|
NativeStoreUnavailableError,
|
|
@@ -46,6 +51,7 @@ from climate_ref_core.regression.store import (
|
|
|
46
51
|
|
|
47
52
|
__all__ = [
|
|
48
53
|
"COMMITTED_BUNDLE_FILES",
|
|
54
|
+
"COMMITTED_DIRNAME",
|
|
49
55
|
"SCHEMA_VERSION",
|
|
50
56
|
"Action",
|
|
51
57
|
"GateDecision",
|
|
@@ -53,11 +59,13 @@ __all__ = [
|
|
|
53
59
|
"NativeEntry",
|
|
54
60
|
"NativeStore",
|
|
55
61
|
"NativeStoreUnavailableError",
|
|
62
|
+
"ReportStore",
|
|
56
63
|
"S3WriteConfig",
|
|
57
64
|
"Tolerance",
|
|
58
65
|
"assert_bundle_regression",
|
|
59
66
|
"build_native_snapshot",
|
|
60
67
|
"build_native_store",
|
|
68
|
+
"build_report_store",
|
|
61
69
|
"compare_json_content",
|
|
62
70
|
"compute_committed_digests",
|
|
63
71
|
"decide_coupling",
|
{climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/regression/manifest.py
RENAMED
|
@@ -62,6 +62,10 @@ def _validate_digest(digest: str) -> str:
|
|
|
62
62
|
return digest
|
|
63
63
|
|
|
64
64
|
|
|
65
|
+
COMMITTED_DIRNAME = "regression"
|
|
66
|
+
"""The directory a test case's committed bundle sits in, both in git and in an output slot."""
|
|
67
|
+
|
|
68
|
+
|
|
65
69
|
COMMITTED_BUNDLE_FILES: tuple[str, ...] = (
|
|
66
70
|
"series.json",
|
|
67
71
|
"diagnostic.json",
|