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.
Files changed (101) hide show
  1. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/PKG-INFO +1 -1
  2. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/pyproject.toml +1 -1
  3. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/cmip6_to_cmip7.py +99 -63
  4. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/diagnostics.py +8 -0
  5. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/esgf/cmip7.py +89 -18
  6. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/reference_data.py +16 -12
  7. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/regression/__init__.py +8 -0
  8. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/regression/manifest.py +4 -0
  9. climate_ref_core-0.18.0/src/climate_ref_core/regression/report_store.py +223 -0
  10. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/regression/store.py +127 -78
  11. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/summary.py +11 -2
  12. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/testing.py +2 -2
  13. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/unit/esgf/test_cmip7.py +149 -1
  14. climate_ref_core-0.18.0/tests/unit/regression/test_report_store.py +227 -0
  15. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/unit/test_cmip6_to_cmip7.py +119 -58
  16. climate_ref_core-0.18.0/tests/unit/test_datasets/dataset_collection_obs4mips_hash.yml +2 -0
  17. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/unit/test_datasets.py +2 -2
  18. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/unit/test_reference_data.py +36 -8
  19. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/unit/test_summary.py +5 -5
  20. climate_ref_core-0.17.2/tests/unit/test_datasets/dataset_collection_obs4mips_hash.yml +0 -2
  21. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/.gitignore +0 -0
  22. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/LICENCE +0 -0
  23. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/NOTICE +0 -0
  24. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/README.md +0 -0
  25. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/__init__.py +0 -0
  26. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/cmor.py +0 -0
  27. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/constraints.py +0 -0
  28. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/data/cmip6_cmip7_variable_map.json +0 -0
  29. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/data.py +0 -0
  30. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/dataset_registry.py +0 -0
  31. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/datasets.py +0 -0
  32. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/env.py +0 -0
  33. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/esgf/__init__.py +0 -0
  34. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/esgf/base.py +0 -0
  35. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/esgf/cmip6.py +0 -0
  36. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/esgf/fetcher.py +0 -0
  37. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/esgf/obs4mips.py +0 -0
  38. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/esgf/registry.py +0 -0
  39. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/esmvaltool_reference.py +0 -0
  40. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/exceptions.py +0 -0
  41. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/executor.py +0 -0
  42. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/logging.py +0 -0
  43. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/metric_values/__init__.py +0 -0
  44. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/metric_values/typing.py +0 -0
  45. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/output_files.py +0 -0
  46. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/paths.py +0 -0
  47. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/providers.py +0 -0
  48. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/py.typed +0 -0
  49. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/pycmec/README.md +0 -0
  50. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/pycmec/__init__.py +0 -0
  51. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/pycmec/controlled_vocabulary.py +0 -0
  52. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/pycmec/cv_cmip7_aft.yaml +0 -0
  53. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/pycmec/metric.py +0 -0
  54. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/pycmec/output.py +0 -0
  55. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/regression/_quantise.py +0 -0
  56. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/regression/capture.py +0 -0
  57. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/regression/compare.py +0 -0
  58. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/regression/gate.py +0 -0
  59. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/resources.py +0 -0
  60. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/serialisation.py +0 -0
  61. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/src/climate_ref_core/source_types.py +0 -0
  62. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/integration/test_resource_measurement.py +0 -0
  63. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/unit/esgf/test_base.py +0 -0
  64. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/unit/esgf/test_cmip6.py +0 -0
  65. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/unit/esgf/test_fetcher.py +0 -0
  66. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/unit/esgf/test_obs4mips.py +0 -0
  67. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/unit/esgf/test_registry.py +0 -0
  68. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/unit/metric_values/test_typing.py +0 -0
  69. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/unit/pycmec/cmec_testdata/cmec_metric_sample.json +0 -0
  70. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/unit/pycmec/cmec_testdata/cmec_output_sample.json +0 -0
  71. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/unit/pycmec/cmec_testdata/cv_sample.yaml +0 -0
  72. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/unit/pycmec/cmec_testdata/test_metric_json_schema.yml +0 -0
  73. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/unit/pycmec/cmec_testdata/test_output_json_schema.yml +0 -0
  74. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/unit/pycmec/conftest.py +0 -0
  75. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/unit/pycmec/test_cmec_metric.py +0 -0
  76. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/unit/pycmec/test_cmec_output.py +0 -0
  77. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/unit/pycmec/test_controlled_vocabulary.py +0 -0
  78. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/unit/regression/test_capture.py +0 -0
  79. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/unit/regression/test_compare.py +0 -0
  80. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/unit/regression/test_gate.py +0 -0
  81. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/unit/regression/test_manifest.py +0 -0
  82. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/unit/regression/test_quantise.py +0 -0
  83. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/unit/regression/test_store.py +0 -0
  84. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/unit/test_cmor.py +0 -0
  85. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/unit/test_constraints.py +0 -0
  86. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/unit/test_data.py +0 -0
  87. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/unit/test_dataset_registry/test_dataset_registry.py +0 -0
  88. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/unit/test_datasets/dataset_collection_hash.yml +0 -0
  89. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/unit/test_datasets/execution_dataset_hash.yml +0 -0
  90. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/unit/test_diagnostics.py +0 -0
  91. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/unit/test_esmvaltool_reference.py +0 -0
  92. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/unit/test_exceptions.py +0 -0
  93. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/unit/test_executor.py +0 -0
  94. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/unit/test_logging.py +0 -0
  95. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/unit/test_output_files.py +0 -0
  96. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/unit/test_packaging.py +0 -0
  97. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/unit/test_paths.py +0 -0
  98. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/unit/test_providers.py +0 -0
  99. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/unit/test_resources.py +0 -0
  100. {climate_ref_core-0.17.2 → climate_ref_core-0.18.0}/tests/unit/test_serialisation.py +0 -0
  101. {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.17.2
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "climate-ref-core"
3
- version = "0.17.2"
3
+ version = "0.18.0"
4
4
  description = "Core library for the CMIP Rapid Evaluation Framework"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -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, timedelta
25
+ from datetime import UTC, datetime
24
26
  from importlib import resources
25
- from typing import TYPE_CHECKING, Any
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 * 12 + t.month - 1
514
+ return t.year * _MONTHS_PER_YEAR + t.month - 1
492
515
  ts = pd.Timestamp(t)
493
- return ts.year * 12 + ts.month - 1
516
+ return ts.year * _MONTHS_PER_YEAR + ts.month - 1
494
517
 
495
518
 
496
- _MONTHS_PER_YEAR = 12
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 shift_time_axis_end(ds: xr.Dataset, end_year: int, end_month: int = 12) -> xr.Dataset:
528
+ def repeat_final_year_to(ds: xr.Dataset, end_year: int, end_month: int = 12) -> xr.Dataset:
500
529
  """
501
- Relabel a monthly time axis so the final timestep lands on ``end_year``-``end_month``.
530
+ Extend a monthly series to ``end_year``-``end_month`` by repeating its final year.
502
531
 
503
- The whole ``time`` coordinate (and any ``time_bnds``) is shifted by a constant
504
- whole-month offset, so monthly spacing, calendar, and cftime types are preserved
505
- and only the labels move. This is used to fabricate CMIP7 ``historical`` coverage
506
- that reaches years for which no real data exists (e.g. 2002-2021 for the fire
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; datasets without a
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 shallow copy with the relabelled time axis.
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
- "shift_time_axis_end requires a cftime time axis; "
537
- f"got {type(last).__name__}. Decode with use_cftime=True."
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
- # Whole-month offset that moves the final label onto the requested end.
541
- target_index = end_year * 12 + (end_month - 1)
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
- calendar = last.calendar
547
-
548
- def _days_in_month(year: int, month: int) -> int:
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
- logger.debug(f"Shifted time axis by {offset_months} months so it ends {end_year:04d}-{end_month:02d}")
581
- return ds
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 ``time`` axis is relabelled
608
- via :func:`shift_time_axis_end` so the series ends on that month, fabricating
609
- CMIP7 coverage for years without real data. Defaults to ``None`` (time axis
610
- untouched), so existing conversions are byte-identical.
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 = shift_time_axis_end(ds, end_year=end_year, end_month=end_month)
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
- shift_time_axis_end,
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)`` passed to :func:`convert_cmip6_dataset`
55
- to relabel the time axis so historical coverage reaches that month.
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", "v1"))
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, relabel the time axis first
89
- # so both the filename time range and the written data reflect the new dates.
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 = shift_time_axis_end(ds, end_year=end_year, end_month=end_month)
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, each converted CMIP7 file has
203
- its time axis relabelled so historical coverage ends on that month, letting
204
- us fabricate CMIP7 data for years without real CMIP6 source data (e.g. the
205
- fire diagnostic's 2002-2021 window). Defaults to ``None`` (time axis
206
- untouched), so other CMIP7 conversions are unchanged.
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
- files = row_dict.get("files", [])
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 file_path in files:
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, cmip7_row, extend_historical_to=self.extend_historical_to
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: {file_path}")
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
- for source_type in source_types:
156
- names = found[(source_type, source_id)]
157
- if name not in names:
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
- registry_names = registry_of.get((source_type, source_id), [])
205
- registry_name = registry_names[0] if registry_names else None
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,
@@ -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",
@@ -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",