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