Skip to content

climate_ref_ilamb.standard #

ILAMBStandard #

Bases: Diagnostic

Apply the standard ILAMB analysis with respect to a given reference dataset.

Source code in packages/climate-ref-ilamb/src/climate_ref_ilamb/standard.py
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
class ILAMBStandard(Diagnostic):
    """
    Apply the standard ILAMB analysis with respect to a given reference dataset.
    """

    version = 2
    """
    Default version for ILAMB diagnostics.

    Individual diagnostics can override this with a ``version`` key in their configuration entry.
    """

    def __init__(  # noqa: PLR0915, PLR0912
        self,
        realm: str,
        metric_name: str,
        sources: dict[str, str | dict[str, str]],
        region_masks: str | None = None,
        ilamb_registry: str | None = None,
        **ilamb_kwargs: Any,
    ):
        """
        Set up an ILAMB standard diagnostic.

        Parameters
        ----------
        realm
            Diagnostic realm, ``"land"`` or ``"ocean"``.
            Drives the CMIP6 table set / CMIP7 branded-variable lookup and the
            supplementary land vs ocean ancillary variables.
        metric_name
            Name of the ILAMB diagnostic, used to build the REF slug.
        sources
            ILAMB source specification (string-form registry keys or dict-form
            REF data requirement filters).
        region_masks
            Name of the registry holding the ``ilamb/regions/*.nc`` mask files
            (typically ``"ilamb-regions"``), or ``None`` if this diagnostic does not need region masks.

            Deliberately independent of ``realm``,
            because a land diagnostic may still not need masks.
        ilamb_registry
            Name of the registry that resolves this diagnostic's string-form reference sources.

            Defaults to ``"ilamb"`` for land diagnostics and to ``None``
            (no ILAMB registry data, e.g. ocean diagnostics whose sources are all dict-form obs4REF requests)
            otherwise.

            Tests may override this to point at the small ``"ilamb-test"`` fixture
            registry instead of the real reference data.
        """
        if ilamb_registry is None:
            ilamb_registry = "ilamb" if realm == "land" else None
        # Setup the diagnostic
        self.variable_id = ilamb_kwargs.get("analysis_variable", next(iter(sources.keys())))
        if "sources" not in ilamb_kwargs:  # pragma: no cover
            ilamb_kwargs["sources"] = sources
        if "relationships" not in ilamb_kwargs:
            ilamb_kwargs["relationships"] = {}
        if ilamb_kwargs["relationships"]:
            transforms = list(ilamb_kwargs.get("transforms", []))
            transform_names = [
                next(iter(transform.keys())) if isinstance(transform, dict) else transform
                for transform in transforms
            ]
            if "climate_ref_relationship_time" not in transform_names:
                transforms.append({"climate_ref_relationship_time": {"variable_id": self.variable_id}})
            ilamb_kwargs["transforms"] = transforms

        # Allow per-diagnostic override of the test source_id
        # (not all variables are available from CanESM5)
        test_source_id = ilamb_kwargs.pop("test_source_id", "CanESM5")

        diagnostic_version = ilamb_kwargs.pop("version", None)
        if diagnostic_version is not None:
            self.version = diagnostic_version

        self.ilamb_kwargs = ilamb_kwargs

        # REF stuff
        self.name = metric_name
        self.slug = self.name.lower().replace(" ", "-")

        # Collect all variable IDs used by this diagnostic
        all_variable_ids = (
            self.variable_id,
            *ilamb_kwargs.get("alternate_vars", []),
            *ilamb_kwargs.get("related_vars", []),
            *ilamb_kwargs.get("relationships", {}).keys(),
        )

        # Variables that the primary diagnostic requires (not relationship vars)
        primary_variable_ids = (
            self.variable_id,
            *ilamb_kwargs.get("alternate_vars", []),
            *ilamb_kwargs.get("related_vars", []),
        )

        # Look up CMIP7 branded variable names
        branded_variables = _get_branded_variable(all_variable_ids, realm)

        # Determine realm/region for CMIP7 filter
        is_land = realm == "land"

        relationship_variable_ids = tuple(ilamb_kwargs.get("relationships", {}).keys())

        cmip7_primary_variable_ids = _get_cmip7_variable_ids(primary_variable_ids, realm)
        cmip7_relationship_variable_ids = _get_cmip7_variable_ids(relationship_variable_ids, realm)

        # Create the data requirement for the dataset under test
        cmip6_requirement = _build_cmip_data_requirement(
            source_type=SourceDatasetType.CMIP6,
            filters={
                "variable_id": all_variable_ids,
                "frequency": "mon",
                "experiment_id": ("historical", "esm-hist", "land-hist"),
                "table_id": (
                    "AERmonZ",
                    "Amon",
                    "CFmon",
                    "Emon",
                    "EmonZ",
                    "LImon",
                    "Lmon",
                    "Omon",
                    "SImon",
                ),
            },
            group_by=("experiment_id", "source_id", "member_id", "grid_label"),
            primary_variable_ids=primary_variable_ids,
            relationship_variable_ids=relationship_variable_ids,
            is_land=is_land,
        )

        cmip7_requirement = _build_cmip_data_requirement(
            source_type=SourceDatasetType.CMIP7,
            filters={
                "branded_variable": branded_variables,
                "frequency": "mon",
                "experiment_id": ("historical", "esm-hist", "land-hist"),
                "region": "glb",
            },
            group_by=("experiment_id", "source_id", "variant_label", "grid_label"),
            primary_variable_ids=cmip7_primary_variable_ids,
            relationship_variable_ids=cmip7_relationship_variable_ids,
            is_land=is_land,
        )

        # Observational data requirement, normally ilamb3 expects the `sources` to
        # resolve to keys in one of its data registries. If instead we find a
        # dictionary, then assume that these keys are meant to be keywords in a
        # REF data requirement.
        # obs_source names where the test data is fetched from, the registry or ESGF.
        # It does not affect the requirement below.
        filters: dict[str, tuple[str, ...]] = {}
        obs_source = None
        for _, source in sources.items():
            if isinstance(source, dict):
                obs_source = source.pop("obs_source", None)
                for key, val in source.items():
                    if key not in filters:
                        filters[key] = ()
                    filters[key] += (val,)
        obs4mips_requirement = (
            DataRequirement(
                source_type=SourceDatasetType.obs4MIPs,
                filters=(FacetFilter(facets=filters),),
                group_by=tuple(filters.keys()),
                fallback_source_types=(SourceDatasetType.obs4REF,),
            )
            if filters
            else None
        )

        data_requirements: Sequence[Sequence[DataRequirement]]
        if obs4mips_requirement is None:
            data_requirements = (
                (cmip6_requirement,),
                (cmip7_requirement,),
            )
        else:
            data_requirements = (
                (cmip6_requirement, obs4mips_requirement),
                (cmip7_requirement, obs4mips_requirement),
            )
        self.data_requirements = data_requirements

        self.facets = (
            "experiment_id",
            "source_id",
            "member_id",
            "grid_label",
            "region",
            "metric",
            "statistic",
        )

        self.test_data_spec = _build_test_data_spec(
            all_variable_ids=all_variable_ids,
            realm=realm,
            test_source_id=test_source_id,
            is_land=is_land,
            obs_filters=filters,
            obs_source=obs_source,
        )

        # Setup ILAMB data and options
        self.realm = realm
        self.region_masks = region_masks
        self.ilamb_registry = ilamb_registry
        if self.ilamb_registry is not None:
            self.ilamb_data = registry_to_collection(dataset_registry_manager[self.ilamb_registry])
        else:
            # No ILAMB registry backs this diagnostic (all references on ESGF)
            # Degrade to an empty collection with the columns downstream code expects,
            # rather than crashing on a missing registry.
            self.ilamb_data = DatasetCollection(pd.DataFrame(columns=["key", "path"]), "key")

    def execute(self, definition: ExecutionDefinition) -> None:
        """
        Run the ILAMB standard analysis.
        """
        _set_ilamb3_options(
            dataset_registry_manager[self.region_masks] if self.region_masks is not None else None
        )
        # The rewrite below is per-execution, but the provider shares one diagnostic instance
        # across executions, so copy rather than mutate `self`.
        ilamb_kwargs = {**self.ilamb_kwargs, "sources": dict(self.ilamb_kwargs["sources"])}
        # Temporary hack of the ilamb3 inputs while we still need to refer to
        # data not yet available in obs4{MIPs,REF}. This logic allows for
        # DataRequirement filters to be added as a 'source' in the ilamb
        # configure file. If a dictionary instead of a string was found, we
        # populate an observational requirement.
        if SourceDatasetType.obs4MIPs in definition.datasets:
            # ilamb3 will expect the reference dataset dataframe to have a `key`
            # column that uniquely describes each dataset. Create one using the
            # `instance_id` and an integer and then modify the ilamb3 configure
            # so that it finds the proper data.
            ref_datasets = definition.datasets[SourceDatasetType.obs4MIPs].datasets
            ref_datasets = ref_datasets.reset_index()
            ref_datasets["key"] = ref_datasets["instance_id"] + ref_datasets.index.astype(str)
            source_keys = {
                entry["variable_id"]: key
                for key, entry in ilamb_kwargs["sources"].items()
                if isinstance(entry, dict) and "variable_id" in entry
            }
            for instance_id, df in ref_datasets.groupby("instance_id"):
                variable_id = df["variable_id"].unique()[0]
                source_key = source_keys.get(variable_id, variable_id)
                ilamb_kwargs["sources"][source_key] = f"{instance_id}*"
            # Relationship analyses (and any remaining legacy string-path sources)
            # still refer to keys in the ILAMB/obs4REF registries.
            # Keep those keys in the reference dataframe alongside the ingested obs4MIPs datasets so
            # they remain resolvable when a diagnostic mixes obs4REF and registry data.
            ref_datasets = pd.concat(
                [
                    ref_datasets,
                    self.ilamb_data.datasets,
                    registry_to_collection(dataset_registry_manager["obs4ref"]).datasets,
                ],
                ignore_index=True,
            )
        else:
            # If the data is not ingested yet but in a registry, we concat the
            # obs4REF registries to the ilamb one so that any key may be used
            # from either. Eventually (?) this can be removed.
            ref_datasets = pd.concat(
                [
                    self.ilamb_data.datasets.set_index(self.ilamb_data.slug_column),
                    registry_to_collection(dataset_registry_manager["obs4ref"]).datasets.set_index("key"),
                ]
            )
        cmip_source = _get_cmip_source_type(definition.datasets)
        model_datasets = definition.datasets[cmip_source].datasets

        # CMIP7 uses variant_label instead of member_id; ilamb3 expects member_id
        if cmip_source == SourceDatasetType.CMIP7 and "member_id" not in model_datasets.columns:
            model_datasets = model_datasets.copy()
            model_datasets["member_id"] = model_datasets["variant_label"]

        # When both a 3D primary variable (e.g. thetao) and its 2D alternate
        # (e.g. tos) are present, drop the primary so ilamb3 uses the alternate.
        # This avoids two problems with merging datasets of different time ranges:
        # 1) time_bnds gets NaN-filled for the shorter variable's missing steps,
        #    corrupting the time frequency calculation in ilamb3
        # 2) The 3D variable may not cover the reference period (e.g. thetao
        #    covers 1850-1860 but WOA2023 reference covers 2005-2014)
        # The alternate surface variable is equivalent after select_depth and
        # typically has full temporal coverage.
        alternate_vars = ilamb_kwargs.get("alternate_vars", [])
        if alternate_vars:
            available_alternates = [v for v in alternate_vars if v in model_datasets["variable_id"].values]
            if available_alternates and self.variable_id in model_datasets["variable_id"].values:
                model_datasets = model_datasets[model_datasets["variable_id"] != self.variable_id]

        # In ilamb3, this is run with all the models that we know about to
        # create different colors. For REF this will at least make the model
        # line color not be black
        run.set_model_colors(model_datasets)

        # Run ILAMB in a single-threaded mode to avoid issues with multithreading (#394)
        with dask.config.set(scheduler="synchronous"):
            run.run_single_block(
                self.slug,
                ref_datasets,
                model_datasets,
                definition.output_directory,
                **ilamb_kwargs,
            )

    def build_execution_result(self, definition: ExecutionDefinition) -> ExecutionResult:
        """
        Build the diagnostic result after running ILAMB.

        Parameters
        ----------
        definition
            The definition of the diagnostic execution

        Returns
        -------
            An execution result object
        """
        _set_ilamb3_options(
            dataset_registry_manager[self.region_masks] if self.region_masks is not None else None
        )
        # In ILAMB, scalars are saved in CSV files in the output directory. To
        # be compatible with the REF system we will need to add the metadata
        # that is associated with the execution group, called the selector.
        df = _load_csv_and_merge(definition.output_directory)
        selectors = _get_selectors(definition.datasets)

        # TODO: Fix reference data once we are using the obs4MIPs dataset
        dataset_source = self.name.split("-")[1] if "-" in self.name else "None"
        common_dimensions = {**selectors, "reference_source_id": dataset_source}
        for key, value in common_dimensions.items():
            df[key] = value
        metric_bundle = CMECMetric.model_validate(_build_cmec_bundle(df))

        # Add each png file plot to the output.
        output_bundle = CMECOutput.create_template()
        for plotfile in sorted(definition.output_directory.glob("*.png")):
            relative_path = str(definition.as_relative_path(plotfile))
            caption, figure_dimensions = _caption_from_filename(plotfile, common_dimensions)

            output_bundle[OutputCV.PLOTS.value][relative_path] = {
                OutputCV.FILENAME.value: relative_path,
                OutputCV.LONG_NAME.value: caption,
                OutputCV.DESCRIPTION.value: "",
                OutputCV.DIMENSIONS.value: figure_dimensions,
            }

        # Register the scalar CSV files and the netCDF time-trace files in the data section so
        # they are persisted with the execution outputs and captured in the regression baseline;
        # build_execution_result re-reads them to reconstruct the metrics and series.
        _register_data_outputs(output_bundle, definition)

        # Add the html page to the output
        index_html = definition.to_output_path("index.html")
        if index_html.exists():
            relative_path = str(definition.as_relative_path(index_html))
            output_bundle[OutputCV.HTML.value][relative_path] = {
                OutputCV.FILENAME.value: relative_path,
                OutputCV.LONG_NAME.value: "Results page",
                OutputCV.DESCRIPTION.value: "Page displaying scalars and plots from the ILAMB execution.",
                OutputCV.DIMENSIONS.value: common_dimensions,
            }
            output_bundle[OutputCV.INDEX.value] = relative_path

        # Add series to the output based on the time traces we find in the
        # output files
        series = _build_series(definition.output_directory, dataset_source, common_dimensions)

        return ExecutionResult.build_from_output_bundle(
            definition, cmec_output_bundle=output_bundle, cmec_metric_bundle=metric_bundle, series=series
        )

version = 2 class-attribute instance-attribute #

Default version for ILAMB diagnostics.

Individual diagnostics can override this with a version key in their configuration entry.

__init__(realm, metric_name, sources, region_masks=None, ilamb_registry=None, **ilamb_kwargs) #

Set up an ILAMB standard diagnostic.

Parameters:

Name Type Description Default
realm str

Diagnostic realm, "land" or "ocean". Drives the CMIP6 table set / CMIP7 branded-variable lookup and the supplementary land vs ocean ancillary variables.

required
metric_name str

Name of the ILAMB diagnostic, used to build the REF slug.

required
sources dict[str, str | dict[str, str]]

ILAMB source specification (string-form registry keys or dict-form REF data requirement filters).

required
region_masks str | None

Name of the registry holding the ilamb/regions/*.nc mask files (typically "ilamb-regions"), or None if this diagnostic does not need region masks.

Deliberately independent of realm, because a land diagnostic may still not need masks.

None
ilamb_registry str | None

Name of the registry that resolves this diagnostic's string-form reference sources.

Defaults to "ilamb" for land diagnostics and to None (no ILAMB registry data, e.g. ocean diagnostics whose sources are all dict-form obs4REF requests) otherwise.

Tests may override this to point at the small "ilamb-test" fixture registry instead of the real reference data.

None
Source code in packages/climate-ref-ilamb/src/climate_ref_ilamb/standard.py
def __init__(  # noqa: PLR0915, PLR0912
    self,
    realm: str,
    metric_name: str,
    sources: dict[str, str | dict[str, str]],
    region_masks: str | None = None,
    ilamb_registry: str | None = None,
    **ilamb_kwargs: Any,
):
    """
    Set up an ILAMB standard diagnostic.

    Parameters
    ----------
    realm
        Diagnostic realm, ``"land"`` or ``"ocean"``.
        Drives the CMIP6 table set / CMIP7 branded-variable lookup and the
        supplementary land vs ocean ancillary variables.
    metric_name
        Name of the ILAMB diagnostic, used to build the REF slug.
    sources
        ILAMB source specification (string-form registry keys or dict-form
        REF data requirement filters).
    region_masks
        Name of the registry holding the ``ilamb/regions/*.nc`` mask files
        (typically ``"ilamb-regions"``), or ``None`` if this diagnostic does not need region masks.

        Deliberately independent of ``realm``,
        because a land diagnostic may still not need masks.
    ilamb_registry
        Name of the registry that resolves this diagnostic's string-form reference sources.

        Defaults to ``"ilamb"`` for land diagnostics and to ``None``
        (no ILAMB registry data, e.g. ocean diagnostics whose sources are all dict-form obs4REF requests)
        otherwise.

        Tests may override this to point at the small ``"ilamb-test"`` fixture
        registry instead of the real reference data.
    """
    if ilamb_registry is None:
        ilamb_registry = "ilamb" if realm == "land" else None
    # Setup the diagnostic
    self.variable_id = ilamb_kwargs.get("analysis_variable", next(iter(sources.keys())))
    if "sources" not in ilamb_kwargs:  # pragma: no cover
        ilamb_kwargs["sources"] = sources
    if "relationships" not in ilamb_kwargs:
        ilamb_kwargs["relationships"] = {}
    if ilamb_kwargs["relationships"]:
        transforms = list(ilamb_kwargs.get("transforms", []))
        transform_names = [
            next(iter(transform.keys())) if isinstance(transform, dict) else transform
            for transform in transforms
        ]
        if "climate_ref_relationship_time" not in transform_names:
            transforms.append({"climate_ref_relationship_time": {"variable_id": self.variable_id}})
        ilamb_kwargs["transforms"] = transforms

    # Allow per-diagnostic override of the test source_id
    # (not all variables are available from CanESM5)
    test_source_id = ilamb_kwargs.pop("test_source_id", "CanESM5")

    diagnostic_version = ilamb_kwargs.pop("version", None)
    if diagnostic_version is not None:
        self.version = diagnostic_version

    self.ilamb_kwargs = ilamb_kwargs

    # REF stuff
    self.name = metric_name
    self.slug = self.name.lower().replace(" ", "-")

    # Collect all variable IDs used by this diagnostic
    all_variable_ids = (
        self.variable_id,
        *ilamb_kwargs.get("alternate_vars", []),
        *ilamb_kwargs.get("related_vars", []),
        *ilamb_kwargs.get("relationships", {}).keys(),
    )

    # Variables that the primary diagnostic requires (not relationship vars)
    primary_variable_ids = (
        self.variable_id,
        *ilamb_kwargs.get("alternate_vars", []),
        *ilamb_kwargs.get("related_vars", []),
    )

    # Look up CMIP7 branded variable names
    branded_variables = _get_branded_variable(all_variable_ids, realm)

    # Determine realm/region for CMIP7 filter
    is_land = realm == "land"

    relationship_variable_ids = tuple(ilamb_kwargs.get("relationships", {}).keys())

    cmip7_primary_variable_ids = _get_cmip7_variable_ids(primary_variable_ids, realm)
    cmip7_relationship_variable_ids = _get_cmip7_variable_ids(relationship_variable_ids, realm)

    # Create the data requirement for the dataset under test
    cmip6_requirement = _build_cmip_data_requirement(
        source_type=SourceDatasetType.CMIP6,
        filters={
            "variable_id": all_variable_ids,
            "frequency": "mon",
            "experiment_id": ("historical", "esm-hist", "land-hist"),
            "table_id": (
                "AERmonZ",
                "Amon",
                "CFmon",
                "Emon",
                "EmonZ",
                "LImon",
                "Lmon",
                "Omon",
                "SImon",
            ),
        },
        group_by=("experiment_id", "source_id", "member_id", "grid_label"),
        primary_variable_ids=primary_variable_ids,
        relationship_variable_ids=relationship_variable_ids,
        is_land=is_land,
    )

    cmip7_requirement = _build_cmip_data_requirement(
        source_type=SourceDatasetType.CMIP7,
        filters={
            "branded_variable": branded_variables,
            "frequency": "mon",
            "experiment_id": ("historical", "esm-hist", "land-hist"),
            "region": "glb",
        },
        group_by=("experiment_id", "source_id", "variant_label", "grid_label"),
        primary_variable_ids=cmip7_primary_variable_ids,
        relationship_variable_ids=cmip7_relationship_variable_ids,
        is_land=is_land,
    )

    # Observational data requirement, normally ilamb3 expects the `sources` to
    # resolve to keys in one of its data registries. If instead we find a
    # dictionary, then assume that these keys are meant to be keywords in a
    # REF data requirement.
    # obs_source names where the test data is fetched from, the registry or ESGF.
    # It does not affect the requirement below.
    filters: dict[str, tuple[str, ...]] = {}
    obs_source = None
    for _, source in sources.items():
        if isinstance(source, dict):
            obs_source = source.pop("obs_source", None)
            for key, val in source.items():
                if key not in filters:
                    filters[key] = ()
                filters[key] += (val,)
    obs4mips_requirement = (
        DataRequirement(
            source_type=SourceDatasetType.obs4MIPs,
            filters=(FacetFilter(facets=filters),),
            group_by=tuple(filters.keys()),
            fallback_source_types=(SourceDatasetType.obs4REF,),
        )
        if filters
        else None
    )

    data_requirements: Sequence[Sequence[DataRequirement]]
    if obs4mips_requirement is None:
        data_requirements = (
            (cmip6_requirement,),
            (cmip7_requirement,),
        )
    else:
        data_requirements = (
            (cmip6_requirement, obs4mips_requirement),
            (cmip7_requirement, obs4mips_requirement),
        )
    self.data_requirements = data_requirements

    self.facets = (
        "experiment_id",
        "source_id",
        "member_id",
        "grid_label",
        "region",
        "metric",
        "statistic",
    )

    self.test_data_spec = _build_test_data_spec(
        all_variable_ids=all_variable_ids,
        realm=realm,
        test_source_id=test_source_id,
        is_land=is_land,
        obs_filters=filters,
        obs_source=obs_source,
    )

    # Setup ILAMB data and options
    self.realm = realm
    self.region_masks = region_masks
    self.ilamb_registry = ilamb_registry
    if self.ilamb_registry is not None:
        self.ilamb_data = registry_to_collection(dataset_registry_manager[self.ilamb_registry])
    else:
        # No ILAMB registry backs this diagnostic (all references on ESGF)
        # Degrade to an empty collection with the columns downstream code expects,
        # rather than crashing on a missing registry.
        self.ilamb_data = DatasetCollection(pd.DataFrame(columns=["key", "path"]), "key")

build_execution_result(definition) #

Build the diagnostic result after running ILAMB.

Parameters:

Name Type Description Default
definition ExecutionDefinition

The definition of the diagnostic execution

required

Returns:

Type Description
An execution result object
Source code in packages/climate-ref-ilamb/src/climate_ref_ilamb/standard.py
def build_execution_result(self, definition: ExecutionDefinition) -> ExecutionResult:
    """
    Build the diagnostic result after running ILAMB.

    Parameters
    ----------
    definition
        The definition of the diagnostic execution

    Returns
    -------
        An execution result object
    """
    _set_ilamb3_options(
        dataset_registry_manager[self.region_masks] if self.region_masks is not None else None
    )
    # In ILAMB, scalars are saved in CSV files in the output directory. To
    # be compatible with the REF system we will need to add the metadata
    # that is associated with the execution group, called the selector.
    df = _load_csv_and_merge(definition.output_directory)
    selectors = _get_selectors(definition.datasets)

    # TODO: Fix reference data once we are using the obs4MIPs dataset
    dataset_source = self.name.split("-")[1] if "-" in self.name else "None"
    common_dimensions = {**selectors, "reference_source_id": dataset_source}
    for key, value in common_dimensions.items():
        df[key] = value
    metric_bundle = CMECMetric.model_validate(_build_cmec_bundle(df))

    # Add each png file plot to the output.
    output_bundle = CMECOutput.create_template()
    for plotfile in sorted(definition.output_directory.glob("*.png")):
        relative_path = str(definition.as_relative_path(plotfile))
        caption, figure_dimensions = _caption_from_filename(plotfile, common_dimensions)

        output_bundle[OutputCV.PLOTS.value][relative_path] = {
            OutputCV.FILENAME.value: relative_path,
            OutputCV.LONG_NAME.value: caption,
            OutputCV.DESCRIPTION.value: "",
            OutputCV.DIMENSIONS.value: figure_dimensions,
        }

    # Register the scalar CSV files and the netCDF time-trace files in the data section so
    # they are persisted with the execution outputs and captured in the regression baseline;
    # build_execution_result re-reads them to reconstruct the metrics and series.
    _register_data_outputs(output_bundle, definition)

    # Add the html page to the output
    index_html = definition.to_output_path("index.html")
    if index_html.exists():
        relative_path = str(definition.as_relative_path(index_html))
        output_bundle[OutputCV.HTML.value][relative_path] = {
            OutputCV.FILENAME.value: relative_path,
            OutputCV.LONG_NAME.value: "Results page",
            OutputCV.DESCRIPTION.value: "Page displaying scalars and plots from the ILAMB execution.",
            OutputCV.DIMENSIONS.value: common_dimensions,
        }
        output_bundle[OutputCV.INDEX.value] = relative_path

    # Add series to the output based on the time traces we find in the
    # output files
    series = _build_series(definition.output_directory, dataset_source, common_dimensions)

    return ExecutionResult.build_from_output_bundle(
        definition, cmec_output_bundle=output_bundle, cmec_metric_bundle=metric_bundle, series=series
    )

execute(definition) #

Run the ILAMB standard analysis.

Source code in packages/climate-ref-ilamb/src/climate_ref_ilamb/standard.py
def execute(self, definition: ExecutionDefinition) -> None:
    """
    Run the ILAMB standard analysis.
    """
    _set_ilamb3_options(
        dataset_registry_manager[self.region_masks] if self.region_masks is not None else None
    )
    # The rewrite below is per-execution, but the provider shares one diagnostic instance
    # across executions, so copy rather than mutate `self`.
    ilamb_kwargs = {**self.ilamb_kwargs, "sources": dict(self.ilamb_kwargs["sources"])}
    # Temporary hack of the ilamb3 inputs while we still need to refer to
    # data not yet available in obs4{MIPs,REF}. This logic allows for
    # DataRequirement filters to be added as a 'source' in the ilamb
    # configure file. If a dictionary instead of a string was found, we
    # populate an observational requirement.
    if SourceDatasetType.obs4MIPs in definition.datasets:
        # ilamb3 will expect the reference dataset dataframe to have a `key`
        # column that uniquely describes each dataset. Create one using the
        # `instance_id` and an integer and then modify the ilamb3 configure
        # so that it finds the proper data.
        ref_datasets = definition.datasets[SourceDatasetType.obs4MIPs].datasets
        ref_datasets = ref_datasets.reset_index()
        ref_datasets["key"] = ref_datasets["instance_id"] + ref_datasets.index.astype(str)
        source_keys = {
            entry["variable_id"]: key
            for key, entry in ilamb_kwargs["sources"].items()
            if isinstance(entry, dict) and "variable_id" in entry
        }
        for instance_id, df in ref_datasets.groupby("instance_id"):
            variable_id = df["variable_id"].unique()[0]
            source_key = source_keys.get(variable_id, variable_id)
            ilamb_kwargs["sources"][source_key] = f"{instance_id}*"
        # Relationship analyses (and any remaining legacy string-path sources)
        # still refer to keys in the ILAMB/obs4REF registries.
        # Keep those keys in the reference dataframe alongside the ingested obs4MIPs datasets so
        # they remain resolvable when a diagnostic mixes obs4REF and registry data.
        ref_datasets = pd.concat(
            [
                ref_datasets,
                self.ilamb_data.datasets,
                registry_to_collection(dataset_registry_manager["obs4ref"]).datasets,
            ],
            ignore_index=True,
        )
    else:
        # If the data is not ingested yet but in a registry, we concat the
        # obs4REF registries to the ilamb one so that any key may be used
        # from either. Eventually (?) this can be removed.
        ref_datasets = pd.concat(
            [
                self.ilamb_data.datasets.set_index(self.ilamb_data.slug_column),
                registry_to_collection(dataset_registry_manager["obs4ref"]).datasets.set_index("key"),
            ]
        )
    cmip_source = _get_cmip_source_type(definition.datasets)
    model_datasets = definition.datasets[cmip_source].datasets

    # CMIP7 uses variant_label instead of member_id; ilamb3 expects member_id
    if cmip_source == SourceDatasetType.CMIP7 and "member_id" not in model_datasets.columns:
        model_datasets = model_datasets.copy()
        model_datasets["member_id"] = model_datasets["variant_label"]

    # When both a 3D primary variable (e.g. thetao) and its 2D alternate
    # (e.g. tos) are present, drop the primary so ilamb3 uses the alternate.
    # This avoids two problems with merging datasets of different time ranges:
    # 1) time_bnds gets NaN-filled for the shorter variable's missing steps,
    #    corrupting the time frequency calculation in ilamb3
    # 2) The 3D variable may not cover the reference period (e.g. thetao
    #    covers 1850-1860 but WOA2023 reference covers 2005-2014)
    # The alternate surface variable is equivalent after select_depth and
    # typically has full temporal coverage.
    alternate_vars = ilamb_kwargs.get("alternate_vars", [])
    if alternate_vars:
        available_alternates = [v for v in alternate_vars if v in model_datasets["variable_id"].values]
        if available_alternates and self.variable_id in model_datasets["variable_id"].values:
            model_datasets = model_datasets[model_datasets["variable_id"] != self.variable_id]

    # In ilamb3, this is run with all the models that we know about to
    # create different colors. For REF this will at least make the model
    # line color not be black
    run.set_model_colors(model_datasets)

    # Run ILAMB in a single-threaded mode to avoid issues with multithreading (#394)
    with dask.config.set(scheduler="synchronous"):
        run.run_single_block(
            self.slug,
            ref_datasets,
            model_datasets,
            definition.output_directory,
            **ilamb_kwargs,
        )

format_cmec_output_bundle(dataset, dimensions, metadata_columns, value_column='value') #

Create a CMEC output bundle for the dataset.

Parameters:

Name Type Description Default
dataset DataFrame

Processed dataset

required
dimensions list[str]

The dimensions of the dataset (e.g., ["source_id", "member_id", "region"])

required
metadata_columns list[str]

The columns to be used as metadata (e.g., ["Description", "LongName"])

required
value_column str

The column containing the values

'value'

Returns:

Type Description
A CMEC output bundle ready to be written to disk
Source code in packages/climate-ref-ilamb/src/climate_ref_ilamb/standard.py
def format_cmec_output_bundle(
    dataset: pd.DataFrame,
    dimensions: list[str],
    metadata_columns: list[str],
    value_column: str = "value",
) -> dict[str, Any]:
    """
    Create a CMEC output bundle for the dataset.

    Parameters
    ----------
    dataset
        Processed dataset
    dimensions
        The dimensions of the dataset (e.g., ["source_id", "member_id", "region"])
    metadata_columns
        The columns to be used as metadata (e.g., ["Description", "LongName"])
    value_column
        The column containing the values

    Returns
    -------
        A CMEC output bundle ready to be written to disk
    """
    # Validate that all required columns exist
    required_columns = set(dimensions) | {value_column} | set(metadata_columns)
    missing_columns = required_columns - set(dataset.columns)
    if missing_columns:
        raise ValueError(f"Missing required columns: {missing_columns}")

    # Build the dimensions section
    dimensions_dict: dict[str, dict[str, dict[str, str]]] = {}

    # For each dimension, create a dictionary of unique values and their metadata
    for dim in dimensions:
        unique_values = dataset[dim].unique()
        dim_dict: dict[str, dict[str, str]] = {}

        for val in unique_values:
            # Get the row for this dimension value

            dim_dict[str(val)] = {}

            if dim == dimensions[-1]:
                # Last dimension carries the value column's metadata.
                metadata = dataset[dataset[dim] == val].iloc[0][metadata_columns].to_dict()
                dim_dict[str(val)] = {column: str(value) for column, value in metadata.items()}

        dimensions_dict[dim] = dim_dict

    # Build the results section - create nested structure based on dimensions
    def nest_results(df: pd.DataFrame, dims: list[str]) -> dict[str, Any] | float:
        if not dims:
            return float(df[value_column].iloc[0].item())

        current_dim = dims[0]
        remaining_dims = dims[1:]

        return {
            str(group_name): nest_results(group_df, remaining_dims)
            for group_name, group_df in df.groupby(current_dim)
        }

    results = nest_results(dataset, list(dimensions))

    return {"DIMENSIONS": {"json_structure": list(dimensions), **dimensions_dict}, "RESULTS": results}