Source code for grid_process.blending

# Copyright (c) 2026 MeteoSwiss, contributors listed in AUTHORS
#
# Distributed under the terms of the BSD 3-Clause License.
#
# SPDX-License-Identifier: BSD-3-Clause

"""
Module for blending datasets together
"""

import logging
from collections.abc import Sequence

import numpy as np
import pandas as pd
import xarray as xr

from grid_process.helpers import replace_vars_attrs
from grid_process.data_model import get_t_dim, get_validtime, get_reftime
from grid_process.units_conv import consistency_check
from grid_process.timeseries import (
    setindex_validtime,
    interptogranularity,
    InterpMethods,
)

logger = logging.getLogger(__name__)


[docs] def combine_models(datasets: Sequence[xr.Dataset], strict_start: bool = True) -> xr.Dataset: """ Combine datasets in valid time dimension. The new coordinates follow the normal broadcasting and alignment rules of ``join='outer'``. Vacant cells in the expanded coordinates are filled with numpy.nan . Parameters ---------- datasets : sequence of xarray.Dataset Datasets with valid time or lead time dimension to be combined. The sequence must reflect priority order, so where several datasets overlap in time, the data of the first one is kept Typical temporal dimensions names are supported, and can be extended via environment variables: - Valid time: export GRID_PROCESS__data_model__validtime (e.g., '["time", "valid_time", "dummy_time"]') - Lead time: export GRID_PROCESS__data_model__leadtime (e.g., '["lead_time", "step", "dummy_leadtime"]') - When the temporal coordinates variables have a CF compliant `standard_name` attribute, arbitrary names can be used directly. strict_start : bool, optional If True (default), the resulting timeseries strictly starts with the dataset having highest priority (i.e. no older timestamps from lower priority datasets can be prepended) Returns ------- combined_ds : xarray.Dataset contains the full timeseries resulting from the datasets combination. Valid time dimension is labelled by numpy.datetime64 although inputs could have lead time dimension (numpy.timedelta64) See also -------- xarray.Dataset.combine_first, :func:`~grid_process.timeseries.setindex_validtime` Note ---- This function is not aimed to combine models that do not have the same grid and members, but to combine them after regridding/interpolation and ensemble statistic computations (quantiles, probabilities). A typical use case for this function could be to combine past deterministic analyses to all members of a NWP model as it will automatically broadcast it to the ensemble members dimension and pioritize analyses over the NWP forecast for common timestamps in both datasets. """ units_attrs = consistency_check(datasets, check_vars=False) datasets = [setindex_validtime(ds) for ds in datasets if ds.sizes[get_t_dim(ds)]] combined_attrs = {"source": blend_src_attr(datasets)} combined_ds = datasets[0].copy() combined_ds = combined_ds.drop_vars(get_reftime(combined_ds, "coord"), errors="ignore") # type: ignore[arg-type] vt_dim = get_validtime(combined_ds) vt_start = combined_ds[vt_dim][0].values if strict_start else None for ds in datasets[1:]: vt_dim_ds = get_validtime(ds) if vt_dim_ds != vt_dim: ds = ds.rename({vt_dim_ds: vt_dim}) if vt_start is not None: ds = ds.sel({vt_dim: slice(vt_start, None)}) ds = ds.drop_vars(get_reftime(ds, "coord"), errors="ignore") # type: ignore[arg-type] combined_ds = combined_ds.combine_first(ds) replace_vars_attrs(combined_ds, units_attrs) combined_ds.attrs = combined_attrs return combined_ds
[docs] def cleaver( # pylint: disable=too-many-locals ds_tail: xr.Dataset, ds_head: xr.Dataset, h: int = 6, interp_method: InterpMethods = "cubic", ) -> xr.Dataset: """ Combine two timeseries by applying a linear seamless transition from the last values of the tail to the remaining of the head. Parameters ---------- ds_tail : xarray.Dataset dataset with a valid time or lead time dimension. Contains the tail of the newly produced timeseries ds_head : xarray.Dataset dataset with a valid time or lead time dimension. Contains the head of the newly produced timeseries h : int, optional number of hours over which the seamless transition is done. Default is 6 interp_method : str arg passed directly to :func:`~grid_process.timeseries.interptogranularity` in case the tail and the head do not overlap. Default is 'cubic' Returns ------- seamless_ds : xarray.Dataset newly produced seamless timeseries from the two input datasets. Has a valid time dimension labelled by numpy.datetime64 although inputs could have a lead time dimension (numpy.timedelta64) See also -------- :func:`~grid_process.timeseries.interptogranularity` :func:`~grid_process.timeseries.setindex_validtime` """ units_attrs = consistency_check((ds_tail, ds_head)) combined_attrs = {"source": blend_src_attr([ds_tail, ds_head])} ds_tail = setindex_validtime(ds_tail) ds_tail = ds_tail.drop_vars(get_reftime(ds_tail, "coord"), errors="ignore") # type: ignore[arg-type] ds_head = setindex_validtime(ds_head) ds_head = ds_head.drop_vars(get_reftime(ds_head, "coord"), errors="ignore") # type: ignore[arg-type] vt_dim = get_validtime(ds_tail) vt_dim_head = get_validtime(ds_head) if vt_dim_head != vt_dim: ds_head = ds_head.rename({vt_dim_head: vt_dim}) overlap_t = ds_tail[vt_dim].where(ds_tail[vt_dim].isin(ds_head[vt_dim]), drop=True).values if overlap_t.size == 0: logger.warning( "No seamless transition is performed as no overlapping timestamps was found between the tail and the head" ) combined_ds = ds_tail.combine_first(ds_head) combined_ds.attrs = combined_attrs return interptogranularity(combined_ds, method=interp_method) last_t = overlap_t.max() h_td = np.timedelta64(h, "h") incr = np.timedelta64(1, "s") ds_clever = ds_head.sel({vt_dim: slice(last_t + incr, last_t + h_td - incr)}) factor = (1 - (ds_clever[vt_dim] - last_t) / h_td).astype(np.float32) last_t_pd = pd.to_datetime(last_t) # hack for bug with current xarray version delta_last_t = ds_tail.sel({vt_dim: last_t_pd}) - ds_head.sel({vt_dim: last_t_pd}) ds_clever += factor * delta_last_t seamless_ds = xr.concat( ( ds_tail.sel({vt_dim: slice(None, last_t)}), ds_clever, ds_head.sel({vt_dim: slice(last_t + h_td, None)}), ), dim=vt_dim, ) replace_vars_attrs(seamless_ds, units_attrs) seamless_ds.attrs = combined_attrs return seamless_ds
[docs] def blend_src_attr(datasets: Sequence[xr.Dataset]) -> dict[str, str]: """ Returns a dict with keys being the sources of the input datasets and values the corresponding reftimes. This dict can become the output ``source`` attribute when blending datasets together. """ src_dict = {} for ds in datasets: reftime_coord = get_reftime(ds, "coord") if isinstance(ds.source, dict): src_dict.update(ds.source) elif reftime_coord is not None: if len(ds[reftime_coord].dims) == 0: reftime = ds[reftime_coord].values else: reftime = ds[reftime_coord].values[-1] src_dict.update({ds.source: pd.to_datetime(reftime).isoformat()}) elif ds.source not in src_dict: src_dict.update({ds.source: None}) return src_dict