grib2io.xarray_backend

grib2io Backend Engine for Xarray

grib2io provides a Xarray backend entrypoint for decoding many GRIB2 messages from a single file or many files and represented as Xarray DataArray objects and collected along common coordinates as Datasets and DataTrees.

The grib2io.xarray_backend engine API is experimental. Its interface and behavior may change in future releases, which could affect backward compatibility.

Users are encouraged to treat this backend as subject to change and to pin their grib2io version if depending on its current implementation details.

   1"""
   2grib2io Backend Engine for Xarray
   3=================================
   4grib2io provides a Xarray backend entrypoint for decoding many GRIB2 messages
   5from a single file or many files and represented as Xarray DataArray objects and
   6collected along common coordinates as Datasets and DataTrees.
   7
   8.. warning::
   9
  10   The ``grib2io.xarray_backend`` engine API is **experimental**.
  11   Its interface and behavior may change in future releases,
  12   which could affect backward compatibility.
  13
  14   Users are encouraged to treat this backend as subject to change
  15   and to pin their ``grib2io`` version if depending on its current
  16   implementation details.
  17"""
  18from grib2io._grib2io import _data
  19from grib2io import Grib2Message, Grib2GridDef, msgs_from_index
  20import grib2io
  21from xarray.backends.locks import SerializableLock
  22from xarray.core import indexing
  23from xarray.backends import (
  24    BackendArray,
  25    BackendEntrypoint,
  26)
  27from copy import copy
  28from collections import defaultdict
  29from dataclasses import dataclass, field, astuple
  30import importlib.metadata
  31import itertools
  32import logging
  33import typing
  34import warnings
  35
  36from . import tables
  37
  38import numpy as np
  39import pandas as pd
  40import xarray as xr
  41import re
  42from pyproj import CRS
  43import datetime
  44
  45# Check if xarray version supports DataTree
  46_HAS_DATATREE = False
  47try:
  48    # Try importing DataTree to check if it's available
  49    xarray_version = importlib.metadata.version('xarray')
  50    xarray_parts = [int(x) if x.isdigit() else x for x in xarray_version.split('.')]
  51    min_version_parts = [2024, 10, 0]
  52    _HAS_DATATREE = xarray_parts >= min_version_parts
  53except (ImportError, ValueError):
  54    _HAS_DATATREE = False
  55
  56_logger = logging.getLogger(__name__)
  57
  58_LOCK = SerializableLock()
  59
  60_LEVEL_NAME_MAPPING = grib2io.tables.get_table('4.5.grib2io.level.name')
  61
  62_TREE_HIERARCHY_LEVELS = [
  63    "typeOfFirstFixedSurface",
  64    "valueOfFirstFixedSurface",
  65    "productDefinitionTemplateNumber",
  66    "perturbationNumber",
  67    "leadTime",
  68    "duration",
  69    "percentileValue",
  70    "typeOfProbability",
  71    "thresholdLowerLimit",
  72    "thresholdUpperLimit"
  73]
  74
  75AVAILABLE_NON_GEO_COORDS = [
  76    "duration",
  77    "leadTime",
  78    "percentileValue",
  79    "perturbationNumber",
  80    "refDate",
  81    "thresholdLowerLimit",
  82    "thresholdUpperLimit",
  83    "valueOfFirstFixedSurface",
  84    "valueOfSecondFixedSurface",
  85    "aerosolType",
  86    "scaledValueOfFirstWavelength",
  87    "scaledValueOfSecondWavelength",
  88    "scaledValueOfCentralWaveNumber",
  89    "scaledValueOfFirstSize",
  90    "scaledValueOfSecondSize"
  91]
  92"""Available non-geographic coordinate names."""
  93
  94AVAILABLE_NON_GEO_DIMS = [
  95    "duration",
  96    "leadTime",
  97    "percentileValue",
  98    "perturbationNumber",
  99    "refDate",
 100    "threshold",
 101    "level",
 102]
 103"""Available non-geographic dimension names."""
 104
 105# Lookup table to define surface types that should be parsed as vertical coordinates
 106VERTICAL_COORDINATE_SURFACES = [
 107    "Ground or Water Surface",
 108    "Isothermal Level",
 109    "Specified radius from the centre of the Sun",
 110    "Isobaric Surface",
 111    "Mean Sea Level",
 112    "Specific Altitude Above Mean Sea Level",
 113    "Specified Height Level Above Ground",
 114    "Sigma Level",
 115    "Hybrid Level",
 116    "Depth Below Land Surface",
 117    "Isentropic (theta) Level",
 118    "Level at Specified Pressure Difference from Ground to Level",
 119    "Potential Vorticity Surface",
 120    "Eta Level",
 121    "Logarithmic Hybrid Level",
 122    "Sigma height level",
 123    "Hybrid Height Level",
 124    "Hybrid Pressure Level",
 125    "Soil level",
 126    "Sea-ice level",
 127    "Depth Below Sea Level",
 128    "Depth Below Water Surface",
 129    "Ocean Model Level",
 130    "Ocean level defined by water density (sigma-theta) difference from near-surface to level",
 131    "Ocean level defined by water potential temperature difference from near-surface to level",
 132    "Ocean level defined by vertical eddy diffusivity difference from near-surface to level",
 133    "Ocean level defined by water density (rho) difference from near-surface to level"
 134]
 135"""
 136Lookup table to define surface types that should be parsed as vertical coordinates
 137when `data_model="nws-viz"`.
 138"""
 139
 140def parse_data_model(ds, data_model):
 141    """
 142    Normalize a GRIB2-derived Dataset to a target data model (currently ``"nws-viz"``).
 143
 144    When ``data_model == "nws-viz"``, this function converts coordinate and
 145    variable names to snake_case, derives CF-like metadata, promotes select
 146    GRIB-derived quantities to coordinates, optionally swaps dimensions, and
 147    standardizes units/attributes. If ``data_model`` is anything else, the
 148    input dataset is returned unchanged.
 149
 150    Parameters
 151    ----------
 152    ds : xarray.Dataset
 153        GRIB2-derived dataset whose variables and attributes follow the
 154        conventions emitted by ``grib2io``. Expected to contain GRIB-related
 155        attributes such as ``typeOfFirstFixedSurface``,
 156        ``typeOfSecondFixedSurface``, and (for probabilistic variables)
 157        ``typeOfProbability``.
 158    data_model : str
 159        Target data model name. Only the value ``"nws-viz"`` triggers
 160        transformations.
 161
 162    Returns
 163    -------
 164    xarray.Dataset
 165        A new dataset with:
 166        * Selected coordinates renamed:
 167          ``refDate -> forecast_reference_time``,
 168          ``leadTime -> lead_time``,
 169          ``validDate -> time``,
 170          ``percentileValue -> percentile``,
 171          ``thresholdLowerLimit -> threshold_lower_limit``,
 172          ``thresholdUpperLimit -> threshold_upper_limit``.
 173        * Vertical coordinates derived from
 174          ``valueOfFirstFixedSurface`` / ``valueOfSecondFixedSurface`` and their
 175          corresponding ``typeOf*FixedSurface`` definitions. New coordinate
 176          names are generated from the surface definition (lowercased, spaces
 177          to underscores, punctuation removed). If the name already exists, a
 178          ``"_2"`` suffix is appended.
 179        * Possible dimension swaps:
 180          ``level -> <derived_vertical_coord>`` when present; and for
 181          probabilistic variables, ``threshold -> threshold_lower_limit`` or
 182          ``threshold -> threshold_upper_limit`` when
 183          ``typeOfProbability`` indicates the appropriate semantics.
 184        * Variable names lowercased; dataset- and variable-level attributes
 185          converted to snake_case (except GRIB section attributes which are
 186          normalized to ``grib...``).
 187        * CF-adjacent metadata populated: ``standard_name`` and
 188          ``cell_methods`` are set via the shortname→CF lookup table.
 189        * Percent units normalized from ``"%"`` to ``"percent"`` on coordinates.
 190        * For precipitation type (``PTYPE``) thresholds, numeric codes are
 191          decoded to strings (GRIB2 Table 4.201) in relevant attrs/coords.
 192
 193    Notes
 194    -----
 195    - Precipitation type decoding uses GRIB2 Table 4.201 via
 196      ``tables.get_value_from_table(code, "4.201")`` and returns a NumPy
 197      array with ``np.dtypes.StringDType``.
 198    - CF-related lookups are performed using
 199      ``tables.get_table("shortname_to_cf")``.
 200    - Vertical coordinate surface names are validated against
 201      ``VERTICAL_COORDINATE_SURFACES`` before promotion to coordinates.
 202
 203    Warnings
 204    --------
 205    This function assumes the presence of certain GRIB-derived attributes on the
 206    first data variable (e.g., ``typeOfFirstFixedSurface``,
 207    ``typeOfSecondFixedSurface``, and possibly ``typeOfProbability``).
 208    If these are absent or malformed, errors (e.g., ``KeyError``) may occur.
 209
 210    Examples
 211    --------
 212    >>> ds2 = parse_data_model(ds, "nws-viz")
 213    >>> list(ds2.coords)
 214    ['forecast_reference_time', 'lead_time', 'time', 'percentile', ...]
 215    """
 216
 217    def _decode_ptype(values):
 218        """
 219        Decode precipitation type values into human-readable strings.
 220
 221        Uses GRIB2 Table 4.201 to map numeric codes to precipitation type descriptions.
 222
 223        Parameters
 224        ----------
 225        values : array_like
 226            Array of numeric precipitation type codes (e.g., integers or floats).
 227            Each value corresponds to a GRIB2 Table 4.201 precipitation type code.
 228
 229        Returns
 230        -------
 231        numpy.ndarray
 232            Array of decoded precipitation type strings with
 233            NumPy’s flexible string data type (`np.dtypes.StringDType`).
 234        """
 235        results = []
 236        for val in values:
 237            # Convert each numeric code to string and look up in Table 4.201
 238            results.append(str(tables.get_value_from_table(str(int(val)), '4.201')))
 239
 240        # Return array of strings using numpy's string data type
 241        return np.array(results, dtype=np.dtypes.StringDType)
 242
 243    # convert coordinates and attributes to CF if requested
 244    if data_model == 'nws-viz':
 245
 246        # define regex to convert to snake case
 247        pattern = re.compile(r'(?<!^)(?=[A-Z])')
 248
 249        # check for coordinates and rename
 250        for coord in ds.coords:
 251            if coord == 'refDate':
 252                ds = ds.rename({'refDate': 'forecast_reference_time'})
 253
 254            elif coord == 'leadTime':
 255                ds = ds.rename({'leadTime': 'lead_time'})
 256
 257            elif coord == 'validDate':
 258                ds = ds.rename({'validDate': 'time'})
 259
 260            elif coord == 'percentileValue':
 261                ds = ds.rename({'percentileValue': 'percentile'})
 262
 263            elif coord == 'thresholdLowerLimit':
 264                ds = ds.rename({'thresholdLowerLimit': 'threshold_lower_limit'})
 265                ds['threshold_lower_limit'].attrs['long_name'] = 'Threshold Lower Limit'
 266                ds['threshold_lower_limit'].attrs['units'] = ds[list(ds.data_vars.keys())[0]].attrs['units']
 267
 268                if 'PTYPE' in ds.data_vars:
 269                    ds['threshold_lower_limit'] = xr.apply_ufunc(_decode_ptype, ds['threshold_lower_limit'])
 270
 271                # check if thresholdLowerLimit should be a dimension coordinate
 272                if 'threshold' in ds.dims:
 273                    var_key = list(ds.data_vars.keys())[0]
 274                    prob_types = [
 275                        'Probability of event below lower limit',
 276                        'Probability of event above lower limit',
 277                        'Probability of event equal to lower limit',
 278                        'Probability of event between upper and lower limits (the range includes lower limit but not the upper limit)'
 279                    ]
 280                    if ds[var_key].attrs['typeOfProbability'] in prob_types:
 281                        ds = ds.swap_dims({'threshold': 'threshold_lower_limit'})
 282
 283            elif coord == 'thresholdUpperLimit':
 284                ds = ds.rename({'thresholdUpperLimit': 'threshold_upper_limit'})
 285                ds['threshold_upper_limit'].attrs['long_name'] = 'Threshold Upper Limit'
 286                ds['threshold_upper_limit'].attrs['units'] = ds[list(ds.data_vars.keys())[0]].attrs['units']
 287
 288                if 'PTYPE' in ds.data_vars:
 289                    ds['threshold_upper_limit'] = xr.apply_ufunc(_decode_ptype, ds['threshold_upper_limit'])
 290
 291                if 'threshold' in ds.dims:
 292                    var_key = list(ds.data_vars.keys())[0]
 293                    prob_types = [
 294                        'Probability of event below upper limit',
 295                        'Probability of event above upper limit'
 296                    ]
 297                    if ds[var_key].attrs['typeOfProbability'] in prob_types:
 298                        ds = ds.swap_dims({'threshold': 'threshold_upper_limit'})
 299
 300            # If the dataset has valueOfFirstFixedSurface as a coordinate
 301            elif coord == 'valueOfFirstFixedSurface':
 302                # Get the valueOfFirstFixedSurface coordinate
 303                da = ds.valueOfFirstFixedSurface
 304
 305                # Get the definition and units from typeOfFirstFixedSurface
 306                var_key = list(ds.data_vars.keys())[0]
 307                definition, units = ds[var_key].attrs['typeOfFirstFixedSurface']
 308
 309                if definition in VERTICAL_COORDINATE_SURFACES:
 310                    # Convert definition to lowercase and replace spaces with underscores
 311                    key = definition.lower().replace(' ', '_')
 312
 313                    # remove special characters
 314                    key = re.sub(r'[^a-z0-9_]', '', key)
 315
 316                    # Add units and grib_name attributes
 317                    da.attrs['units'] = units
 318                    da.attrs['grib_name'] = ['valueOfFirstFixedSurface', 'typeOfFirstFixedSurface']
 319
 320                    # Assign the coordinate with the new key name
 321                    ds = ds.assign_coords({key: da})
 322
 323                    # If valueOfFirstFixedSurface is a dimension, swap it with the new key
 324                    if 'level' in ds.dims:
 325                        ds = ds.swap_dims({"level": key})
 326
 327                # Remove the original coordinates
 328                del ds['valueOfFirstFixedSurface']
 329
 330            # If the dataset has valueOfSecondFixedSurface as a coordinate
 331            elif coord == 'valueOfSecondFixedSurface':
 332                # Get the valueOfSecondFixedSurface coordinate
 333                da = ds.valueOfSecondFixedSurface
 334
 335                # Get the definition and units from typeOfSecondFixedSurface
 336                var_key = list(ds.data_vars.keys())[0]
 337                definition, units = ds[var_key].attrs['typeOfSecondFixedSurface']
 338
 339                if definition in VERTICAL_COORDINATE_SURFACES:
 340                    # Convert definition to lowercase and replace spaces with underscores
 341                    key = definition.lower().replace(' ', '_')
 342
 343                    # remove special characters
 344                    key = re.sub(r'[^a-z0-9_]', '', key)
 345
 346                    # check if key is already in coords
 347                    if key in ds.coords:
 348                        key = key + '_2'
 349
 350                    # Add units and grib_name attributes
 351                    da.attrs['units'] = units
 352                    da.attrs['grib_name'] = ['valueOfSecondFixedSurface', 'typeOfSecondFixedSurface']
 353
 354                    # Assign the coordinate with the new key name
 355                    ds = ds.assign_coords({key: da})
 356
 357                # Remove the original coordinates
 358                del ds['valueOfSecondFixedSurface']
 359            else:
 360                # change coord name to snake case
 361                new_coord_name = pattern.sub('_', coord).lower()
 362                ds = ds.rename({coord: new_coord_name})
 363
 364        # convert all attributes and variable names to snake case
 365        for var in ds.data_vars:
 366            da = ds[var]
 367            record = tables.get_table('shortname_to_cf').get(da.name)
 368            da.attrs['standard_name'] = 'unknown' if record is None else record['cf_standard_name']
 369            da.attrs['cell_methods'] = 'unknown' if record is None else record['cf_cell_methods']
 370
 371            ds[var] = da
 372
 373            # rename variable
 374            new_var_name = var.lower()
 375            ds = ds.rename({var: new_var_name})
 376
 377            # remove attr for typeOfFirstFixedSurface (applied as coordinate above)
 378            if 'typeOfFirstFixedSurface' in ds[new_var_name].attrs:
 379                definition, units = ds[new_var_name].attrs['typeOfFirstFixedSurface']
 380                ds[new_var_name].attrs['typeOfFirstFixedSurface'] = f'{definition} ({units})'
 381
 382            if 'typeOfSecondFixedSurface' in ds[new_var_name].attrs:
 383                definition, units = ds[new_var_name].attrs['typeOfSecondFixedSurface']
 384                ds[new_var_name].attrs['typeOfSecondFixedSurface'] = f'{definition} ({units})'
 385
 386            ds[new_var_name].attrs.pop('percentileValue', None)
 387
 388            if 'threshold_lower_limit' in ds.coords:
 389                ds[new_var_name].attrs.pop('thresholdLowerLimit', None)
 390
 391            if 'threshold_upper_limit' in ds.coords:
 392                ds[new_var_name].attrs.pop('thresholdUpperLimit', None)
 393
 394            for attr in list(ds[new_var_name].attrs.keys()):
 395                # skip grib section attrs
 396                if 'GRIB2IO_section' in attr:
 397                    # replace GRIB2IO with grib in attr
 398                    new_attr_name = attr.replace('GRIB2IO', 'grib')
 399                else:
 400                    # change attr name to snake case
 401                    new_attr_name = pattern.sub('_', attr).lower()
 402
 403                # update new attr name for specific CF names
 404                if new_attr_name == 'full_name':
 405                    new_attr_name = 'long_name'
 406
 407                # change % to percent
 408                if attr == 'units' and ds[new_var_name].attrs[attr] == '%':
 409                    ds[new_var_name].attrs[attr] = 'percent'
 410
 411                if new_var_name == 'ptype' and 'threshold' in new_attr_name:
 412                    value = ds[new_var_name].attrs.pop(attr)
 413                    ds[new_var_name].attrs[attr] = _decode_ptype(value)
 414                else:
 415                    # change attr name in attrs
 416                    ds[new_var_name].attrs[new_attr_name] = ds[new_var_name].attrs.pop(attr)
 417
 418
 419        # change dataset attrs to snake case
 420        for attr in list(ds.attrs.keys()):
 421            # change attr name to snake case
 422            new_attr_name = pattern.sub('_', attr).lower()
 423
 424            # change attr name in attrs
 425            ds.attrs[new_attr_name] = ds.attrs.pop(attr)
 426
 427        # change % to percent
 428        for coord in ds.coords:
 429            if 'units' in ds[coord].attrs and ds[coord].attrs['units'] == '%':
 430                ds[coord].attrs['units'] = 'percent'
 431
 432    return ds
 433
 434
 435class GribBackendEntrypoint(BackendEntrypoint):
 436    """
 437    xarray backend engine entrypoint for opening and decoding grib2 files.
 438
 439    .. warning::
 440
 441       This backend is experimental and the API/behavior may change without
 442       backward compatibility.
 443    """
 444
 445    def open_dataset(
 446        self,
 447        filename,
 448        *,
 449        drop_variables=None,
 450        filters: typing.Mapping[str, typing.Any] = dict(),
 451        data_model=None
 452    ):
 453        """
 454        Read and parse metadata from grib file.
 455
 456        Parameters
 457        ----------
 458        filename
 459            GRIB2 file to be opened.
 460        filters
 461            Filter GRIB2 messages to single hypercube. Dict keys can be any
 462            GRIB2 metadata attribute name.
 463        data_model
 464            Parse GRIB metadata following a defined data model comvention.
 465
 466        Returns
 467        -------
 468        open_dataset
 469            Xarray dataset of grib2 messages.
 470        """
 471        with grib2io.open(filename, _xarray_backend=True) as f:
 472            file_index = pd.DataFrame(f._index)
 473            file_index = file_index.assign(msg=msgs_from_index(f._index))
 474
 475        # parse grib2io _index to dataframe and acquire non-geo possible dims
 476        # (scalar coord when not dim due to squeeze) parse_grib_index applies
 477        # filters to index and expands metadata based on product definition
 478        # template number
 479        file_index, dim_coords, attrs, coord_attrs = parse_grib_index(file_index, filters)
 480
 481        # Divide up records by variable
 482        frames, cube, extra_geo = make_variables(file_index, filename, dim_coords)  # have this return var_attrs
 483
 484        # return empty dataset if no data
 485        if frames is None:
 486            return xr.Dataset()
 487
 488        # create dataframe and add datarrays without any coords
 489        ds = xr.Dataset()
 490        for var_df in frames:
 491            da = build_da_without_coords(var_df, cube, filename, attrs)
 492            ds[da.name] = da
 493
 494        # add coords and dataset meta
 495        ds = assign_xr_meta(ds, frames, cube, dim_coords, extra_geo, coord_attrs)
 496
 497        if data_model is not None:
 498            ds = parse_data_model(ds, data_model)
 499
 500        # assign attributes
 501        ds.attrs['engine'] = 'grib2io'
 502
 503        return ds
 504
 505    def open_datatree(
 506        self,
 507        filename,
 508        *,
 509        drop_variables=None,
 510        filters: typing.Mapping[str, typing.Any] = None,
 511        stack_vertical: bool = False,
 512    ):
 513        """
 514        Open a GRIB2 file as an xarray DataTree.
 515
 516        Parameters
 517        ----------
 518        filename : str
 519            Path to the GRIB2 file.
 520        drop_variables : list, optional
 521            List of variables to exclude.
 522        filters : dict, optional
 523            Filter criteria for GRIB2 messages.
 524        stack_vertical : bool, optional
 525            If True, organize the tree with vertical layers stacked in a single dataset.
 526
 527        Returns
 528        -------
 529        xarray.DataTree
 530            A hierarchical DataTree representation of the GRIB2 data.
 531        """
 532        if not _HAS_DATATREE:
 533            raise ImportError("xarray version does not support DataTree functionality.")
 534
 535        if filters is None:
 536            filters = {}
 537
 538        # Open the file without any filters first to get all messages
 539        with grib2io.open(filename, _xarray_backend=True) as f:
 540            file_index = pd.DataFrame(f._index)
 541            file_index = file_index.assign(msg=msgs_from_index(f._index))
 542
 543        # Build tree structure from GRIB messages with specified options
 544        tree = build_datatree_from_grib(filename, file_index, filters, stack_vertical=stack_vertical)
 545
 546        # Put warning here so it is the last message from likely other Xarray warnings.
 547        warnings.warn(
 548            "grib2io’s xarray backend DataTree support is experimental. "
 549            "The DataTree structure or attributes may change in future releases.",
 550        UserWarning,
 551        stacklevel=2,
 552        )
 553
 554        return tree
 555
 556
 557class GribBackendArray(BackendArray):
 558
 559    def __init__(self, array, lock):
 560        self.array = array
 561        self.shape = array.shape
 562        self.dtype = np.dtype(array.dtype)
 563        self.lock = lock
 564
 565    def __getitem__(self, key: xr.core.indexing.ExplicitIndexer) -> np.typing.ArrayLike:
 566        return xr.core.indexing.explicit_indexing_adapter(
 567            key,
 568            self.shape,
 569            indexing.IndexingSupport.BASIC,
 570            self._raw_getitem,
 571        )
 572
 573    def _raw_getitem(self, key: tuple):
 574        """Implement thread safe access to data on disk."""
 575        with self.lock:
 576            return self.array[key]
 577
 578
 579def exclusive_slice_to_inclusive(item: slice):
 580    """
 581    Convert a slice with exclusive stop to an inclusive slice.
 582
 583    If the slice has a step, the stop is reduced by the step, so that both
 584    interpretations would yield the same result.
 585
 586    The means that [start, stop) is converted to [start, stop - step].
 587
 588    Parameters
 589    ----------
 590    item
 591        The slice to convert.
 592
 593    Returns
 594    -------
 595    slice
 596        The converted slice.
 597    """
 598    # return the None slice
 599    if item.start is None and item.stop is None and item.step is None:
 600        return item
 601    if not isinstance(item, slice):
 602        raise ValueError(f'item must be a slice; it was of type {type(item)}')
 603    # if step is None, it's one
 604    step = 1 if item.step is None else item.step
 605    if item.stop < item.start or step < 1:
 606        raise ValueError(f'slice {item} not accounted for')
 607    # handle case where slice has one item
 608    if abs(item.stop - item.start) == step:
 609        return [item.start]
 610    # other cases require reducing the stop by the step
 611    s = slice(item.start, item.stop - step, step)
 612    return s
 613
 614
 615class Validator:
 616    def __set_name__(self, owner, name):
 617        self.private_name = f'_{name}'
 618        self.name = name
 619
 620    def __get__(self, obj, objtype=None):
 621        try:
 622            value = getattr(obj, self.private_name)
 623        except AttributeError:
 624            value = None
 625        return value
 626
 627
 628class PdIndex(Validator):
 629
 630    def __set__(self, obj, value):
 631        try:
 632            value = pd.Index(value)
 633        except TypeError:
 634            value = pd.Index([value])
 635        setattr(obj, self.private_name, value)
 636
 637
 638def _asarray_tuplesafe(values):
 639    """
 640    Convert values to a numpy array of at most 1-dimension and preserve tuples.
 641
 642    Adapted from pandas.core.common._asarray_tuplesafe
 643    """
 644    if isinstance(values, tuple):
 645        result = np.empty(1, dtype=object)
 646        result[0] = values
 647    else:
 648        result = np.asarray(values)
 649        if result.ndim == 2:
 650            result = np.empty(len(values), dtype=object)
 651            result[:] = values
 652
 653    return result
 654
 655
 656def array_safe_eq(a, b) -> bool:
 657    """Check if a and b are equal, even if they are numpy arrays."""
 658    if a is b:
 659        return True
 660    if hasattr(a, 'equals'):
 661        return a.equals(b)
 662    if hasattr(a, 'all') and hasattr(b, 'all'):
 663        return a.shape == b.shape and (a == b).all()
 664    if hasattr(a, 'all') or hasattr(b, 'all'):
 665        return False
 666    try:
 667        return a == b
 668    except TypeError:
 669        return NotImplementedError
 670
 671
 672def dc_eq(dc1, dc2) -> bool:
 673    """Check if two dataclasses which hold numpy arrays are equal."""
 674    if dc1 is dc2:
 675        return True
 676    if dc1.__class__ is not dc2.__class__:
 677        return NotImplementedError
 678    t1 = astuple(dc1)
 679    t2 = astuple(dc2)
 680    return all(array_safe_eq(a1, a2) for a1, a2 in zip(t1, t2))
 681
 682
 683def coords_from_cube(cube) -> typing.Dict[str, xr.Variable]:
 684    keys = list(cube.keys())
 685    keys.remove('x')
 686    keys.remove('y')
 687    coords = dict()
 688    for k in keys:
 689        if k is not None:
 690            if len(cube[k]) > 1:
 691                coords[k] = xr.Variable(dims=k, data=cube[k], attrs=dict(grib_name=k))
 692            elif len(cube[k]) == 1:
 693                coords[k] = xr.Variable(dims=tuple(), data=cube[k][0], attrs=dict(grib_name=k))
 694    return coords
 695
 696
 697@dataclass
 698class OnDiskArray:
 699    file_name: str
 700    index: pd.DataFrame = field(repr=False)
 701    cube: dict = field(repr=False)
 702    shape: typing.Tuple[int, ...] = field(init=False)
 703    ndim: int = field(init=False)
 704    geo_ndim: int = field(init=False)
 705    dtype = 'float32'
 706
 707    def __post_init__(self):
 708        # multiple grids not allowed so can just use first
 709        geo_shape = (self.index.iloc[0].ny, self.index.iloc[0].nx)
 710
 711        self.geo_shape = geo_shape
 712        self.geo_ndim = len(geo_shape)
 713
 714        if len(self.index) == 1:
 715            self.shape = geo_shape
 716        else:
 717            if self.index.index.nlevels == 1:
 718                self.shape = tuple([len(self.index.index)]) + geo_shape
 719            else:
 720                self.shape = tuple([len(i) for i in self.index.index.levels]) + geo_shape
 721        self.ndim = len(self.shape)
 722
 723        cols = ['msg', 'sectionOffset']
 724        self.index = self.index[cols]
 725
 726    def __getitem__(self, item) -> np.array:
 727        # dimensions not in index are internal to tdlpack records; 2 dims for
 728        # grids; 1 dim for stations
 729
 730        index_slicer = item[:-self.geo_ndim]
 731        # maintain all multindex levels
 732        index_slicer = tuple([[i] if isinstance(i, int) else i for i in index_slicer])
 733
 734        # pandas loc slicing is inclusive, therefore convert slices into
 735        # explicit lists
 736        index_slicer_inclusive = tuple([exclusive_slice_to_inclusive(
 737            i) if isinstance(i, slice) else i for i in index_slicer])
 738
 739        # get records selected by item in new index dataframe
 740        if len(index_slicer_inclusive) == 1:
 741            index = self.index.loc[index_slicer_inclusive]
 742        elif len(index_slicer_inclusive) > 1:
 743            index = self.index.loc[index_slicer_inclusive, :]
 744        else:
 745            index = self.index
 746        index = index.set_index(index.index)
 747
 748        # set miloc to new relative locations in sub array
 749        index['miloc'] = list(
 750            zip(*[index.index.unique(level=dim).get_indexer(index.index.get_level_values(dim)) for dim in index.index.names]))
 751
 752        if len(index_slicer_inclusive) == 1:
 753            array_field_shape = tuple([len(index.index)]) + self.geo_shape
 754        elif len(index_slicer_inclusive) > 1:
 755            array_field_shape = index.index.levshape + self.geo_shape
 756        else:
 757            array_field_shape = self.geo_shape
 758
 759        array_field = np.full(array_field_shape, fill_value=np.nan, dtype="float32")
 760
 761        with open(self.file_name, mode='rb') as filehandle:
 762            for key, row in index.iterrows():
 763
 764                bitmap_offset = None if pd.isna(row['sectionOffset'][6]) else int(row['sectionOffset'][6])
 765                values = _data(filehandle, row.msg, bitmap_offset, row['sectionOffset'][7])
 766
 767                if len(index_slicer_inclusive) >= 1:
 768                    array_field[row.miloc] = values
 769                else:
 770                    array_field = values
 771
 772        # handle geo dim slicing
 773        array_field = array_field[(Ellipsis,) + item[-self.geo_ndim:]]
 774
 775        # squeeze array dimensions expressed as integer
 776        for i, it in reversed(list(enumerate(item[: -self.geo_ndim]))):
 777            if isinstance(it, int):
 778                array_field = array_field[(slice(None, None, None),) * i + (0,)]
 779
 780        return array_field
 781
 782
 783def dims_to_shape(d) -> tuple:
 784    if 'nx' in d:
 785        t = (d['ny'], d['nx'])
 786    else:
 787        t = (d['nsta'],)
 788    return t
 789
 790
 791def filter_index(index, k, v):
 792    if isinstance(v, slice):
 793        index = index.set_index(k)
 794        index = index.loc[v]
 795        index = index.reset_index()
 796    else:
 797        label = (
 798            v
 799            if getattr(v, "ndim", 1) > 1  # vectorized-indexing
 800            else _asarray_tuplesafe(v)
 801        )
 802        if label.ndim == 0:
 803            # see https://github.com/pydata/xarray/pull/4292 for details
 804            label_value = label[()] if label.dtype.kind in "mM" else label.item()
 805            try:
 806                indexer = pd.Index(index[k]).get_loc(label_value)
 807                if isinstance(indexer, int):
 808                    index = index.iloc[[indexer]]
 809                else:
 810                    index = index.iloc[indexer]
 811            except KeyError:
 812                index = index.iloc[[]]
 813        else:
 814            indexer = pd.Index(index[k]).get_indexer_for(np.ravel(v))
 815            index = index.iloc[indexer[indexer >= 0]]
 816
 817    return index
 818
 819
 820def parse_grib_index(
 821    index: pd.DataFrame,
 822    filters: typing.Mapping[str, typing.Any] = dict(),
 823):
 824    """
 825    Apply filters.
 826
 827    Evaluate remaining dimensions based on pdtn and parse each out.
 828
 829    Parameters
 830    ----------
 831    index
 832        Pandas DataFrame containing the GRIB2 message index.
 833    filters
 834        Filter GRIB2 messages to single hypercube. Dict keys can be any
 835        GRIB2 metadata attribute name.
 836
 837    Returns
 838    -------
 839    index
 840        Modified Pandas DataFrame with added GRIB2 metadata columns.
 841    dim_coords
 842        List of GRIB2 attributes that will be used for coordinates and/or dimensions.
 843    attrs
 844        Dict of metadata attributes (non-coordinates, non-geo)
 845    """
 846
 847    # make a copy of filters, remove filters as they are applied
 848    filters = copy(filters)
 849
 850    for k, v in filters.items():
 851        if k not in index.columns:
 852            kwarg = {k: index.msg.apply(lambda msg: getattr(msg, k))}
 853            index = index.assign(**kwarg)
 854        # adopt parts of xarray's sel logic  so that filters behave similarly
 855        # allowed to filter to nothing to make empty dataset
 856        index = filter_index(index, k, v)
 857
 858    if len(index) == 0:
 859        return index, list()
 860
 861    dim_coords = dict()  # key=name of dim, value=list of coord names
 862    attrs = dict()
 863    coord_attrs = dict()
 864
 865    # expand index
 866    index = index.assign(shortName=index.msg.apply(lambda msg: msg.shortName))
 867    index = index.assign(nx=index.msg.apply(lambda msg: msg.nx))
 868    index = index.assign(ny=index.msg.apply(lambda msg: msg.ny))
 869    index = index.astype({'ny': 'int', 'nx': 'int'})
 870
 871    # apply common filters(to all definition templates) to reduce dataset to
 872    # single cube
 873    # ensure only one of each of the below exists after filters applied
 874    required_uniques = [
 875        "productDefinitionTemplateNumber",
 876        "typeOfGeneratingProcess",
 877        "typeOfFirstFixedSurface",
 878        "typeOfSecondFixedSurface",
 879    ]
 880
 881    def meta_check(index, attrs, meta):
 882        """
 883        add meta to the datframe index
 884        check that there is a single type
 885        add the type to attrs
 886
 887        returns index, attrs
 888        """
 889        index = index.assign(**{meta: index.msg.apply(lambda msg: getattr(msg, meta))})
 890
 891        unique = index[meta].unique()
 892        if len(index[meta].unique()) > 1:
 893            raise ValueError(f'filter to a single {meta}; found: {[str(i) for i in unique]}')
 894        value = unique.item()
 895        if type(value) == grib2io.templates.Grib2Metadata:
 896            value = value.definition
 897
 898        # None is returned if no value found,
 899        # check and change to string None
 900        if value is None:
 901            value = 'None'
 902
 903        attrs[meta] = value
 904        return index, attrs
 905
 906    for meta in required_uniques:
 907        index, attrs = meta_check(index, attrs, meta)
 908
 909    pdtn = index.productDefinitionTemplateNumber.iloc[0].value
 910
 911    # determine which non geo dimensions can be created from data by this point
 912    # the index is filtered down to a single type for all required_uniques
 913
 914    # Dim Name     # matching dim_name for using this data as index coordinate
 915    dim_coords["refDate"] = ["refDate"]
 916    coord_attrs["refDate"] = dict(standard_name="forecast_reference_time")
 917#   dim_coords["refDate"] = ["refDate", "hour"] # non dim name matching items in list are used as non-index coordinates
 918
 919    dim_coords["leadTime"] = ["leadTime"]
 920    coord_attrs["leadTime"] = dict(standard_name="forecast_period")
 921
 922    if 'valueOfFirstFixedSurface' not in index.columns:
 923        index = index.assign(valueOfFirstFixedSurface=index.msg.apply(lambda msg: msg.valueOfFirstFixedSurface))
 924    if 'valueOfsecondFixedSurface' not in index.columns:
 925        index = index.assign(valueOfSecondFixedSurface=index.msg.apply(lambda msg: msg.valueOfSecondFixedSurface))
 926
 927    # dim name api change, user could run ds = ds.swap_dims(fixedSurface="valueOfFirstFixedSurface")
 928    index = index.assign(level=list(zip(index['valueOfFirstFixedSurface'], index['valueOfSecondFixedSurface'])))
 929#   index = index.assign(level=index.msg.apply(lambda msg: msg.level))
 930    # lack of "level" indeicates don't create extra index coordinate "level"
 931    dim_coords["level"] = ["valueOfFirstFixedSurface", "valueOfSecondFixedSurface"]
 932
 933    # logic for parsing possible dims from specific product definition section
 934
 935    if pdtn in {5, 9}:
 936
 937        # Probability forecasts at a horizontal level or in a horizontal layer
 938        # in a continuous or non-continuous time interval.  (see Template
 939        # 4.9)
 940        #       AVAILABLE_THRESHOLD = {
 941        #           0: {'has_lower': True, 'has_upper': False},
 942        #           1: {'has_lower': False, 'has_upper': True},
 943        #           2: {'has_lower': True, 'has_upper': True},
 944        #           3: {'has_lower': True, 'has_upper': False},
 945        #           4: {'has_lower': False, 'has_upper': True},
 946        #           5: {'has_lower': True, 'has_upper': False},
 947        #       }
 948
 949        index, attrs = meta_check(index, attrs, "typeOfProbability")
 950        if 'thresholdLowerLimit' not in index.columns:
 951            index = index.assign(thresholdLowerLimit=index.msg.apply(lambda msg: msg.thresholdLowerLimit))
 952        if 'thresholdUpperLimit' not in index.columns:
 953            index = index.assign(thresholdUpperLimit=index.msg.apply(lambda msg: msg.thresholdUpperLimit))
 954        if 'threshold' not in index.columns:
 955            # using composite of lower and upper, but could use threshold string from grib2io as long as that is unique and based on lower and upper
 956            index = index.assign(threshold=list(zip(index['thresholdLowerLimit'], index['thresholdUpperLimit'])))
 957#           index = index.assign(threshold = index.msg.apply(lambda msg: msg.threshold))
 958
 959        # ommiting threshold results in no index being assigned for this possible dim
 960        dim_coords["threshold"] = ["thresholdLowerLimit", "thresholdUpperLimit"]
 961
 962    if pdtn in {6, 10}:
 963
 964        # Percentile forecasts at a horizontal level or in a horizontal layer
 965        # in a continuous or non-continuous time interval.  (see Template
 966        # 4.10)
 967        dim_coords["percentileValue"] = ["percentileValue"]
 968        coord_attrs["percentileValue"] = dict(long_name='percentile', units='percent')
 969
 970    if pdtn in {8, 9, 10, 11, 12, 13, 14, 42, 43, 45, 46, 47, 61, 62, 63, 67, 68, 72, 73, 78, 79, 82, 83, 84, 85, 87, 91}:
 971        dim_coords["duration"] = ["duration"]
 972
 973    if pdtn in {1, 11, 33, 34, 41, 43, 45, 47, 49, 54, 56, 58, 59, 63, 68, 77, 79, 81, 83, 84, 85, 92}:
 974        dim_coords["perturbationNumber"] = ["perturbationNumber"]
 975
 976    if pdtn in {2,3,4,12,13,14}:
 977        index, attrs = meta_check(index, attrs, 'typeOfDerivedForecast')
 978
 979    if pdtn in {8,15,42,46,62,67,72,78,82,1001,1002,1100,1101}:
 980        index, attrs = meta_check(index, attrs, 'statisticalProcess')
 981
 982    # Finish logic by pdtn
 983
 984    for k, v in dim_coords.items():
 985        for meta in v:
 986            if meta not in index.columns:
 987                index = index.assign(**{meta: index.msg.apply(lambda msg: getattr(msg, meta))})
 988
 989    return index, dim_coords, attrs, coord_attrs
 990
 991
 992# Custom open_datatree function to open grib files as DataTree
 993def open_datatree(filename, *, filters: typing.Mapping[str, typing.Any] = None, engine="grib2io"):
 994    """
 995    Open a GRIB2 file as an xarray DataTree.
 996
 997    Parameters
 998    ----------
 999    filename : str
1000        Path to the GRIB2 file.
1001    filters : dict, optional
1002        Filter criteria for GRIB2 messages.
1003    engine : str, optional
1004        Engine to use for opening the file, defaults to "grib2io".
1005
1006    Returns
1007    -------
1008    xarray.DataTree
1009        A hierarchical DataTree representation of the GRIB2 data.
1010    """
1011    if not _HAS_DATATREE:
1012        raise ImportError("xarray version does not support DataTree functionality.")
1013
1014    if filters is None:
1015        filters = {}
1016
1017    # Open the file without any filters first to get all messages
1018    with grib2io.open(filename, _xarray_backend=True) as f:
1019        file_index = pd.DataFrame(f._index)
1020
1021    # Create a DataTree root
1022    tree = xr.DataTree()
1023
1024    # Build tree structure from GRIB messages
1025    return build_datatree_from_grib(filename, file_index, filters)
1026
1027
1028def build_da_without_coords(index, cube, filename, attrs) -> xr.DataArray:
1029    """
1030    Build a DataArray without coordinates from a cube of grib2 messages.
1031
1032    Parameters
1033    ----------
1034    index
1035        Index of cube.
1036    cube
1037        Cube of grib2 messages.
1038    filename
1039        Filename of grib2 file
1040    add_grib_section_attrs
1041        Include grib section arrays as dataArray attributes
1042
1043    Returns
1044    -------
1045    DataArray
1046        DataArray without coordinates
1047    """
1048
1049    dim_names = [k for k in cube.keys() if cube[k] is not None and len(cube[k]) > 1]
1050    constant_meta_names = [k for k in cube.keys() if cube[k] is None]
1051    dims = {k: len(cube[k]) for k in dim_names}
1052
1053    # guard against bad datarrays being formed
1054    dims_total = 1
1055    dims_to_filter = []
1056    for dim_name, dim_len, in dims.items():
1057        if dim_name not in {'x', 'y', 'station'}:
1058            dims_total *= dim_len
1059            dims_to_filter.append(dim_name)
1060
1061    # Check number of GRIB2 message indexed compared to non-X/Y
1062    # dimensions.
1063    if dims_total != len(index):
1064        raise ValueError(
1065            f"DataArray dimensions are not compatible with number of GRIB2 messages; DataArray has {dims_total} "
1066            f"and GRIB2 index has {len(index)}. Consider applying a filter for dimensions: {dims_to_filter}"
1067        )
1068
1069    data = OnDiskArray(filename, index, cube)
1070    lock = _LOCK
1071    data = GribBackendArray(data, lock)
1072    data = indexing.LazilyIndexedArray(data)
1073    if len(dim_names) != len(data.shape):
1074        raise ValueError(
1075            "different number of dimensions on data "
1076            f"and dims: {len(data.shape)} vs {len(dim_names)}\n"
1077            "Grib2 messages could not be formed into a data cube; "
1078            "It's possible extra messages exist along a non-accounted for dimension based on PDTN\n"
1079            "It might be possible to get around this by applying a filter on the non-accounted for dimension"
1080        )
1081    da = xr.DataArray(data, dims=dim_names)
1082
1083    da.encoding['original_shape'] = data.shape
1084
1085    da.encoding['preferred_chunks'] = {'y': -1, 'x': -1}
1086    msg1 = index.msg.iloc[0]
1087
1088    # plain language metadata is minimized
1089    # add grib section metadata
1090    da.attrs['GRIB2IO_section0'] = msg1.section0
1091    da.attrs['GRIB2IO_section1'] = msg1.section1
1092    da.attrs['GRIB2IO_section2'] = msg1.section2 if msg1.section2 else []
1093    da.attrs['GRIB2IO_section3'] = msg1.section3
1094    da.attrs['GRIB2IO_section4'] = msg1.section4
1095    da.attrs['GRIB2IO_section5'] = msg1.section5
1096    da.attrs['fullName'] = str(msg1.fullName)
1097    da.attrs['shortName'] = str(msg1.shortName)
1098    da.attrs['units'] = str(msg1.units)
1099    da.attrs['originatingCenter'] = str(msg1.originatingCenter.definition)
1100    da.attrs['originatingSubCenter'] = str(msg1.originatingSubCenter.definition)
1101
1102    # add master table
1103    da.attrs['masterTableInfo'] = str(msg1.masterTableInfo.definition)
1104
1105    da.name = index.shortName.iloc[0]
1106    for meta_name in constant_meta_names:
1107        if meta_name in index.columns:
1108            da.attrs[meta_name] = index[meta_name].iloc[0]
1109
1110    da.attrs.update(attrs)
1111
1112    return da
1113
1114
1115def assign_xr_meta(ds, frames, cube, non_geo_dims, extra_geo, coord_attrs):
1116
1117    # assign coords from the cube; the cube prevents datarrays with
1118    # different shapes
1119    ds = ds.assign_coords(coords_from_cube(cube))
1120    # assign extra index associated coords
1121    df = frames[0]  # use first variable as they all have same shape and index metadata
1122    for dim_name, coord_names in non_geo_dims.items():
1123        retain_index_coord = False
1124        for name in coord_names:
1125            if name == dim_name:
1126                retain_index_coord = True
1127            else:
1128                if ds[dim_name].size == 1:
1129                    # for assigning scalar coords
1130                    coord_data = [df[name].unique().item()]
1131                    ds = ds.assign_coords({name: coord_data}).squeeze()
1132                else:
1133                    # "ValueError: can only convert an array of size 1 to a Python scalar" indicates the coord is not compatible with the index
1134                    coord_data = [df[df.index.get_level_values(f'{dim_name}_ix') == val][name].unique(
1135                    ).item() for val in range(ds[dim_name].size)]
1136                    coord = pd.Index(coord_data, name=dim_name)
1137                    ds = ds.assign_coords({name: coord})
1138        if not retain_index_coord:
1139            ds = ds.drop_vars(dim_name)
1140
1141    # assign extra geo coords
1142    ds = ds.assign_coords(extra_geo)
1143    # add crs data from first grib message to each data variable and the dataset
1144    geo_attrs = {
1145        'crs_wkt': CRS.from_dict(df.msg.iloc[0].projParameters).to_wkt(),
1146        'gridlengthXDirection': df.msg.iloc[0].gridlengthXDirection,
1147        'gridlengthYDirection': df.msg.iloc[0].gridlengthYDirection,
1148        'latitudeFirstGridpoint': df.msg.iloc[0].latitudeFirstGridpoint,
1149        'longitudeFirstGridpoint': df.msg.iloc[0].longitudeFirstGridpoint,
1150    }
1151    for data_var in ds.data_vars:
1152        ds[data_var].attrs.update(geo_attrs)
1153    ds.attrs.update(geo_attrs)
1154
1155    # add coordinate specific attributes
1156    for coord, attrs in coord_attrs.items():
1157        ds[coord].attrs.update(attrs)
1158
1159    # assign valid date coords
1160    ds = ds.assign_coords(dict(validDate=ds.coords['refDate']+ds.coords['leadTime']))
1161    ds.validDate.attrs['standard_name'] = 'time'
1162    ds.validDate.attrs['long_name'] = 'time'
1163
1164    # assign attributes
1165    ds.attrs['engine'] = 'grib2io'
1166
1167    return ds
1168
1169
1170def make_variables(index, f, non_geo_dims, allow_uneven_dims=False):
1171    """
1172    Create an individual dataframe index and cube for each variable.
1173
1174    Parameters
1175    ----------
1176    index
1177        Index of cube.
1178    f
1179        ?
1180    non_geo_dims
1181        Dimensions not associated with the x,y grid
1182    allow_uneven_dims
1183        If True, allows uneven dimensions (used for DataTree creation)
1184
1185    Returns
1186    -------
1187    ordered_frames
1188        List of dataframes, one for each variable.
1189    cube
1190        Cube of grib2 messages.
1191    extra_geo
1192        Extra geographic coordinates.
1193    """
1194    # let shortName determine the variables
1195
1196    # set the index to the name
1197    index = index.set_index('shortName').sort_index()
1198    # return nothing if no data
1199    if index.empty:
1200        return None, None, None
1201
1202    # define the DimCube
1203    dims = copy(non_geo_dims)
1204
1205    ordered_meta = list(non_geo_dims.keys())
1206    cube = None
1207    ordered_frames = list()
1208    for key in index.index.unique():
1209        frame = index.loc[[key]]
1210        frame = frame.reset_index()
1211        # frame is a dataframe with all records for one variable
1212        c = dict()
1213        # for colname in frame.columns:
1214        for colname in ordered_meta:
1215            uniques = pd.Index(frame[colname]).unique()
1216            if len(uniques) > 1:
1217                c[colname] = uniques.sort_values()
1218            else:
1219                c[colname] = [uniques[0]]
1220
1221        dims = [k for k in ordered_meta if len(c[k]) > 1]
1222
1223        for dim in dims:
1224            if frame[dim].value_counts().nunique() > 1 and not allow_uneven_dims:
1225                raise ValueError(
1226                    f'uneven number of grib msgs associated with dimension: {dim}\n unique values for {dim}: {frame[dim].unique()} ')
1227
1228        if len(dims) >= 1:  # dims may be empty if no extra dims on top of x,y
1229            frame = frame.sort_values(dims)
1230            frame = frame.set_index(dims)
1231
1232        if cube:
1233            if cube != c and not allow_uneven_dims:
1234                raise ValueError(f'{cube},\n {c};\n cubes are not the same; filter to a single cube')
1235        else:
1236            cube = c
1237
1238        # miloc is multi-index integer location of msg in nd DataArray
1239        miloc = list(zip(*[frame.index.unique(level=dim).get_indexer(frame.index.get_level_values(dim))
1240                     for dim in dims]))
1241
1242        # set frame multi index
1243        if len(miloc) >= 1:  # miloc will be empty when no extra dims, thus no multiindex
1244            dim_ix = tuple([n+'_ix' for n in dims])
1245            frame = frame.set_index(pd.MultiIndex.from_tuples(miloc, names=dim_ix))
1246
1247        ordered_frames.append(frame)
1248
1249    # no variables
1250    if cube is None:
1251        cube = dict()
1252
1253    # check geography of data and assign to cube
1254    if len(index.ny.unique()) > 1 or len(index.nx.unique()) > 1:
1255        raise ValueError('multiple grids not accommodated')
1256    cube["y"] = range(int(index.ny.iloc[0]))
1257    cube["x"] = range(int(index.nx.iloc[0]))
1258
1259    extra_geo = None
1260    msg = index.msg.iloc[0]
1261
1262    # we want the lat lons; make them via accessing a record; we are assuming
1263    # all records are the same grid because they have the same shape;
1264    # may want a unique grid identifier from grib2io to avoid assuming this
1265    latitude, longitude = msg.latlons()
1266    latitude = xr.DataArray(latitude, dims=['y', 'x'])
1267    latitude.attrs['standard_name'] = 'latitude'
1268    latitude.attrs['units'] = 'degrees_north'
1269    longitude = xr.DataArray(longitude, dims=['y', 'x'])
1270    longitude.attrs['standard_name'] = 'longitude'
1271    longitude.attrs['units'] = 'degrees_east'
1272    extra_geo = dict(latitude=latitude, longitude=longitude)
1273
1274    return ordered_frames, cube, extra_geo
1275
1276
1277def interp_nd(a, *, method, grid_def_in, grid_def_out, method_options=None, num_threads=1):
1278    front_shape = a.shape[:-2]
1279    a = a.reshape(-1, a.shape[-2], a.shape[-1])
1280    a = grib2io.interpolate(a, method, grid_def_in, grid_def_out, method_options=method_options,
1281                            num_threads=num_threads)
1282    a = a.reshape(front_shape + (a.shape[-2], a.shape[-1]))
1283    return a
1284
1285
1286def interp_nd_stations(a, *, method, grid_def_in, lats, lons, method_options=None, num_threads=1):
1287    front_shape = a.shape[:-2]
1288    a = a.reshape(-1, a.shape[-2], a.shape[-1])
1289    a = grib2io.interpolate_to_stations(a, method, grid_def_in, lats, lons, method_options=method_options,
1290                                        num_threads=num_threads)
1291    a = a.reshape(front_shape + (len(lats),))
1292    return a
1293
1294
1295@xr.register_dataset_accessor("grib2io")
1296class Grib2ioDataSet:
1297
1298    def __init__(self, xarray_obj):
1299        self._obj = xarray_obj
1300
1301    def griddef(self):
1302        return Grib2GridDef.from_section3(self._obj[list(self._obj.data_vars)[0]].attrs['GRIB2IO_section3'])
1303
1304    def interp(self, method, grid_def_out, method_options=None, num_threads=1) -> xr.Dataset:
1305        # see interp method of class Grib2ioDataArray
1306        da = self._obj.to_array()
1307        da.attrs['GRIB2IO_section3'] = self._obj[list(self._obj.data_vars)[0]].attrs['GRIB2IO_section3']
1308        da = da.grib2io.interp(method, grid_def_out, method_options=method_options,
1309                               num_threads=num_threads)
1310        ds = da.to_dataset(dim='variable')
1311        return ds
1312
1313    def interp_to_stations(self, method, calls, lats, lons, method_options=None, num_threads=1) -> xr.Dataset:
1314        # see interp_to_stations method of class Grib2ioDataArray
1315        da = self._obj.to_array()
1316        da.attrs['GRIB2IO_section3'] = self._obj[list(self._obj.data_vars)[0]].attrs['GRIB2IO_section3']
1317        da = da.grib2io.interp_to_stations(method, calls, lats, lons, method_options=method_options,
1318                                           num_threads=num_threads)
1319        ds = da.to_dataset(dim='variable')
1320        return ds
1321
1322    def to_grib2(self, filename, mode: typing.Literal["x", "w", "a"] = "x"):
1323        """
1324        Write a DataSet to a grib2 file.
1325
1326        Parameters
1327        ----------
1328        filename
1329            Name of the grib2 file to write to.
1330        mode: {"x", "w", "a"}, optional, default="x"
1331            Persistence mode
1332
1333            | mode | Description                       |
1334            | :---:| :---:                             |
1335            | 'x'  | create (fail if exists)           |
1336            | 'w'  | create (overwrite if exists)      |
1337            | 'a'  | append (create if does not exist) |
1338
1339        """
1340        ds = self._obj
1341
1342        for shortName in sorted(ds):
1343            # make a DataArray from the "Data Variables" in the DataSet
1344            da = ds[shortName]
1345
1346            da.grib2io.to_grib2(filename, mode=mode)
1347            mode = "a"
1348
1349    def update_attrs(self, **kwargs):
1350        """
1351        Raises an error because Datasets don't have a .attrs attribute.
1352
1353        Parameters
1354        ----------
1355        attrs
1356            Attributes to update.
1357        """
1358        raise ValueError(
1359            f"Datasets do not have a .attrs attribute; use .grib2io.update_attrs({kwargs}) on a DataArray instead."
1360        )
1361
1362    def subset(self, lats, lons) -> xr.Dataset:
1363        """
1364        Subset the DataSet to a region defined by latitudes and longitudes.
1365
1366        Parameters
1367        ----------
1368        lats
1369            Latitude bounds of the region.
1370        lons
1371            Longitude bounds of the region.
1372
1373        Returns
1374        -------
1375        subset
1376            DataSet subset to the region.
1377        """
1378        ds = self._obj
1379
1380        newds = xr.Dataset()
1381        for shortName in ds:
1382            newds[shortName] = ds[shortName].grib2io.subset(lats, lons).copy()
1383
1384        return newds
1385
1386
1387@xr.register_dataarray_accessor("grib2io")
1388class Grib2ioDataArray:
1389
1390    def __init__(self, xarray_obj):
1391        self._obj = xarray_obj
1392
1393    def griddef(self):
1394        return Grib2GridDef.from_section3(self._obj.attrs['GRIB2IO_section3'])
1395
1396    def interp(self, method, grid_def_out, method_options=None, num_threads=1) -> xr.DataArray:
1397        """
1398        Perform grid spatial interpolation.
1399
1400        Uses the [NCEPLIBS-ip library](https://github.com/NOAA-EMC/NCEPLIBS-ip).
1401
1402        Parameters
1403        ----------
1404        method
1405            Interpolate method to use. This can either be an integer or string
1406            using the following mapping:
1407
1408            | Interpolate Scheme | Integer Value |
1409            | :---:              | :---:         |
1410            | 'bilinear'         | 0             |
1411            | 'bicubic'          | 1             |
1412            | 'neighbor'         | 2             |
1413            | 'budget'           | 3             |
1414            | 'spectral'         | 4             |
1415            | 'neighbor-budget'  | 6             |
1416        grid_def_out
1417            Grib2GridDef object of the output grid.
1418        method_options : list of ints, optional
1419            Interpolation options. See the NCEPLIBS-ip documentation for
1420            more information on how these are used.
1421        num_threads : int, optional
1422            Number of OpenMP threads to use for interpolation. The default
1423            value is 1. If grib2io_interp was not built with OpenMP, then
1424            this keyword argument and value will have no impact.
1425
1426        Returns
1427        -------
1428        interp
1429            DataSet interpolated to new grid definition.  The attribute
1430            GRIB2IO_section3 is replaced with the section3 array from the new
1431            grid definition.
1432        """
1433        da = self._obj
1434        # ensure that y, x are rightmost dims; they should be if opening with
1435        # grib2io engine
1436
1437        # gdtn and gdt is not the entirety of the new s3
1438        npoints = grid_def_out.npoints
1439        s3_new = np.array([0, npoints, 0, 0, grid_def_out.gdtn] + list(grid_def_out.gdt))
1440
1441        # make new lat lons
1442        lats, lons = Grib2Message(section3=s3_new, pdtn=0, drtn=0).grid()
1443        latitude = xr.DataArray(lats, dims=['y', 'x'])
1444        longitude = xr.DataArray(lons, dims=['y', 'x'])
1445
1446        # create new coords
1447        new_coords = dict(da.coords)
1448        del new_coords['latitude']
1449        del new_coords['longitude']
1450        new_coords['longitude'] = longitude
1451        new_coords['latitude'] = latitude
1452
1453        # make grid def in from section3 on da.attrs
1454        grid_def_in = self.griddef()
1455
1456        if da.chunks is None:
1457            data = interp_nd(da.data, method=method, grid_def_in=grid_def_in,
1458                             grid_def_out=grid_def_out,
1459                             method_options=method_options, num_threads=num_threads)
1460        else:
1461            import dask
1462            front_shape = da.shape[:-2]
1463            data = da.data.map_blocks(interp_nd, method=method, grid_def_in=grid_def_in,
1464                                      grid_def_out=grid_def_out, method_options=method_options,
1465                                      chunks=da.chunks[:-2]+latitude.shape, dtype=da.dtype)
1466
1467        new_da = xr.DataArray(data, dims=da.dims, coords=new_coords, attrs=da.attrs)
1468
1469        new_da.attrs['GRIB2IO_section3'] = s3_new
1470        new_da.name = da.name
1471        return new_da
1472
1473    def interp_to_stations(self, method, calls, lats, lons, method_options=None, num_threads=1) -> xr.DataArray:
1474        """
1475        Perform spatial interpolation to station points.
1476
1477        Parameters
1478        ----------
1479        method
1480            Interpolate method to use. This can either be an integer or string
1481            using the following mapping:
1482
1483            | Interpolate Scheme | Integer Value |
1484            | :---:              | :---:         |
1485            | 'bilinear'         | 0             |
1486            | 'bicubic'          | 1             |
1487            | 'neighbor'         | 2             |
1488            | 'budget'           | 3             |
1489            | 'spectral'         | 4             |
1490            | 'neighbor-budget'  | 6             |
1491
1492        calls
1493            Station calls used for labeling new station index coordinate
1494        lats
1495            Latitudes of the station points.
1496        lons
1497            Longitudes of the station points.
1498
1499        Returns
1500        -------
1501        interp_to_stations
1502            DataArray interpolated to lat and lon locations and labeled with
1503            dimension and coordinate 'station'. (..., y, x) -> (..., station)
1504        """
1505        da = self._obj
1506        # TODO ensure that y, x are rightmost dims; they should be if opening
1507        # with grib2io engine
1508
1509        calls = np.asarray(calls)
1510        lats = np.asarray(lats)
1511        lons = np.asarray(lons)
1512        latitude = xr.DataArray(lats, dims=['station'])
1513        longitude = xr.DataArray(lons, dims=['station'])
1514
1515        # create new coords
1516        new_coords = dict(da.coords)
1517        del new_coords['latitude']
1518        del new_coords['longitude']
1519        new_coords['longitude'] = longitude
1520        new_coords['latitude'] = latitude
1521        new_coords['station'] = calls
1522
1523        new_dims = da.dims[:-2] + ('station',)
1524
1525        # make grid def in from section3 on da attrs
1526        grid_def_in = self.griddef()
1527
1528        if da.chunks is None:
1529            data = interp_nd_stations(da.data, method=method, grid_def_in=grid_def_in, lats=lats,
1530                                      lons=lons, method_options=method_options, num_threads=num_threads)
1531        else:
1532            import dask
1533            front_shape = da.shape[:-1]
1534            data = da.data.map_blocks(interp_nd_stations, method=method, grid_def_in=grid_def_in,
1535                                      lats=lats, lons=lons, method_options=method_options,
1536                                      drop_axis=-1, chunks=da.chunks[:-2]+latitude.shape,
1537                                      dtype=da.dtype)
1538
1539        new_da = xr.DataArray(data, dims=new_dims, coords=new_coords, attrs=da.attrs)
1540
1541        new_da.name = da.name
1542        return new_da
1543
1544    def to_grib2(self, filename, mode: typing.Literal["x", "w", "a"] = "x"):
1545        """
1546        Write a DataArray to a grib2 file.
1547
1548        Parameters
1549        ----------
1550        filename
1551            Name of the grib2 file to write to.
1552        mode: {"x", "w", "a"}, optional, default="x"
1553            Persistence mode
1554
1555            +------+-----------------------------------+
1556            | mode | Description                       |
1557            +======+===================================+
1558            | x    | create (fail if exists)           |
1559            +------+-----------------------------------+
1560            | w    | create (overwrite if exists)      |
1561            +------+-----------------------------------+
1562            | a    | append (create if does not exist) |
1563            +------+-----------------------------------+
1564
1565        """
1566        da = self._obj.copy(deep=True)
1567
1568        coords_keys = sorted(da.coords.keys())
1569        coords_keys = [k for k in coords_keys if k in AVAILABLE_NON_GEO_COORDS]
1570
1571        # If there are dimension coordinates, the DataArray is a hypercube of
1572        # grib2 messages.
1573
1574        # Create `indexes` which is a list of lists of dictionaries for all
1575        # dimension coordinates. Each dictionary key is the dimension
1576        # coordinate name and the value is a list of the dimension coordinate
1577        # values.  This allows for easy iteration over all possible grib2
1578        # messages in the DataArray by using itertools.product.
1579        #
1580        # For example:
1581        # indexes = [
1582        #     [
1583        #         {"leadTime": 9},
1584        #         {"leadTime": 12},
1585        #     ],
1586        #     [
1587        #         {"valueOfFirstFixedSurface": 900},
1588        #         {"valueOfFirstFixedSurface": 925},
1589        #         {"valueOfFirstFixedSurface": 950},
1590        #     ],
1591        # ]
1592
1593        # assign loc indexes to dimensions without indexes for uniform selection by name
1594        loc_indexes = list()
1595        for dim in da.dims:
1596            if dim not in da.indexes:
1597                da = da.assign_coords({dim: range(da[dim].size)})
1598                loc_indexes.append(dim)
1599
1600        indexes = []
1601        for index in [i for i in AVAILABLE_NON_GEO_DIMS if i in da.dims]:
1602            values = da.coords[index].values
1603            if len(values) != len(set(values)):
1604                raise ValueError(
1605                    f"Dimension coordinate '{index}' has duplicate values, but to_grib2 requires unique values to find each GRIB2 message in the DataArray."
1606                )
1607            listeach = [{index: value} for value in sorted(values)]
1608            indexes.append(listeach)
1609
1610        # If `dim_coords` is [], then the DataArray is a single grib2 message and
1611        # itertools.product(*dim_coords) will run once with `selectors = ()`.
1612        for selectors in itertools.product(*indexes):
1613            # Need to find the correct data in the DataArray based on the
1614            # dimension coordinates.
1615            filters = {k: v for d in selectors for k, v in d.items()}
1616
1617            # If `filters` is {}, then the DataArray is a single grib2 message
1618            # and da.sel(indexers={}) returns the DataArray.
1619            selected = da.sel(indexers=filters)
1620
1621            newmsg = Grib2Message(
1622                selected.attrs["GRIB2IO_section0"],
1623                selected.attrs["GRIB2IO_section1"],
1624                selected.attrs["GRIB2IO_section2"],
1625                selected.attrs["GRIB2IO_section3"],
1626                selected.attrs["GRIB2IO_section4"],
1627                selected.attrs["GRIB2IO_section5"],
1628            )
1629            newmsg.data = np.array(selected.data)
1630
1631            # For dimension coordinates, set the grib2 message metadata to the
1632            # dimension coordinate value.
1633            for index, value in filters.items():
1634                if index not in loc_indexes:
1635                    setattr(newmsg, index, value)
1636
1637            # For non-dimension coordinates, set the grib2 message metadata to
1638            # the DataArray coordinate value.
1639            for index in [i for i in coords_keys if i not in da.dims]:
1640                setattr(newmsg, index, selected.coords[index].values)
1641
1642            # Set section 5 attributes to the da.encoding dictionary.
1643            for key, value in selected.encoding.items():
1644                if key in ["dtype", "chunks", "original_shape"]:
1645                    continue
1646                setattr(newmsg, key, value)
1647
1648            # write the message to file
1649            with grib2io.open(filename, mode=mode) as f:
1650                f.write(newmsg)
1651            mode = "a"
1652
1653    def update_attrs(self, **kwargs):
1654        """
1655        Update many of the attributes of the DataArray.
1656
1657        Parameters
1658        ----------
1659        **kwargs
1660            Attributes to update.  This can include many of the GRIB2IO message
1661            attributes that you can find when you print a GRIB2IO message. For
1662            conflicting updates, the last keyword will be used.
1663
1664            +-----------------------+------------------------------------------+
1665            | kwargs                | Description                              |
1666            +=======================+==========================================+
1667            | shortName="VTMP"      | Set shortName to "VTMP", along with      |
1668            |                       | appropriate discipline,                  |
1669            |                       | parameterCategory, parameterNumber,      |
1670            |                       | fullName and units.                      |
1671            +-----------------------+------------------------------------------+
1672            | discipline=0,         | Set shortName, discipline,               |
1673            | parameterCategory=0,  | parameterCategory, parameterNumber,      |
1674            | parameterNumber=1     | fullName and units appropriate for       |
1675            |                       | "Virtual Temperature".                   |
1676            +-----------------------+------------------------------------------+
1677            | discipline=0,         | Conflicting keywords but                 |
1678            | parameterCategory=0,  | 'shortName="TMP"' wins.  Set shortName,  |
1679            | parameterNumber=1,    | discipline, parameterCategory,           |
1680            | shortName="TMP"       | parameterNumber, fullName and units      |
1681            |                       | appropriate for "Temperature".           |
1682            +-----------------------+------------------------------------------+
1683
1684        Returns
1685        -------
1686        DataArray
1687            DataArray with updated attributes.
1688        """
1689        da = self._obj.copy(deep=True)
1690
1691        newmsg = Grib2Message(
1692            da.attrs["GRIB2IO_section0"],
1693            da.attrs["GRIB2IO_section1"],
1694            da.attrs["GRIB2IO_section2"],
1695            da.attrs["GRIB2IO_section3"],
1696            da.attrs["GRIB2IO_section4"],
1697            da.attrs["GRIB2IO_section5"],
1698        )
1699
1700        coords_keys = [
1701            k
1702            for k in da.coords.keys()
1703            if k in AVAILABLE_NON_GEO_COORDS
1704        ]
1705
1706        for grib2_name, value in kwargs.items():
1707            if grib2_name == "gridDefinitionTemplateNumber":
1708                raise ValueError(
1709                    "The gridDefinitionTemplateNumber attribute cannot be updated.  The best way to change to a different grid is to interpolate the data to a new grid using the grib2io interpolate functions."
1710                )
1711            if grib2_name == "productDefinitionTemplateNumber":
1712                raise ValueError(
1713                    "The productDefinitionTemplateNumber attribute cannot be updated."
1714                )
1715            if grib2_name == "dataRepresentationTemplateNumber":
1716                raise ValueError(
1717                    "The dataRepresentationTemplateNumber attribute cannot be updated."
1718                )
1719            if grib2_name in coords_keys:
1720                warnings.warn(
1721                    f"Skipping attribute '{grib2_name}' because it is a coordinate. Use da.assign_coords() to change coordinate values."
1722                )
1723                continue
1724            if hasattr(newmsg, grib2_name):
1725                setattr(newmsg, grib2_name, value)
1726            else:
1727                warnings.warn(
1728                    f"Skipping attribute '{grib2_name}' because it is not a valid GRIB2 attribute for this message and cannot be updated."
1729                )
1730                continue
1731
1732        da.attrs["GRIB2IO_section0"] = newmsg.section0
1733        da.attrs["GRIB2IO_section1"] = newmsg.section1
1734        da.attrs["GRIB2IO_section2"] = newmsg.section2 or []
1735        da.attrs["GRIB2IO_section3"] = newmsg.section3
1736        da.attrs["GRIB2IO_section4"] = newmsg.section4
1737        da.attrs["GRIB2IO_section5"] = newmsg.section5
1738        da.attrs["fullName"] = newmsg.fullName
1739        da.attrs["shortName"] = newmsg.shortName
1740        da.attrs["units"] = newmsg.units
1741
1742        return da
1743
1744    def subset(self, lats, lons) -> xr.DataArray:
1745        """
1746        Subset the DataArray to a region defined by latitudes and longitudes.
1747
1748        Parameters
1749        ----------
1750        lats
1751            Latitude bounds of the region.
1752        lons
1753            Longitude bounds of the region.
1754
1755        Returns
1756        -------
1757        subset
1758            DataArray subset to the region.
1759        """
1760        da = self._obj.copy(deep=True)
1761
1762        newmsg = Grib2Message(
1763            da.attrs["GRIB2IO_section0"],
1764            da.attrs["GRIB2IO_section1"],
1765            da.attrs["GRIB2IO_section2"],
1766            da.attrs["GRIB2IO_section3"],
1767            da.attrs["GRIB2IO_section4"],
1768            da.attrs["GRIB2IO_section5"],
1769        )
1770
1771        newmsg.data = np.zeros((newmsg.ny, newmsg.nx), dtype=np.float32)
1772
1773        newmsg = newmsg.subset(lats, lons)
1774
1775        da.attrs["GRIB2IO_section3"] = newmsg.section3
1776
1777        mask_lat = (da.latitude >= newmsg.latitudeLastGridpoint) & (
1778            da.latitude <= newmsg.latitudeFirstGridpoint
1779        )
1780        mask_lon = (da.longitude >= newmsg.longitudeFirstGridpoint) & (
1781            da.longitude <= newmsg.longitudeLastGridpoint
1782        )
1783
1784        del newmsg
1785
1786        return da.where((mask_lon & mask_lat).compute(), drop=True)
1787
1788
1789def build_datatree_from_grib(filename, file_index, filters=None, stack_vertical=False):
1790    """
1791    Build a DataTree from GRIB2 messages.
1792
1793    Parameters
1794    ----------
1795    filename : str
1796        Path to the GRIB2 file.
1797    file_index : pandas.DataFrame
1798        DataFrame of GRIB2 message index.
1799    filters : dict, optional
1800        Filter criteria for GRIB2 messages.
1801    stack_vertical : bool, optional
1802        If True, vertical levels will be stacked in a single dataset
1803        instead of being organized in separate tree nodes.
1804
1805    Returns
1806    -------
1807    xarray.DataTree
1808        A hierarchical DataTree representation of the GRIB2 data.
1809    """
1810    if filters is None:
1811        filters = {}
1812
1813    # Apply any filters from user
1814    for k, v in filters.items():
1815        if k not in file_index.columns:
1816            file_index = file_index.copy()
1817            file_index[k] = file_index.msg.apply(lambda msg: getattr(msg, k, None))
1818        file_index = filter_index(file_index, k, v)
1819
1820    # Make a copy to avoid the SettingWithCopyWarning
1821    file_index = file_index.copy()
1822
1823    # Extract metadata needed for tree organization
1824    # Use a safer approach to handle missing attributes
1825    def safe_getattr(obj, name):
1826        try:
1827            attr = getattr(obj, name)
1828            # Need to test if the attribute is Grib2Metadata. If so,
1829            # then get the value attribute.
1830            if isinstance(attr, grib2io.templates.Grib2Metadata):
1831                attr = attr.value
1832            return attr
1833        except (AttributeError, KeyError):
1834            return None
1835
1836    for attr in _TREE_HIERARCHY_LEVELS:
1837        if (attr not in file_index.columns) and (attr != 'valueOfFirstFixedSurface'):
1838            file_index[attr] = file_index.msg.apply(lambda msg: safe_getattr(msg, attr))
1839
1840    # Also extract shortName for variable naming
1841    if 'shortName' not in file_index.columns:
1842        file_index = file_index.assign(shortName=file_index.msg.apply(lambda msg: getattr(msg, 'shortName', None)))
1843        file_index = file_index.assign(nx=file_index.msg.apply(lambda msg: getattr(msg, 'nx', None)))
1844        file_index = file_index.assign(ny=file_index.msg.apply(lambda msg: getattr(msg, 'ny', None)))
1845
1846    # Create root DataTree
1847    root = xr.DataTree()
1848
1849    # Adjust hierarchy levels if we're stacking vertical levels
1850    hierarchy_levels = list(_TREE_HIERARCHY_LEVELS) # This makes a copy
1851    if stack_vertical and "valueOfFirstFixedSurface" in hierarchy_levels:
1852        hierarchy_levels.remove("valueOfFirstFixedSurface")
1853
1854    # First group by level type
1855    level_groups = {}
1856
1857    # Create a dictionary to group data by level type
1858    for level_type in file_index['typeOfFirstFixedSurface'].unique():
1859        if pd.notna(level_type):  # Skip None/NaN values
1860            level_info = _LEVEL_NAME_MAPPING.get(level_type, f"level_{level_type}")
1861            level_name = level_info[0]
1862            level_source = level_info[1]
1863            # Get all rows for this level type
1864            level_data = file_index[file_index['typeOfFirstFixedSurface'] == level_type]
1865            level_groups[level_type] = {'name': level_name, 'data': level_data}
1866
1867    # Process each level group
1868    for level_type, group_info in level_groups.items():
1869        level_name = group_info['name']
1870        level_df = group_info['data']
1871
1872        # Create a branch for this level type
1873        level_tree = xr.DataTree()
1874
1875        # Process this branch based on PDTN, perturbation number, etc.
1876        process_level_branch(level_tree, level_df, filename)
1877
1878        # Add this branch to the main tree
1879        root[level_name] = level_tree
1880
1881    return root
1882
1883
1884def process_level_branch(level_tree, df, filename):
1885    """
1886    Process a level type branch of the data tree, organizing by PDTN and other attributes.
1887
1888    Parameters
1889    ----------
1890    level_tree : xarray.DataTree
1891        The DataTree node for this level type
1892    df : pandas.DataFrame
1893        DataFrame of messages for this level type
1894    filename : str
1895        Path to the GRIB2 file
1896    """
1897    # Group by PDTN
1898    pdtn_groups = {}
1899
1900    # Group data by PDTN first
1901    for pdtn_value in df['productDefinitionTemplateNumber'].unique():
1902        if pd.notna(pdtn_value):
1903            pdtn_df = df[df['productDefinitionTemplateNumber'] == pdtn_value]
1904            pdtn_groups[pdtn_value] = pdtn_df
1905
1906    # If there's only one PDTN value, skip creating PDTN branch level
1907    if len(pdtn_groups) == 1:
1908        pdtn, pdtn_df = next(iter(pdtn_groups.items()))
1909
1910        pdtn_name = f"pdtn_{int(pdtn)}"
1911
1912        # Check if we need to further subdivide by perturbation number
1913        has_perturbations = ('perturbationNumber' in pdtn_df.columns and
1914                             len(pdtn_df['perturbationNumber'].dropna().unique()) > 1)
1915
1916        # Check if we need to further subdivide by probabilities unique for each variable.
1917        has_probabilities = ('typeOfProbability' in pdtn_df.columns and
1918                             len(pdtn_df['typeOfProbability'].dropna().unique()) > 1)
1919
1920        if has_perturbations:
1921            # Process perturbations directly on the level tree
1922            process_perturbation_groups(level_tree, pdtn_df, filename)
1923        elif has_probabilities:
1924            # Process probability groups
1925            process_probability_groups(level_tree, pdtn_df, filename)
1926        else:
1927            # Try to create dataset directly on level
1928            try:
1929                dss = create_datasets_from_df(pdtn_df, filename)
1930                if dss is not None:
1931                    dt = xr.DataTree()
1932                    if len(dss) == 1:
1933                        dt.ds = dss[0]
1934                    else:
1935                        for ds in dss:
1936                            varname = list(ds.data_vars)[0]
1937                            dt[f"var_{varname}"] = ds
1938                    level_tree[pdtn_name] = dt
1939            except Exception as e:
1940                print(f"Error creating dataset for level with pdtn {int(pdtn)}: {e}")
1941
1942                # Try to separate by variable name as a fallback
1943                try_process_by_variables(level_tree, pdtn_df, filename)
1944    else:
1945        # Multiple PDTN values, process each group with PDTN branch nodes
1946        for pdtn, pdtn_df in pdtn_groups.items():
1947            # Use a simple node name that's easy to use in code
1948            pdtn_name = f"pdtn_{int(pdtn)}"
1949
1950            # Check if we need to further subdivide by perturbation number
1951            has_perturbations = ('perturbationNumber' in pdtn_df.columns and
1952                                 len(pdtn_df['perturbationNumber'].dropna().unique()) > 1)
1953
1954            # Check if we need to further subdivide by probabilities unique for each variable.
1955            has_probabilities = ('typeOfProbability' in pdtn_df.columns and
1956                                 len(pdtn_df['typeOfProbability'].dropna().unique()) > 1)
1957
1958            if has_perturbations:
1959                # Create a branch for this PDTN
1960                pdtn_tree = xr.DataTree()
1961
1962                # Process perturbation groups
1963                process_perturbation_groups(pdtn_tree, pdtn_df, filename)
1964
1965                # Only add the PDTN branch if it has children
1966                if len(pdtn_tree.children) > 0 or pdtn_tree.ds is not None:
1967                    level_tree[pdtn_name] = pdtn_tree
1968            elif has_probabilities:
1969                # Create a branch for this PDTN
1970                pdtn_tree = xr.DataTree()
1971
1972                # Process probability groups
1973                process_probability_groups(pdtn_tree, pdtn_df, filename)
1974
1975                # Only add the PDTN branch if it has children
1976                if len(pdtn_tree.children) > 0 or pdtn_tree.ds is not None:
1977                    level_tree[pdtn_name] = pdtn_tree
1978            else:
1979                # Create a subtree for this PDTN
1980                pdtn_tree = xr.DataTree()
1981
1982                # Try to create dataset directly on level
1983                try:
1984                    dss = create_datasets_from_df(pdtn_df, filename)
1985                    if dss is not None:
1986                        if len(dss) == 1:
1987                            pdtn_tree.ds = dss[0]
1988                        else:
1989                            for ds in dss:
1990                                varname = list(ds.data_vars)[0]
1991                                pdtn_tree[f"var_{varname}"] = ds
1992                        level_tree[pdtn_name] = pdtn_tree
1993                except Exception as e:
1994                    print(f"Error creating dataset for level with pdtn {int(pdtn)}: {e}")
1995
1996                    # Try to separate by variable name as a fallback
1997                    try_process_by_variables(pdtn_tree, pdtn_df, filename)
1998                    level_tree[pdtn_name] = pdtn_tree
1999
2000
2001def process_probability_groups(target_tree, pdtn_df, filename):
2002    """
2003    """
2004    success = False
2005    # Group by type of probability
2006    prob_groups = {}
2007    for prob_value in pdtn_df['typeOfProbability'].unique():
2008        if pd.notna(prob_value):
2009            prob_df = pdtn_df[pdtn_df['typeOfProbability'] == prob_value]
2010            prob_groups[prob_value] = prob_df
2011
2012    # Process each probability group
2013    prob_dict = {}
2014    for prob_num, prob_df in prob_groups.items():
2015        prob_name = f"prob_{int(prob_num)}"
2016
2017        # Try to create dataset for this probability group
2018        try:
2019            dss = create_datasets_from_df(prob_df, filename)
2020            dt = xr.DataTree()
2021            if len(dss) == 1:
2022                dt.ds = dss[0]
2023                target_tree[prob_name] = dt
2024            elif len(dss) > 1:
2025                for ds in dss:
2026                    dt[f"var_{ds.data_vars[0]}"] = ds
2027            target_tree[prob_name] = dt
2028        except Exception as e:
2029            # Log error but continue processing other groups
2030            print(f"Error creating dataset for type of probability {prob_name}: {e}")
2031
2032    return success
2033
2034
2035def process_perturbation_groups(target_tree, pdtn_df, filename):
2036    """
2037    Process perturbation groups and add them to the target tree.
2038
2039    Parameters
2040    ----------
2041    target_tree : xarray.DataTree
2042        The tree node to add perturbation groups to
2043    pdtn_df : pandas.DataFrame
2044        DataFrame of messages for a specific PDTN
2045    filename : str
2046        Path to the GRIB2 file
2047
2048    Returns
2049    -------
2050    bool
2051        True if at least one perturbation was successfully processed
2052    """
2053    success = False
2054    # Group by perturbation number
2055    pert_groups = {}
2056    for pert_value in pdtn_df['perturbationNumber'].unique():
2057        if pd.notna(pert_value):
2058            pert_df = pdtn_df[pdtn_df['perturbationNumber'] == pert_value]
2059            pert_groups[pert_value] = pert_df
2060
2061    # Process each perturbation group
2062    for pert_num, pert_df in pert_groups.items():
2063        pert_name = f"pert_{int(pert_num)}"
2064
2065        ## Try to create dataset for this perturbation group
2066        #try:
2067        #    dss = create_datasets_from_df(pert_df, filename)
2068        #    if dss is not None:
2069        #        if len(dss) == 1:
2070        #            target_tree.ds = dss[0]
2071        #        else:
2072        #            dss_dict = {f"ds_{i}": ds for i, ds in enumerate(dss)}
2073        #            atree = xr.DataTree(dss_dict)
2074        #            target_tree[prob_name] = atree
2075        #        success = True
2076        #except Exception as e:
2077        #    # Log error but continue processing other groups
2078        #    print(f"Error creating dataset for perturbation {pert_name}: {e}")
2079
2080        # Try to create dataset for this perturbation group
2081        try:
2082            dss = create_datasets_from_df(pert_df, filename)
2083            dt = xr.DataTree()
2084            if len(dss) == 1:
2085                dt.ds = dss[0]
2086                target_tree[pert_name] = dt
2087            elif len(dss) > 1:
2088                for ds in dss:
2089                    dt[f"pert{ds.data_vars[0]}"] = ds
2090            target_tree[pert_name] = dt
2091        except Exception as e:
2092            # Log error but continue processing other groups
2093            print(f"Error creating dataset for perturbation {pert_name}: {e}")
2094
2095    return success
2096
2097
2098def try_process_by_variables(target_tree, df, filename):
2099    """
2100    Try to separate data by variable names and create datasets.
2101
2102    Parameters
2103    ----------
2104    target_tree : xarray.DataTree
2105        The tree node to add variable datasets to
2106    df : pandas.DataFrame
2107        DataFrame of messages
2108    filename : str
2109        Path to the GRIB2 file
2110
2111    Returns
2112    -------
2113    bool
2114        True if at least one variable was successfully processed
2115    """
2116    success = False
2117
2118    try:
2119        for var_name in df['shortName'].unique():
2120            if pd.notna(var_name):
2121                var_df = df[df['shortName'] == var_name]
2122                try:
2123                    var_ds = create_datasets_from_df(var_df, filename)
2124                    if var_ds is not None:
2125                        target_tree[f"var_{var_name}"] = var_ds[0]
2126                        success = True
2127                except Exception as var_e:
2128                    print(f"Error creating dataset for variable {var_name}: {var_e}")
2129    except Exception as nested_e:
2130        print(f"Failed to process variables: {nested_e}")
2131
2132    return success
2133
2134
2135def create_datasets_from_df(
2136    df,
2137    filename,
2138    verbose=False
2139) -> typing.Optional[typing.List[xr.Dataset]]:
2140    """
2141    Create a list of xarray Datasets from a DataFrame of messages.
2142
2143    Parameters
2144    ----------
2145    df : pandas.DataFrame
2146        DataFrame of GRIB messages
2147    filename : str
2148        Path to the GRIB2 file
2149    verbose : bool, optional
2150        If True, prints detailed debugging information
2151
2152    Returns
2153    -------
2154    dss
2155        List of Datasets, or None if creation failed
2156    """
2157    try:
2158        if verbose:
2159            print(f"\n==== VERBOSE DEBUG INFO ====")
2160            print(f"Creating dataset from DataFrame with {len(df)} messages")
2161            print(f"DataFrame columns: {df.columns.tolist()}")
2162
2163            if 'shortName' in df.columns:
2164                print(f"Variables in group: {df['shortName'].unique().tolist()}")
2165
2166            if 'valueOfFirstFixedSurface' in df.columns:
2167                print(f"Vertical levels: {df['valueOfFirstFixedSurface'].unique().tolist()}")
2168
2169        # Process by variables
2170        datasets = {}
2171
2172        # Process each variable separately, regardless of whether there are vertical levels
2173        for var_name, var_df in df.groupby('shortName'):
2174            if verbose:
2175                print(
2176                    f"\n  Processing variable: {var_name} with {len(var_df)} messages, with pdtn(s) = {var_df['productDefinitionTemplateNumber'].unique()}")
2177
2178            # Process vertical levels if present
2179            if 'valueOfFirstFixedSurface' in var_df.columns and len(var_df['valueOfFirstFixedSurface'].unique()) > 1:
2180                if verbose:
2181                    print(f"  Variable {var_name} has multiple vertical levels")
2182                # Process each level separately
2183                level_das = []
2184
2185                for level, level_df in var_df.groupby('valueOfFirstFixedSurface'):
2186                    if verbose:
2187                        print(f"    Processing level {level} with {len(level_df)} messages")
2188                    try:
2189                        # Parse the index and get dimensions for this level
2190                        file_index, non_geo_dims, attrs, coord_attrs = parse_grib_index(level_df, {})
2191                        # Remove valueOfFirstFixedSurface from dimensions since we're handling it separately
2192                        non_geo_dims = [d for d in non_geo_dims if d.__name__ != "ValueOfFirstFixedSurfaceDim"]
2193
2194                        frames, cube, extra_geo = make_variables(
2195                            file_index, filename, non_geo_dims, allow_uneven_dims=True)
2196
2197                        if frames is not None and len(frames) == 1:
2198                            level_da = build_da_without_coords(frames[0], cube, filename, attrs)
2199                            # Add this level to the list with its level value as coord
2200                            level_da = level_da.assign_coords(valueOfFirstFixedSurface=level)
2201                            level_das.append(level_da)
2202                    except Exception as e:
2203                        if verbose:
2204                            print(f"    Error processing level {level} for {var_name}: {e}")
2205
2206                if level_das:
2207                    # Combine all levels into a single DataArray along the valueOfFirstFixedSurface dimension
2208                    if verbose:
2209                        print(f"    Combining {len(level_das)} levels for {var_name}")
2210                    try:
2211                        combined_da = xr.concat(level_das, dim='valueOfFirstFixedSurface')
2212                        # Create a simple dataset with just this variable
2213                        var_ds = xr.Dataset({var_name: combined_da})
2214                        # Assign the coords from the first level's cube
2215                        var_ds = assign_xr_meta(var_ds, frames, cube, non_geo_dims, extra_geo, coord_attrs)
2216                       # TODO: is the below code all now in assign_xr_meta? was there instances where refDate and leadTime were not coords?
2217                       # var_ds = var_ds.assign_coords(coords_from_cube(cube))
2218                       # Add extra geo coords
2219                       # if extra_geo:
2220                       #    var_ds = var_ds.assign_coords(extra_geo)
2221                       # Add valid date coords if available
2222                       # if 'refDate' in var_ds.coords and 'leadTime' in var_ds.coords:
2223                       #    var_ds = var_ds.assign_coords(dict(validDate=var_ds.coords['refDate']+var_ds.coords['leadTime']))
2224
2225                        # Store this variable's dataset
2226                        datasets[var_name] = var_ds
2227                        if verbose:
2228                            print(f"    Created dataset for {var_name} with levels")
2229                    except Exception as e:
2230                        if verbose:
2231                            print(f"    Error combining levels for {var_name}: {e}")
2232            else:
2233                # Single level or no vertical levels
2234                if verbose:
2235                    print(f"  Variable {var_name} is a single level or has no vertical dimension")
2236                try:
2237                    # Parse the index and get dimensions
2238                    file_index, non_geo_dims, attrs, coord_attrs = parse_grib_index(var_df, {})
2239                    frames, cube, extra_geo = make_variables(file_index, filename, non_geo_dims, allow_uneven_dims=True)
2240
2241                    if frames is not None and len(frames) == 1:
2242                        # Create dataset with this variable
2243                        var_ds = xr.Dataset()
2244                        da = build_da_without_coords(frames[0], cube, filename, attrs)
2245                        var_ds[da.name] = da
2246
2247                        # Assign coords
2248                        var_ds = assign_xr_meta(var_ds, frames, cube, non_geo_dims, extra_geo, coord_attrs)
2249                       # TODO: is the below code all now in assign_xr_meta? was there instances where refDate and leadTime were not coords?
2250                       # var_ds = var_ds.assign_coords(coords_from_cube(cube))
2251                       # if extra_geo:
2252                       #    var_ds = var_ds.assign_coords(extra_geo)
2253                       # if 'refDate' in var_ds.coords and 'leadTime' in var_ds.coords:
2254                       #    var_ds = var_ds.assign_coords(dict(validDate=var_ds.coords['refDate']+var_ds.coords['leadTime']))
2255
2256                        # Store this variable's dataset
2257                        datasets[var_name] = var_ds
2258                        if verbose:
2259                            print(f"  Created dataset for {var_name}")
2260                    elif frames is not None and len(frames) > 1:
2261                        if verbose:
2262                            print(f"  Variable {var_name} has multiple frames, possibly different parameters")
2263                        # Just use the first frame for now (simplified approach)
2264                        var_ds = xr.Dataset()
2265                        da = build_da_without_coords(frames[0], cube, filename, attrs)
2266                        var_ds[da.name] = da
2267
2268                        # Assign coords
2269                        var_ds = assign_xr_meta(var_ds, frames, cube, non_geo_dims, extra_geo, coord_attrs)
2270                       # TODO: is the below code all now in assign_xr_meta? was there instances where refDate and leadTime were not coords?
2271                       # var_ds = var_ds.assign_coords(coords_from_cube(cube))
2272                       # if extra_geo:
2273                       #    var_ds = var_ds.assign_coords(extra_geo)
2274                       # if 'refDate' in var_ds.coords and 'leadTime' in var_ds.coords:
2275                       #    var_ds = var_ds.assign_coords(dict(validDate=var_ds.coords['refDate']+var_ds.coords['leadTime']))
2276
2277                        datasets[var_name] = var_ds
2278                        if verbose:
2279                            print(f"  Created dataset with first frame for {var_name}")
2280                except Exception as e:
2281                    if verbose:
2282                        print(f"  Error processing variable {var_name}: {e}")
2283
2284        # Attempt to merge all the variable datasets
2285        if datasets:
2286            try:
2287                if verbose:
2288                    print(f"\nMerging {len(datasets)} datasets...")
2289                # Get the list of datasets to merge
2290                ds_list = list(datasets.values())
2291
2292                # Try merging them all at once
2293                try:
2294                    combined_ds = xr.merge(ds_list)
2295                    if verbose:
2296                        print(f"Successfully merged all datasets into one.")
2297                        print(f"Final dataset has variables: {list(combined_ds.data_vars)}")
2298                        print(f"==== END VERBOSE DEBUG INFO ====\n")
2299                    return [combined_ds]
2300                except Exception as merge_error:
2301                    if verbose:
2302                        print(f"Error merging all datasets: {merge_error}")
2303                    return ds_list
2304            except Exception as e:
2305                if verbose:
2306                    print(f"Error in final merge process: {e}")
2307                    print(f"==== END VERBOSE DEBUG INFO ====\n")
2308                return None
2309        else:
2310            if verbose:
2311                print(f"No datasets were created for any variables")
2312                print(f"==== END VERBOSE DEBUG INFO ====\n")
2313            return None
2314
2315    except Exception as e:
2316        # If there's an error, log it and return None
2317        if verbose:
2318            print(f"Error creating dataset: {e}")
2319            import traceback
2320            traceback.print_exc()
2321            print(f"==== END VERBOSE DEBUG INFO ====\n")
2322        return None
2323
2324
2325# Only register the DataTree accessor if DataTree is supported
2326if _HAS_DATATREE:
2327    @xr.register_datatree_accessor("grib2io")
2328    class Grib2ioDataTree:
2329        """
2330        DataTree accessor for GRIB2 files.
2331
2332        This accessor provides methods for working with GRIB2 data organized
2333        in a hierarchical tree structure.
2334        """
2335
2336        def __init__(self, datatree_obj):
2337            self._obj = datatree_obj
2338
2339        def to_grib2(self, filename, mode: typing.Literal["x", "w", "a"] = "x"):
2340            """
2341            Write all datasets in the DataTree to a GRIB2 file.
2342
2343            Parameters
2344            ----------
2345            filename : str
2346                Name of the GRIB2 file to write to.
2347            mode : {"x", "w", "a"}, optional
2348                Persistence mode, default is "x" (create, fail if exists)
2349            """
2350            # Start with the specified mode
2351            current_mode = mode
2352
2353            # Function to recursively process the tree
2354            def process_tree(node):
2355                nonlocal current_mode
2356
2357                # If this is a Dataset node with data variables
2358                if node.ds is not None and node.ds.data_vars:
2359                    # Write dataset to GRIB2 file
2360                    node.ds.grib2io.to_grib2(filename, mode=current_mode)
2361                    # Switch to append mode after first write
2362                    current_mode = "a"
2363
2364                # Process children
2365                for child_name, child_node in node.children.items():
2366                    process_tree(child_node)
2367
2368            # Start processing from the root
2369            process_tree(self._obj)
2370
2371        def griddef(self):
2372            """
2373            Get the grid definition from the first dataset in the tree that has one.
2374
2375            Returns
2376            -------
2377            grib2io.Grib2GridDef
2378                Grid definition object
2379            """
2380            # Function to find first dataset with GRIB2IO_section3
2381            def find_griddef(node):
2382                if node.ds is not None and node.ds.data_vars:
2383                    for var_name in node.ds.data_vars:
2384                        if 'GRIB2IO_section3' in node.ds[var_name].attrs:
2385                            return Grib2GridDef.from_section3(node.ds[var_name].attrs['GRIB2IO_section3'])
2386
2387                # Check children
2388                for child_name, child_node in node.children.items():
2389                    griddef = find_griddef(child_node)
2390                    if griddef is not None:
2391                        return griddef
2392
2393                return None
2394
2395            return find_griddef(self._obj)
2396
2397        def interp(self, method, grid_def_out, method_options=None, num_threads=1):
2398            """
2399            Interpolate all datasets in the tree to a new grid.
2400
2401            Parameters
2402            ----------
2403            method : str or int
2404                Interpolation method to use
2405            grid_def_out : grib2io.Grib2GridDef
2406                Target grid definition
2407            method_options : list, optional
2408                Options for interpolation method
2409            num_threads : int, optional
2410                Number of threads to use for interpolation
2411
2412            Returns
2413            -------
2414            xarray.DataTree
2415                New DataTree with interpolated data
2416            """
2417            new_tree = xr.DataTree()
2418
2419            # Function to recursively process the tree
2420            def process_tree(node, new_parent):
2421                # If this is a Dataset node with data variables
2422                if node.ds is not None and node.ds.data_vars:
2423                    # Interpolate dataset
2424                    interp_ds = node.ds.grib2io.interp(method, grid_def_out,
2425                                                       method_options=method_options,
2426                                                       num_threads=num_threads)
2427
2428                    # Add to new tree at the same path
2429                    if node == self._obj:  # Root node
2430                        new_parent.ds = interp_ds
2431                    else:
2432                        new_parent.ds = interp_ds
2433
2434                # Process children
2435                for child_name, child_node in node.children.items():
2436                    # Create same child in new tree
2437                    new_child = xr.DataTree()
2438                    new_parent[child_name] = new_child
2439                    process_tree(child_node, new_child)
2440
2441            # Start processing from the root
2442            process_tree(self._obj, new_tree)
2443
2444            return new_tree
2445
2446        def subset(self, lats, lons):
2447            """
2448            Subset all datasets in the tree to a region.
2449
2450            Parameters
2451            ----------
2452            lats : list or tuple
2453                Latitude bounds [min_lat, max_lat]
2454            lons : list or tuple
2455                Longitude bounds [min_lon, max_lon]
2456
2457            Returns
2458            -------
2459            xarray.DataTree
2460                New DataTree with subset data
2461            """
2462            new_tree = xr.DataTree()
2463
2464            # Function to recursively process the tree
2465            def process_tree(node, new_parent):
2466                # If this is a Dataset node with data variables
2467                if node.ds is not None and node.ds.data_vars:
2468                    # Subset dataset
2469                    subset_ds = node.ds.grib2io.subset(lats, lons)
2470
2471                    # Add to new tree at the same path
2472                    if node == self._obj:  # Root node
2473                        new_parent.ds = subset_ds
2474                    else:
2475                        new_parent.ds = subset_ds
2476
2477                # Process children
2478                for child_name, child_node in node.children.items():
2479                    # Create same child in new tree
2480                    new_child = xr.DataTree()
2481                    new_parent[child_name] = new_child
2482                    process_tree(child_node, new_child)
2483
2484            # Start processing from the root
2485            process_tree(self._obj, new_tree)
2486
2487            return new_tree
AVAILABLE_NON_GEO_COORDS = ['duration', 'leadTime', 'percentileValue', 'perturbationNumber', 'refDate', 'thresholdLowerLimit', 'thresholdUpperLimit', 'valueOfFirstFixedSurface', 'valueOfSecondFixedSurface', 'aerosolType', 'scaledValueOfFirstWavelength', 'scaledValueOfSecondWavelength', 'scaledValueOfCentralWaveNumber', 'scaledValueOfFirstSize', 'scaledValueOfSecondSize']

Available non-geographic coordinate names.

AVAILABLE_NON_GEO_DIMS = ['duration', 'leadTime', 'percentileValue', 'perturbationNumber', 'refDate', 'threshold', 'level']

Available non-geographic dimension names.

VERTICAL_COORDINATE_SURFACES = ['Ground or Water Surface', 'Isothermal Level', 'Specified radius from the centre of the Sun', 'Isobaric Surface', 'Mean Sea Level', 'Specific Altitude Above Mean Sea Level', 'Specified Height Level Above Ground', 'Sigma Level', 'Hybrid Level', 'Depth Below Land Surface', 'Isentropic (theta) Level', 'Level at Specified Pressure Difference from Ground to Level', 'Potential Vorticity Surface', 'Eta Level', 'Logarithmic Hybrid Level', 'Sigma height level', 'Hybrid Height Level', 'Hybrid Pressure Level', 'Soil level', 'Sea-ice level', 'Depth Below Sea Level', 'Depth Below Water Surface', 'Ocean Model Level', 'Ocean level defined by water density (sigma-theta) difference from near-surface to level', 'Ocean level defined by water potential temperature difference from near-surface to level', 'Ocean level defined by vertical eddy diffusivity difference from near-surface to level', 'Ocean level defined by water density (rho) difference from near-surface to level']

Lookup table to define surface types that should be parsed as vertical coordinates when data_model="nws-viz".

def parse_data_model(ds, data_model):
141def parse_data_model(ds, data_model):
142    """
143    Normalize a GRIB2-derived Dataset to a target data model (currently ``"nws-viz"``).
144
145    When ``data_model == "nws-viz"``, this function converts coordinate and
146    variable names to snake_case, derives CF-like metadata, promotes select
147    GRIB-derived quantities to coordinates, optionally swaps dimensions, and
148    standardizes units/attributes. If ``data_model`` is anything else, the
149    input dataset is returned unchanged.
150
151    Parameters
152    ----------
153    ds : xarray.Dataset
154        GRIB2-derived dataset whose variables and attributes follow the
155        conventions emitted by ``grib2io``. Expected to contain GRIB-related
156        attributes such as ``typeOfFirstFixedSurface``,
157        ``typeOfSecondFixedSurface``, and (for probabilistic variables)
158        ``typeOfProbability``.
159    data_model : str
160        Target data model name. Only the value ``"nws-viz"`` triggers
161        transformations.
162
163    Returns
164    -------
165    xarray.Dataset
166        A new dataset with:
167        * Selected coordinates renamed:
168          ``refDate -> forecast_reference_time``,
169          ``leadTime -> lead_time``,
170          ``validDate -> time``,
171          ``percentileValue -> percentile``,
172          ``thresholdLowerLimit -> threshold_lower_limit``,
173          ``thresholdUpperLimit -> threshold_upper_limit``.
174        * Vertical coordinates derived from
175          ``valueOfFirstFixedSurface`` / ``valueOfSecondFixedSurface`` and their
176          corresponding ``typeOf*FixedSurface`` definitions. New coordinate
177          names are generated from the surface definition (lowercased, spaces
178          to underscores, punctuation removed). If the name already exists, a
179          ``"_2"`` suffix is appended.
180        * Possible dimension swaps:
181          ``level -> <derived_vertical_coord>`` when present; and for
182          probabilistic variables, ``threshold -> threshold_lower_limit`` or
183          ``threshold -> threshold_upper_limit`` when
184          ``typeOfProbability`` indicates the appropriate semantics.
185        * Variable names lowercased; dataset- and variable-level attributes
186          converted to snake_case (except GRIB section attributes which are
187          normalized to ``grib...``).
188        * CF-adjacent metadata populated: ``standard_name`` and
189          ``cell_methods`` are set via the shortname→CF lookup table.
190        * Percent units normalized from ``"%"`` to ``"percent"`` on coordinates.
191        * For precipitation type (``PTYPE``) thresholds, numeric codes are
192          decoded to strings (GRIB2 Table 4.201) in relevant attrs/coords.
193
194    Notes
195    -----
196    - Precipitation type decoding uses GRIB2 Table 4.201 via
197      ``tables.get_value_from_table(code, "4.201")`` and returns a NumPy
198      array with ``np.dtypes.StringDType``.
199    - CF-related lookups are performed using
200      ``tables.get_table("shortname_to_cf")``.
201    - Vertical coordinate surface names are validated against
202      ``VERTICAL_COORDINATE_SURFACES`` before promotion to coordinates.
203
204    Warnings
205    --------
206    This function assumes the presence of certain GRIB-derived attributes on the
207    first data variable (e.g., ``typeOfFirstFixedSurface``,
208    ``typeOfSecondFixedSurface``, and possibly ``typeOfProbability``).
209    If these are absent or malformed, errors (e.g., ``KeyError``) may occur.
210
211    Examples
212    --------
213    >>> ds2 = parse_data_model(ds, "nws-viz")
214    >>> list(ds2.coords)
215    ['forecast_reference_time', 'lead_time', 'time', 'percentile', ...]
216    """
217
218    def _decode_ptype(values):
219        """
220        Decode precipitation type values into human-readable strings.
221
222        Uses GRIB2 Table 4.201 to map numeric codes to precipitation type descriptions.
223
224        Parameters
225        ----------
226        values : array_like
227            Array of numeric precipitation type codes (e.g., integers or floats).
228            Each value corresponds to a GRIB2 Table 4.201 precipitation type code.
229
230        Returns
231        -------
232        numpy.ndarray
233            Array of decoded precipitation type strings with
234            NumPy’s flexible string data type (`np.dtypes.StringDType`).
235        """
236        results = []
237        for val in values:
238            # Convert each numeric code to string and look up in Table 4.201
239            results.append(str(tables.get_value_from_table(str(int(val)), '4.201')))
240
241        # Return array of strings using numpy's string data type
242        return np.array(results, dtype=np.dtypes.StringDType)
243
244    # convert coordinates and attributes to CF if requested
245    if data_model == 'nws-viz':
246
247        # define regex to convert to snake case
248        pattern = re.compile(r'(?<!^)(?=[A-Z])')
249
250        # check for coordinates and rename
251        for coord in ds.coords:
252            if coord == 'refDate':
253                ds = ds.rename({'refDate': 'forecast_reference_time'})
254
255            elif coord == 'leadTime':
256                ds = ds.rename({'leadTime': 'lead_time'})
257
258            elif coord == 'validDate':
259                ds = ds.rename({'validDate': 'time'})
260
261            elif coord == 'percentileValue':
262                ds = ds.rename({'percentileValue': 'percentile'})
263
264            elif coord == 'thresholdLowerLimit':
265                ds = ds.rename({'thresholdLowerLimit': 'threshold_lower_limit'})
266                ds['threshold_lower_limit'].attrs['long_name'] = 'Threshold Lower Limit'
267                ds['threshold_lower_limit'].attrs['units'] = ds[list(ds.data_vars.keys())[0]].attrs['units']
268
269                if 'PTYPE' in ds.data_vars:
270                    ds['threshold_lower_limit'] = xr.apply_ufunc(_decode_ptype, ds['threshold_lower_limit'])
271
272                # check if thresholdLowerLimit should be a dimension coordinate
273                if 'threshold' in ds.dims:
274                    var_key = list(ds.data_vars.keys())[0]
275                    prob_types = [
276                        'Probability of event below lower limit',
277                        'Probability of event above lower limit',
278                        'Probability of event equal to lower limit',
279                        'Probability of event between upper and lower limits (the range includes lower limit but not the upper limit)'
280                    ]
281                    if ds[var_key].attrs['typeOfProbability'] in prob_types:
282                        ds = ds.swap_dims({'threshold': 'threshold_lower_limit'})
283
284            elif coord == 'thresholdUpperLimit':
285                ds = ds.rename({'thresholdUpperLimit': 'threshold_upper_limit'})
286                ds['threshold_upper_limit'].attrs['long_name'] = 'Threshold Upper Limit'
287                ds['threshold_upper_limit'].attrs['units'] = ds[list(ds.data_vars.keys())[0]].attrs['units']
288
289                if 'PTYPE' in ds.data_vars:
290                    ds['threshold_upper_limit'] = xr.apply_ufunc(_decode_ptype, ds['threshold_upper_limit'])
291
292                if 'threshold' in ds.dims:
293                    var_key = list(ds.data_vars.keys())[0]
294                    prob_types = [
295                        'Probability of event below upper limit',
296                        'Probability of event above upper limit'
297                    ]
298                    if ds[var_key].attrs['typeOfProbability'] in prob_types:
299                        ds = ds.swap_dims({'threshold': 'threshold_upper_limit'})
300
301            # If the dataset has valueOfFirstFixedSurface as a coordinate
302            elif coord == 'valueOfFirstFixedSurface':
303                # Get the valueOfFirstFixedSurface coordinate
304                da = ds.valueOfFirstFixedSurface
305
306                # Get the definition and units from typeOfFirstFixedSurface
307                var_key = list(ds.data_vars.keys())[0]
308                definition, units = ds[var_key].attrs['typeOfFirstFixedSurface']
309
310                if definition in VERTICAL_COORDINATE_SURFACES:
311                    # Convert definition to lowercase and replace spaces with underscores
312                    key = definition.lower().replace(' ', '_')
313
314                    # remove special characters
315                    key = re.sub(r'[^a-z0-9_]', '', key)
316
317                    # Add units and grib_name attributes
318                    da.attrs['units'] = units
319                    da.attrs['grib_name'] = ['valueOfFirstFixedSurface', 'typeOfFirstFixedSurface']
320
321                    # Assign the coordinate with the new key name
322                    ds = ds.assign_coords({key: da})
323
324                    # If valueOfFirstFixedSurface is a dimension, swap it with the new key
325                    if 'level' in ds.dims:
326                        ds = ds.swap_dims({"level": key})
327
328                # Remove the original coordinates
329                del ds['valueOfFirstFixedSurface']
330
331            # If the dataset has valueOfSecondFixedSurface as a coordinate
332            elif coord == 'valueOfSecondFixedSurface':
333                # Get the valueOfSecondFixedSurface coordinate
334                da = ds.valueOfSecondFixedSurface
335
336                # Get the definition and units from typeOfSecondFixedSurface
337                var_key = list(ds.data_vars.keys())[0]
338                definition, units = ds[var_key].attrs['typeOfSecondFixedSurface']
339
340                if definition in VERTICAL_COORDINATE_SURFACES:
341                    # Convert definition to lowercase and replace spaces with underscores
342                    key = definition.lower().replace(' ', '_')
343
344                    # remove special characters
345                    key = re.sub(r'[^a-z0-9_]', '', key)
346
347                    # check if key is already in coords
348                    if key in ds.coords:
349                        key = key + '_2'
350
351                    # Add units and grib_name attributes
352                    da.attrs['units'] = units
353                    da.attrs['grib_name'] = ['valueOfSecondFixedSurface', 'typeOfSecondFixedSurface']
354
355                    # Assign the coordinate with the new key name
356                    ds = ds.assign_coords({key: da})
357
358                # Remove the original coordinates
359                del ds['valueOfSecondFixedSurface']
360            else:
361                # change coord name to snake case
362                new_coord_name = pattern.sub('_', coord).lower()
363                ds = ds.rename({coord: new_coord_name})
364
365        # convert all attributes and variable names to snake case
366        for var in ds.data_vars:
367            da = ds[var]
368            record = tables.get_table('shortname_to_cf').get(da.name)
369            da.attrs['standard_name'] = 'unknown' if record is None else record['cf_standard_name']
370            da.attrs['cell_methods'] = 'unknown' if record is None else record['cf_cell_methods']
371
372            ds[var] = da
373
374            # rename variable
375            new_var_name = var.lower()
376            ds = ds.rename({var: new_var_name})
377
378            # remove attr for typeOfFirstFixedSurface (applied as coordinate above)
379            if 'typeOfFirstFixedSurface' in ds[new_var_name].attrs:
380                definition, units = ds[new_var_name].attrs['typeOfFirstFixedSurface']
381                ds[new_var_name].attrs['typeOfFirstFixedSurface'] = f'{definition} ({units})'
382
383            if 'typeOfSecondFixedSurface' in ds[new_var_name].attrs:
384                definition, units = ds[new_var_name].attrs['typeOfSecondFixedSurface']
385                ds[new_var_name].attrs['typeOfSecondFixedSurface'] = f'{definition} ({units})'
386
387            ds[new_var_name].attrs.pop('percentileValue', None)
388
389            if 'threshold_lower_limit' in ds.coords:
390                ds[new_var_name].attrs.pop('thresholdLowerLimit', None)
391
392            if 'threshold_upper_limit' in ds.coords:
393                ds[new_var_name].attrs.pop('thresholdUpperLimit', None)
394
395            for attr in list(ds[new_var_name].attrs.keys()):
396                # skip grib section attrs
397                if 'GRIB2IO_section' in attr:
398                    # replace GRIB2IO with grib in attr
399                    new_attr_name = attr.replace('GRIB2IO', 'grib')
400                else:
401                    # change attr name to snake case
402                    new_attr_name = pattern.sub('_', attr).lower()
403
404                # update new attr name for specific CF names
405                if new_attr_name == 'full_name':
406                    new_attr_name = 'long_name'
407
408                # change % to percent
409                if attr == 'units' and ds[new_var_name].attrs[attr] == '%':
410                    ds[new_var_name].attrs[attr] = 'percent'
411
412                if new_var_name == 'ptype' and 'threshold' in new_attr_name:
413                    value = ds[new_var_name].attrs.pop(attr)
414                    ds[new_var_name].attrs[attr] = _decode_ptype(value)
415                else:
416                    # change attr name in attrs
417                    ds[new_var_name].attrs[new_attr_name] = ds[new_var_name].attrs.pop(attr)
418
419
420        # change dataset attrs to snake case
421        for attr in list(ds.attrs.keys()):
422            # change attr name to snake case
423            new_attr_name = pattern.sub('_', attr).lower()
424
425            # change attr name in attrs
426            ds.attrs[new_attr_name] = ds.attrs.pop(attr)
427
428        # change % to percent
429        for coord in ds.coords:
430            if 'units' in ds[coord].attrs and ds[coord].attrs['units'] == '%':
431                ds[coord].attrs['units'] = 'percent'
432
433    return ds

Normalize a GRIB2-derived Dataset to a target data model (currently "nws-viz").

When data_model == "nws-viz", this function converts coordinate and variable names to snake_case, derives CF-like metadata, promotes select GRIB-derived quantities to coordinates, optionally swaps dimensions, and standardizes units/attributes. If data_model is anything else, the input dataset is returned unchanged.

Parameters
  • ds (xarray.Dataset): GRIB2-derived dataset whose variables and attributes follow the conventions emitted by grib2io. Expected to contain GRIB-related attributes such as typeOfFirstFixedSurface, typeOfSecondFixedSurface, and (for probabilistic variables) typeOfProbability.
  • data_model (str): Target data model name. Only the value "nws-viz" triggers transformations.
Returns
  • xarray.Dataset: A new dataset with:
    • Selected coordinates renamed: refDate -> forecast_reference_time, leadTime -> lead_time, validDate -> time, percentileValue -> percentile, thresholdLowerLimit -> threshold_lower_limit, thresholdUpperLimit -> threshold_upper_limit.
    • Vertical coordinates derived from valueOfFirstFixedSurface / valueOfSecondFixedSurface and their corresponding typeOf*FixedSurface definitions. New coordinate names are generated from the surface definition (lowercased, spaces to underscores, punctuation removed). If the name already exists, a "_2" suffix is appended.
    • Possible dimension swaps: level -> <derived_vertical_coord> when present; and for probabilistic variables, threshold -> threshold_lower_limit or threshold -> threshold_upper_limit when typeOfProbability indicates the appropriate semantics.
    • Variable names lowercased; dataset- and variable-level attributes converted to snake_case (except GRIB section attributes which are normalized to grib...).
    • CF-adjacent metadata populated: standard_name and cell_methods are set via the shortname→CF lookup table.
    • Percent units normalized from "%" to "percent" on coordinates.
    • For precipitation type (PTYPE) thresholds, numeric codes are decoded to strings (GRIB2 Table 4.201) in relevant attrs/coords.
Notes
  • Precipitation type decoding uses GRIB2 Table 4.201 via tables.get_value_from_table(code, "4.201") and returns a NumPy array with np.dtypes.StringDType.
  • CF-related lookups are performed using tables.get_table("shortname_to_cf").
  • Vertical coordinate surface names are validated against VERTICAL_COORDINATE_SURFACES before promotion to coordinates.
Warnings

This function assumes the presence of certain GRIB-derived attributes on the first data variable (e.g., typeOfFirstFixedSurface, typeOfSecondFixedSurface, and possibly typeOfProbability). If these are absent or malformed, errors (e.g., KeyError) may occur.

Examples
>>> ds2 = parse_data_model(ds, "nws-viz")
>>> list(ds2.coords)
['forecast_reference_time', 'lead_time', 'time', 'percentile', ...]
class GribBackendEntrypoint(xarray.backends.common.BackendEntrypoint):
436class GribBackendEntrypoint(BackendEntrypoint):
437    """
438    xarray backend engine entrypoint for opening and decoding grib2 files.
439
440    .. warning::
441
442       This backend is experimental and the API/behavior may change without
443       backward compatibility.
444    """
445
446    def open_dataset(
447        self,
448        filename,
449        *,
450        drop_variables=None,
451        filters: typing.Mapping[str, typing.Any] = dict(),
452        data_model=None
453    ):
454        """
455        Read and parse metadata from grib file.
456
457        Parameters
458        ----------
459        filename
460            GRIB2 file to be opened.
461        filters
462            Filter GRIB2 messages to single hypercube. Dict keys can be any
463            GRIB2 metadata attribute name.
464        data_model
465            Parse GRIB metadata following a defined data model comvention.
466
467        Returns
468        -------
469        open_dataset
470            Xarray dataset of grib2 messages.
471        """
472        with grib2io.open(filename, _xarray_backend=True) as f:
473            file_index = pd.DataFrame(f._index)
474            file_index = file_index.assign(msg=msgs_from_index(f._index))
475
476        # parse grib2io _index to dataframe and acquire non-geo possible dims
477        # (scalar coord when not dim due to squeeze) parse_grib_index applies
478        # filters to index and expands metadata based on product definition
479        # template number
480        file_index, dim_coords, attrs, coord_attrs = parse_grib_index(file_index, filters)
481
482        # Divide up records by variable
483        frames, cube, extra_geo = make_variables(file_index, filename, dim_coords)  # have this return var_attrs
484
485        # return empty dataset if no data
486        if frames is None:
487            return xr.Dataset()
488
489        # create dataframe and add datarrays without any coords
490        ds = xr.Dataset()
491        for var_df in frames:
492            da = build_da_without_coords(var_df, cube, filename, attrs)
493            ds[da.name] = da
494
495        # add coords and dataset meta
496        ds = assign_xr_meta(ds, frames, cube, dim_coords, extra_geo, coord_attrs)
497
498        if data_model is not None:
499            ds = parse_data_model(ds, data_model)
500
501        # assign attributes
502        ds.attrs['engine'] = 'grib2io'
503
504        return ds
505
506    def open_datatree(
507        self,
508        filename,
509        *,
510        drop_variables=None,
511        filters: typing.Mapping[str, typing.Any] = None,
512        stack_vertical: bool = False,
513    ):
514        """
515        Open a GRIB2 file as an xarray DataTree.
516
517        Parameters
518        ----------
519        filename : str
520            Path to the GRIB2 file.
521        drop_variables : list, optional
522            List of variables to exclude.
523        filters : dict, optional
524            Filter criteria for GRIB2 messages.
525        stack_vertical : bool, optional
526            If True, organize the tree with vertical layers stacked in a single dataset.
527
528        Returns
529        -------
530        xarray.DataTree
531            A hierarchical DataTree representation of the GRIB2 data.
532        """
533        if not _HAS_DATATREE:
534            raise ImportError("xarray version does not support DataTree functionality.")
535
536        if filters is None:
537            filters = {}
538
539        # Open the file without any filters first to get all messages
540        with grib2io.open(filename, _xarray_backend=True) as f:
541            file_index = pd.DataFrame(f._index)
542            file_index = file_index.assign(msg=msgs_from_index(f._index))
543
544        # Build tree structure from GRIB messages with specified options
545        tree = build_datatree_from_grib(filename, file_index, filters, stack_vertical=stack_vertical)
546
547        # Put warning here so it is the last message from likely other Xarray warnings.
548        warnings.warn(
549            "grib2io’s xarray backend DataTree support is experimental. "
550            "The DataTree structure or attributes may change in future releases.",
551        UserWarning,
552        stacklevel=2,
553        )
554
555        return tree

xarray backend engine entrypoint for opening and decoding grib2 files.

This backend is experimental and the API/behavior may change without backward compatibility.

def open_dataset( self, filename, *, drop_variables=None, filters: Mapping[str, Any] = {}, data_model=None):
446    def open_dataset(
447        self,
448        filename,
449        *,
450        drop_variables=None,
451        filters: typing.Mapping[str, typing.Any] = dict(),
452        data_model=None
453    ):
454        """
455        Read and parse metadata from grib file.
456
457        Parameters
458        ----------
459        filename
460            GRIB2 file to be opened.
461        filters
462            Filter GRIB2 messages to single hypercube. Dict keys can be any
463            GRIB2 metadata attribute name.
464        data_model
465            Parse GRIB metadata following a defined data model comvention.
466
467        Returns
468        -------
469        open_dataset
470            Xarray dataset of grib2 messages.
471        """
472        with grib2io.open(filename, _xarray_backend=True) as f:
473            file_index = pd.DataFrame(f._index)
474            file_index = file_index.assign(msg=msgs_from_index(f._index))
475
476        # parse grib2io _index to dataframe and acquire non-geo possible dims
477        # (scalar coord when not dim due to squeeze) parse_grib_index applies
478        # filters to index and expands metadata based on product definition
479        # template number
480        file_index, dim_coords, attrs, coord_attrs = parse_grib_index(file_index, filters)
481
482        # Divide up records by variable
483        frames, cube, extra_geo = make_variables(file_index, filename, dim_coords)  # have this return var_attrs
484
485        # return empty dataset if no data
486        if frames is None:
487            return xr.Dataset()
488
489        # create dataframe and add datarrays without any coords
490        ds = xr.Dataset()
491        for var_df in frames:
492            da = build_da_without_coords(var_df, cube, filename, attrs)
493            ds[da.name] = da
494
495        # add coords and dataset meta
496        ds = assign_xr_meta(ds, frames, cube, dim_coords, extra_geo, coord_attrs)
497
498        if data_model is not None:
499            ds = parse_data_model(ds, data_model)
500
501        # assign attributes
502        ds.attrs['engine'] = 'grib2io'
503
504        return ds

Read and parse metadata from grib file.

Parameters
  • filename: GRIB2 file to be opened.
  • filters: Filter GRIB2 messages to single hypercube. Dict keys can be any GRIB2 metadata attribute name.
  • data_model: Parse GRIB metadata following a defined data model comvention.
Returns
  • open_dataset: Xarray dataset of grib2 messages.
def open_datatree( self, filename, *, drop_variables=None, filters: Mapping[str, Any] = None, stack_vertical: bool = False):
506    def open_datatree(
507        self,
508        filename,
509        *,
510        drop_variables=None,
511        filters: typing.Mapping[str, typing.Any] = None,
512        stack_vertical: bool = False,
513    ):
514        """
515        Open a GRIB2 file as an xarray DataTree.
516
517        Parameters
518        ----------
519        filename : str
520            Path to the GRIB2 file.
521        drop_variables : list, optional
522            List of variables to exclude.
523        filters : dict, optional
524            Filter criteria for GRIB2 messages.
525        stack_vertical : bool, optional
526            If True, organize the tree with vertical layers stacked in a single dataset.
527
528        Returns
529        -------
530        xarray.DataTree
531            A hierarchical DataTree representation of the GRIB2 data.
532        """
533        if not _HAS_DATATREE:
534            raise ImportError("xarray version does not support DataTree functionality.")
535
536        if filters is None:
537            filters = {}
538
539        # Open the file without any filters first to get all messages
540        with grib2io.open(filename, _xarray_backend=True) as f:
541            file_index = pd.DataFrame(f._index)
542            file_index = file_index.assign(msg=msgs_from_index(f._index))
543
544        # Build tree structure from GRIB messages with specified options
545        tree = build_datatree_from_grib(filename, file_index, filters, stack_vertical=stack_vertical)
546
547        # Put warning here so it is the last message from likely other Xarray warnings.
548        warnings.warn(
549            "grib2io’s xarray backend DataTree support is experimental. "
550            "The DataTree structure or attributes may change in future releases.",
551        UserWarning,
552        stacklevel=2,
553        )
554
555        return tree

Open a GRIB2 file as an xarray DataTree.

Parameters
  • filename (str): Path to the GRIB2 file.
  • drop_variables (list, optional): List of variables to exclude.
  • filters (dict, optional): Filter criteria for GRIB2 messages.
  • stack_vertical (bool, optional): If True, organize the tree with vertical layers stacked in a single dataset.
Returns
  • xarray.DataTree: A hierarchical DataTree representation of the GRIB2 data.
class GribBackendArray(xarray.backends.common.BackendArray):
558class GribBackendArray(BackendArray):
559
560    def __init__(self, array, lock):
561        self.array = array
562        self.shape = array.shape
563        self.dtype = np.dtype(array.dtype)
564        self.lock = lock
565
566    def __getitem__(self, key: xr.core.indexing.ExplicitIndexer) -> np.typing.ArrayLike:
567        return xr.core.indexing.explicit_indexing_adapter(
568            key,
569            self.shape,
570            indexing.IndexingSupport.BASIC,
571            self._raw_getitem,
572        )
573
574    def _raw_getitem(self, key: tuple):
575        """Implement thread safe access to data on disk."""
576        with self.lock:
577            return self.array[key]

Mixin class that extends a class that defines a shape property to one that also defines ndim, size and __len__.

GribBackendArray(array, lock)
560    def __init__(self, array, lock):
561        self.array = array
562        self.shape = array.shape
563        self.dtype = np.dtype(array.dtype)
564        self.lock = lock
array
shape
dtype
lock
def exclusive_slice_to_inclusive(item: slice):
580def exclusive_slice_to_inclusive(item: slice):
581    """
582    Convert a slice with exclusive stop to an inclusive slice.
583
584    If the slice has a step, the stop is reduced by the step, so that both
585    interpretations would yield the same result.
586
587    The means that [start, stop) is converted to [start, stop - step].
588
589    Parameters
590    ----------
591    item
592        The slice to convert.
593
594    Returns
595    -------
596    slice
597        The converted slice.
598    """
599    # return the None slice
600    if item.start is None and item.stop is None and item.step is None:
601        return item
602    if not isinstance(item, slice):
603        raise ValueError(f'item must be a slice; it was of type {type(item)}')
604    # if step is None, it's one
605    step = 1 if item.step is None else item.step
606    if item.stop < item.start or step < 1:
607        raise ValueError(f'slice {item} not accounted for')
608    # handle case where slice has one item
609    if abs(item.stop - item.start) == step:
610        return [item.start]
611    # other cases require reducing the stop by the step
612    s = slice(item.start, item.stop - step, step)
613    return s

Convert a slice with exclusive stop to an inclusive slice.

If the slice has a step, the stop is reduced by the step, so that both interpretations would yield the same result.

The means that [start, stop) is converted to [start, stop - step].

Parameters
  • item: The slice to convert.
Returns
  • slice: The converted slice.
class Validator:
616class Validator:
617    def __set_name__(self, owner, name):
618        self.private_name = f'_{name}'
619        self.name = name
620
621    def __get__(self, obj, objtype=None):
622        try:
623            value = getattr(obj, self.private_name)
624        except AttributeError:
625            value = None
626        return value
class PdIndex(Validator):
629class PdIndex(Validator):
630
631    def __set__(self, obj, value):
632        try:
633            value = pd.Index(value)
634        except TypeError:
635            value = pd.Index([value])
636        setattr(obj, self.private_name, value)
def array_safe_eq(a, b) -> bool:
657def array_safe_eq(a, b) -> bool:
658    """Check if a and b are equal, even if they are numpy arrays."""
659    if a is b:
660        return True
661    if hasattr(a, 'equals'):
662        return a.equals(b)
663    if hasattr(a, 'all') and hasattr(b, 'all'):
664        return a.shape == b.shape and (a == b).all()
665    if hasattr(a, 'all') or hasattr(b, 'all'):
666        return False
667    try:
668        return a == b
669    except TypeError:
670        return NotImplementedError

Check if a and b are equal, even if they are numpy arrays.

def dc_eq(dc1, dc2) -> bool:
673def dc_eq(dc1, dc2) -> bool:
674    """Check if two dataclasses which hold numpy arrays are equal."""
675    if dc1 is dc2:
676        return True
677    if dc1.__class__ is not dc2.__class__:
678        return NotImplementedError
679    t1 = astuple(dc1)
680    t2 = astuple(dc2)
681    return all(array_safe_eq(a1, a2) for a1, a2 in zip(t1, t2))

Check if two dataclasses which hold numpy arrays are equal.

def coords_from_cube(cube) -> Dict[str, xarray.core.variable.Variable]:
684def coords_from_cube(cube) -> typing.Dict[str, xr.Variable]:
685    keys = list(cube.keys())
686    keys.remove('x')
687    keys.remove('y')
688    coords = dict()
689    for k in keys:
690        if k is not None:
691            if len(cube[k]) > 1:
692                coords[k] = xr.Variable(dims=k, data=cube[k], attrs=dict(grib_name=k))
693            elif len(cube[k]) == 1:
694                coords[k] = xr.Variable(dims=tuple(), data=cube[k][0], attrs=dict(grib_name=k))
695    return coords
@dataclass
class OnDiskArray:
698@dataclass
699class OnDiskArray:
700    file_name: str
701    index: pd.DataFrame = field(repr=False)
702    cube: dict = field(repr=False)
703    shape: typing.Tuple[int, ...] = field(init=False)
704    ndim: int = field(init=False)
705    geo_ndim: int = field(init=False)
706    dtype = 'float32'
707
708    def __post_init__(self):
709        # multiple grids not allowed so can just use first
710        geo_shape = (self.index.iloc[0].ny, self.index.iloc[0].nx)
711
712        self.geo_shape = geo_shape
713        self.geo_ndim = len(geo_shape)
714
715        if len(self.index) == 1:
716            self.shape = geo_shape
717        else:
718            if self.index.index.nlevels == 1:
719                self.shape = tuple([len(self.index.index)]) + geo_shape
720            else:
721                self.shape = tuple([len(i) for i in self.index.index.levels]) + geo_shape
722        self.ndim = len(self.shape)
723
724        cols = ['msg', 'sectionOffset']
725        self.index = self.index[cols]
726
727    def __getitem__(self, item) -> np.array:
728        # dimensions not in index are internal to tdlpack records; 2 dims for
729        # grids; 1 dim for stations
730
731        index_slicer = item[:-self.geo_ndim]
732        # maintain all multindex levels
733        index_slicer = tuple([[i] if isinstance(i, int) else i for i in index_slicer])
734
735        # pandas loc slicing is inclusive, therefore convert slices into
736        # explicit lists
737        index_slicer_inclusive = tuple([exclusive_slice_to_inclusive(
738            i) if isinstance(i, slice) else i for i in index_slicer])
739
740        # get records selected by item in new index dataframe
741        if len(index_slicer_inclusive) == 1:
742            index = self.index.loc[index_slicer_inclusive]
743        elif len(index_slicer_inclusive) > 1:
744            index = self.index.loc[index_slicer_inclusive, :]
745        else:
746            index = self.index
747        index = index.set_index(index.index)
748
749        # set miloc to new relative locations in sub array
750        index['miloc'] = list(
751            zip(*[index.index.unique(level=dim).get_indexer(index.index.get_level_values(dim)) for dim in index.index.names]))
752
753        if len(index_slicer_inclusive) == 1:
754            array_field_shape = tuple([len(index.index)]) + self.geo_shape
755        elif len(index_slicer_inclusive) > 1:
756            array_field_shape = index.index.levshape + self.geo_shape
757        else:
758            array_field_shape = self.geo_shape
759
760        array_field = np.full(array_field_shape, fill_value=np.nan, dtype="float32")
761
762        with open(self.file_name, mode='rb') as filehandle:
763            for key, row in index.iterrows():
764
765                bitmap_offset = None if pd.isna(row['sectionOffset'][6]) else int(row['sectionOffset'][6])
766                values = _data(filehandle, row.msg, bitmap_offset, row['sectionOffset'][7])
767
768                if len(index_slicer_inclusive) >= 1:
769                    array_field[row.miloc] = values
770                else:
771                    array_field = values
772
773        # handle geo dim slicing
774        array_field = array_field[(Ellipsis,) + item[-self.geo_ndim:]]
775
776        # squeeze array dimensions expressed as integer
777        for i, it in reversed(list(enumerate(item[: -self.geo_ndim]))):
778            if isinstance(it, int):
779                array_field = array_field[(slice(None, None, None),) * i + (0,)]
780
781        return array_field
OnDiskArray(file_name: str, index: pandas.core.frame.DataFrame, cube: dict)
file_name: str
index: pandas.core.frame.DataFrame
cube: dict
shape: Tuple[int, ...]
ndim: int
geo_ndim: int
dtype = 'float32'
def dims_to_shape(d) -> tuple:
784def dims_to_shape(d) -> tuple:
785    if 'nx' in d:
786        t = (d['ny'], d['nx'])
787    else:
788        t = (d['nsta'],)
789    return t
def filter_index(index, k, v):
792def filter_index(index, k, v):
793    if isinstance(v, slice):
794        index = index.set_index(k)
795        index = index.loc[v]
796        index = index.reset_index()
797    else:
798        label = (
799            v
800            if getattr(v, "ndim", 1) > 1  # vectorized-indexing
801            else _asarray_tuplesafe(v)
802        )
803        if label.ndim == 0:
804            # see https://github.com/pydata/xarray/pull/4292 for details
805            label_value = label[()] if label.dtype.kind in "mM" else label.item()
806            try:
807                indexer = pd.Index(index[k]).get_loc(label_value)
808                if isinstance(indexer, int):
809                    index = index.iloc[[indexer]]
810                else:
811                    index = index.iloc[indexer]
812            except KeyError:
813                index = index.iloc[[]]
814        else:
815            indexer = pd.Index(index[k]).get_indexer_for(np.ravel(v))
816            index = index.iloc[indexer[indexer >= 0]]
817
818    return index
def parse_grib_index(index: pandas.core.frame.DataFrame, filters: Mapping[str, Any] = {}):
821def parse_grib_index(
822    index: pd.DataFrame,
823    filters: typing.Mapping[str, typing.Any] = dict(),
824):
825    """
826    Apply filters.
827
828    Evaluate remaining dimensions based on pdtn and parse each out.
829
830    Parameters
831    ----------
832    index
833        Pandas DataFrame containing the GRIB2 message index.
834    filters
835        Filter GRIB2 messages to single hypercube. Dict keys can be any
836        GRIB2 metadata attribute name.
837
838    Returns
839    -------
840    index
841        Modified Pandas DataFrame with added GRIB2 metadata columns.
842    dim_coords
843        List of GRIB2 attributes that will be used for coordinates and/or dimensions.
844    attrs
845        Dict of metadata attributes (non-coordinates, non-geo)
846    """
847
848    # make a copy of filters, remove filters as they are applied
849    filters = copy(filters)
850
851    for k, v in filters.items():
852        if k not in index.columns:
853            kwarg = {k: index.msg.apply(lambda msg: getattr(msg, k))}
854            index = index.assign(**kwarg)
855        # adopt parts of xarray's sel logic  so that filters behave similarly
856        # allowed to filter to nothing to make empty dataset
857        index = filter_index(index, k, v)
858
859    if len(index) == 0:
860        return index, list()
861
862    dim_coords = dict()  # key=name of dim, value=list of coord names
863    attrs = dict()
864    coord_attrs = dict()
865
866    # expand index
867    index = index.assign(shortName=index.msg.apply(lambda msg: msg.shortName))
868    index = index.assign(nx=index.msg.apply(lambda msg: msg.nx))
869    index = index.assign(ny=index.msg.apply(lambda msg: msg.ny))
870    index = index.astype({'ny': 'int', 'nx': 'int'})
871
872    # apply common filters(to all definition templates) to reduce dataset to
873    # single cube
874    # ensure only one of each of the below exists after filters applied
875    required_uniques = [
876        "productDefinitionTemplateNumber",
877        "typeOfGeneratingProcess",
878        "typeOfFirstFixedSurface",
879        "typeOfSecondFixedSurface",
880    ]
881
882    def meta_check(index, attrs, meta):
883        """
884        add meta to the datframe index
885        check that there is a single type
886        add the type to attrs
887
888        returns index, attrs
889        """
890        index = index.assign(**{meta: index.msg.apply(lambda msg: getattr(msg, meta))})
891
892        unique = index[meta].unique()
893        if len(index[meta].unique()) > 1:
894            raise ValueError(f'filter to a single {meta}; found: {[str(i) for i in unique]}')
895        value = unique.item()
896        if type(value) == grib2io.templates.Grib2Metadata:
897            value = value.definition
898
899        # None is returned if no value found,
900        # check and change to string None
901        if value is None:
902            value = 'None'
903
904        attrs[meta] = value
905        return index, attrs
906
907    for meta in required_uniques:
908        index, attrs = meta_check(index, attrs, meta)
909
910    pdtn = index.productDefinitionTemplateNumber.iloc[0].value
911
912    # determine which non geo dimensions can be created from data by this point
913    # the index is filtered down to a single type for all required_uniques
914
915    # Dim Name     # matching dim_name for using this data as index coordinate
916    dim_coords["refDate"] = ["refDate"]
917    coord_attrs["refDate"] = dict(standard_name="forecast_reference_time")
918#   dim_coords["refDate"] = ["refDate", "hour"] # non dim name matching items in list are used as non-index coordinates
919
920    dim_coords["leadTime"] = ["leadTime"]
921    coord_attrs["leadTime"] = dict(standard_name="forecast_period")
922
923    if 'valueOfFirstFixedSurface' not in index.columns:
924        index = index.assign(valueOfFirstFixedSurface=index.msg.apply(lambda msg: msg.valueOfFirstFixedSurface))
925    if 'valueOfsecondFixedSurface' not in index.columns:
926        index = index.assign(valueOfSecondFixedSurface=index.msg.apply(lambda msg: msg.valueOfSecondFixedSurface))
927
928    # dim name api change, user could run ds = ds.swap_dims(fixedSurface="valueOfFirstFixedSurface")
929    index = index.assign(level=list(zip(index['valueOfFirstFixedSurface'], index['valueOfSecondFixedSurface'])))
930#   index = index.assign(level=index.msg.apply(lambda msg: msg.level))
931    # lack of "level" indeicates don't create extra index coordinate "level"
932    dim_coords["level"] = ["valueOfFirstFixedSurface", "valueOfSecondFixedSurface"]
933
934    # logic for parsing possible dims from specific product definition section
935
936    if pdtn in {5, 9}:
937
938        # Probability forecasts at a horizontal level or in a horizontal layer
939        # in a continuous or non-continuous time interval.  (see Template
940        # 4.9)
941        #       AVAILABLE_THRESHOLD = {
942        #           0: {'has_lower': True, 'has_upper': False},
943        #           1: {'has_lower': False, 'has_upper': True},
944        #           2: {'has_lower': True, 'has_upper': True},
945        #           3: {'has_lower': True, 'has_upper': False},
946        #           4: {'has_lower': False, 'has_upper': True},
947        #           5: {'has_lower': True, 'has_upper': False},
948        #       }
949
950        index, attrs = meta_check(index, attrs, "typeOfProbability")
951        if 'thresholdLowerLimit' not in index.columns:
952            index = index.assign(thresholdLowerLimit=index.msg.apply(lambda msg: msg.thresholdLowerLimit))
953        if 'thresholdUpperLimit' not in index.columns:
954            index = index.assign(thresholdUpperLimit=index.msg.apply(lambda msg: msg.thresholdUpperLimit))
955        if 'threshold' not in index.columns:
956            # using composite of lower and upper, but could use threshold string from grib2io as long as that is unique and based on lower and upper
957            index = index.assign(threshold=list(zip(index['thresholdLowerLimit'], index['thresholdUpperLimit'])))
958#           index = index.assign(threshold = index.msg.apply(lambda msg: msg.threshold))
959
960        # ommiting threshold results in no index being assigned for this possible dim
961        dim_coords["threshold"] = ["thresholdLowerLimit", "thresholdUpperLimit"]
962
963    if pdtn in {6, 10}:
964
965        # Percentile forecasts at a horizontal level or in a horizontal layer
966        # in a continuous or non-continuous time interval.  (see Template
967        # 4.10)
968        dim_coords["percentileValue"] = ["percentileValue"]
969        coord_attrs["percentileValue"] = dict(long_name='percentile', units='percent')
970
971    if pdtn in {8, 9, 10, 11, 12, 13, 14, 42, 43, 45, 46, 47, 61, 62, 63, 67, 68, 72, 73, 78, 79, 82, 83, 84, 85, 87, 91}:
972        dim_coords["duration"] = ["duration"]
973
974    if pdtn in {1, 11, 33, 34, 41, 43, 45, 47, 49, 54, 56, 58, 59, 63, 68, 77, 79, 81, 83, 84, 85, 92}:
975        dim_coords["perturbationNumber"] = ["perturbationNumber"]
976
977    if pdtn in {2,3,4,12,13,14}:
978        index, attrs = meta_check(index, attrs, 'typeOfDerivedForecast')
979
980    if pdtn in {8,15,42,46,62,67,72,78,82,1001,1002,1100,1101}:
981        index, attrs = meta_check(index, attrs, 'statisticalProcess')
982
983    # Finish logic by pdtn
984
985    for k, v in dim_coords.items():
986        for meta in v:
987            if meta not in index.columns:
988                index = index.assign(**{meta: index.msg.apply(lambda msg: getattr(msg, meta))})
989
990    return index, dim_coords, attrs, coord_attrs

Apply filters.

Evaluate remaining dimensions based on pdtn and parse each out.

Parameters
  • index: Pandas DataFrame containing the GRIB2 message index.
  • filters: Filter GRIB2 messages to single hypercube. Dict keys can be any GRIB2 metadata attribute name.
Returns
  • index: Modified Pandas DataFrame with added GRIB2 metadata columns.
  • dim_coords: List of GRIB2 attributes that will be used for coordinates and/or dimensions.
  • attrs: Dict of metadata attributes (non-coordinates, non-geo)
def open_datatree(filename, *, filters: Mapping[str, Any] = None, engine='grib2io'):
 994def open_datatree(filename, *, filters: typing.Mapping[str, typing.Any] = None, engine="grib2io"):
 995    """
 996    Open a GRIB2 file as an xarray DataTree.
 997
 998    Parameters
 999    ----------
1000    filename : str
1001        Path to the GRIB2 file.
1002    filters : dict, optional
1003        Filter criteria for GRIB2 messages.
1004    engine : str, optional
1005        Engine to use for opening the file, defaults to "grib2io".
1006
1007    Returns
1008    -------
1009    xarray.DataTree
1010        A hierarchical DataTree representation of the GRIB2 data.
1011    """
1012    if not _HAS_DATATREE:
1013        raise ImportError("xarray version does not support DataTree functionality.")
1014
1015    if filters is None:
1016        filters = {}
1017
1018    # Open the file without any filters first to get all messages
1019    with grib2io.open(filename, _xarray_backend=True) as f:
1020        file_index = pd.DataFrame(f._index)
1021
1022    # Create a DataTree root
1023    tree = xr.DataTree()
1024
1025    # Build tree structure from GRIB messages
1026    return build_datatree_from_grib(filename, file_index, filters)

Open a GRIB2 file as an xarray DataTree.

Parameters
  • filename (str): Path to the GRIB2 file.
  • filters (dict, optional): Filter criteria for GRIB2 messages.
  • engine (str, optional): Engine to use for opening the file, defaults to "grib2io".
Returns
  • xarray.DataTree: A hierarchical DataTree representation of the GRIB2 data.
def build_da_without_coords(index, cube, filename, attrs) -> xarray.core.dataarray.DataArray:
1029def build_da_without_coords(index, cube, filename, attrs) -> xr.DataArray:
1030    """
1031    Build a DataArray without coordinates from a cube of grib2 messages.
1032
1033    Parameters
1034    ----------
1035    index
1036        Index of cube.
1037    cube
1038        Cube of grib2 messages.
1039    filename
1040        Filename of grib2 file
1041    add_grib_section_attrs
1042        Include grib section arrays as dataArray attributes
1043
1044    Returns
1045    -------
1046    DataArray
1047        DataArray without coordinates
1048    """
1049
1050    dim_names = [k for k in cube.keys() if cube[k] is not None and len(cube[k]) > 1]
1051    constant_meta_names = [k for k in cube.keys() if cube[k] is None]
1052    dims = {k: len(cube[k]) for k in dim_names}
1053
1054    # guard against bad datarrays being formed
1055    dims_total = 1
1056    dims_to_filter = []
1057    for dim_name, dim_len, in dims.items():
1058        if dim_name not in {'x', 'y', 'station'}:
1059            dims_total *= dim_len
1060            dims_to_filter.append(dim_name)
1061
1062    # Check number of GRIB2 message indexed compared to non-X/Y
1063    # dimensions.
1064    if dims_total != len(index):
1065        raise ValueError(
1066            f"DataArray dimensions are not compatible with number of GRIB2 messages; DataArray has {dims_total} "
1067            f"and GRIB2 index has {len(index)}. Consider applying a filter for dimensions: {dims_to_filter}"
1068        )
1069
1070    data = OnDiskArray(filename, index, cube)
1071    lock = _LOCK
1072    data = GribBackendArray(data, lock)
1073    data = indexing.LazilyIndexedArray(data)
1074    if len(dim_names) != len(data.shape):
1075        raise ValueError(
1076            "different number of dimensions on data "
1077            f"and dims: {len(data.shape)} vs {len(dim_names)}\n"
1078            "Grib2 messages could not be formed into a data cube; "
1079            "It's possible extra messages exist along a non-accounted for dimension based on PDTN\n"
1080            "It might be possible to get around this by applying a filter on the non-accounted for dimension"
1081        )
1082    da = xr.DataArray(data, dims=dim_names)
1083
1084    da.encoding['original_shape'] = data.shape
1085
1086    da.encoding['preferred_chunks'] = {'y': -1, 'x': -1}
1087    msg1 = index.msg.iloc[0]
1088
1089    # plain language metadata is minimized
1090    # add grib section metadata
1091    da.attrs['GRIB2IO_section0'] = msg1.section0
1092    da.attrs['GRIB2IO_section1'] = msg1.section1
1093    da.attrs['GRIB2IO_section2'] = msg1.section2 if msg1.section2 else []
1094    da.attrs['GRIB2IO_section3'] = msg1.section3
1095    da.attrs['GRIB2IO_section4'] = msg1.section4
1096    da.attrs['GRIB2IO_section5'] = msg1.section5
1097    da.attrs['fullName'] = str(msg1.fullName)
1098    da.attrs['shortName'] = str(msg1.shortName)
1099    da.attrs['units'] = str(msg1.units)
1100    da.attrs['originatingCenter'] = str(msg1.originatingCenter.definition)
1101    da.attrs['originatingSubCenter'] = str(msg1.originatingSubCenter.definition)
1102
1103    # add master table
1104    da.attrs['masterTableInfo'] = str(msg1.masterTableInfo.definition)
1105
1106    da.name = index.shortName.iloc[0]
1107    for meta_name in constant_meta_names:
1108        if meta_name in index.columns:
1109            da.attrs[meta_name] = index[meta_name].iloc[0]
1110
1111    da.attrs.update(attrs)
1112
1113    return da

Build a DataArray without coordinates from a cube of grib2 messages.

Parameters
  • index: Index of cube.
  • cube: Cube of grib2 messages.
  • filename: Filename of grib2 file
  • add_grib_section_attrs: Include grib section arrays as dataArray attributes
Returns
  • DataArray: DataArray without coordinates
def assign_xr_meta(ds, frames, cube, non_geo_dims, extra_geo, coord_attrs):
1116def assign_xr_meta(ds, frames, cube, non_geo_dims, extra_geo, coord_attrs):
1117
1118    # assign coords from the cube; the cube prevents datarrays with
1119    # different shapes
1120    ds = ds.assign_coords(coords_from_cube(cube))
1121    # assign extra index associated coords
1122    df = frames[0]  # use first variable as they all have same shape and index metadata
1123    for dim_name, coord_names in non_geo_dims.items():
1124        retain_index_coord = False
1125        for name in coord_names:
1126            if name == dim_name:
1127                retain_index_coord = True
1128            else:
1129                if ds[dim_name].size == 1:
1130                    # for assigning scalar coords
1131                    coord_data = [df[name].unique().item()]
1132                    ds = ds.assign_coords({name: coord_data}).squeeze()
1133                else:
1134                    # "ValueError: can only convert an array of size 1 to a Python scalar" indicates the coord is not compatible with the index
1135                    coord_data = [df[df.index.get_level_values(f'{dim_name}_ix') == val][name].unique(
1136                    ).item() for val in range(ds[dim_name].size)]
1137                    coord = pd.Index(coord_data, name=dim_name)
1138                    ds = ds.assign_coords({name: coord})
1139        if not retain_index_coord:
1140            ds = ds.drop_vars(dim_name)
1141
1142    # assign extra geo coords
1143    ds = ds.assign_coords(extra_geo)
1144    # add crs data from first grib message to each data variable and the dataset
1145    geo_attrs = {
1146        'crs_wkt': CRS.from_dict(df.msg.iloc[0].projParameters).to_wkt(),
1147        'gridlengthXDirection': df.msg.iloc[0].gridlengthXDirection,
1148        'gridlengthYDirection': df.msg.iloc[0].gridlengthYDirection,
1149        'latitudeFirstGridpoint': df.msg.iloc[0].latitudeFirstGridpoint,
1150        'longitudeFirstGridpoint': df.msg.iloc[0].longitudeFirstGridpoint,
1151    }
1152    for data_var in ds.data_vars:
1153        ds[data_var].attrs.update(geo_attrs)
1154    ds.attrs.update(geo_attrs)
1155
1156    # add coordinate specific attributes
1157    for coord, attrs in coord_attrs.items():
1158        ds[coord].attrs.update(attrs)
1159
1160    # assign valid date coords
1161    ds = ds.assign_coords(dict(validDate=ds.coords['refDate']+ds.coords['leadTime']))
1162    ds.validDate.attrs['standard_name'] = 'time'
1163    ds.validDate.attrs['long_name'] = 'time'
1164
1165    # assign attributes
1166    ds.attrs['engine'] = 'grib2io'
1167
1168    return ds
def make_variables(index, f, non_geo_dims, allow_uneven_dims=False):
1171def make_variables(index, f, non_geo_dims, allow_uneven_dims=False):
1172    """
1173    Create an individual dataframe index and cube for each variable.
1174
1175    Parameters
1176    ----------
1177    index
1178        Index of cube.
1179    f
1180        ?
1181    non_geo_dims
1182        Dimensions not associated with the x,y grid
1183    allow_uneven_dims
1184        If True, allows uneven dimensions (used for DataTree creation)
1185
1186    Returns
1187    -------
1188    ordered_frames
1189        List of dataframes, one for each variable.
1190    cube
1191        Cube of grib2 messages.
1192    extra_geo
1193        Extra geographic coordinates.
1194    """
1195    # let shortName determine the variables
1196
1197    # set the index to the name
1198    index = index.set_index('shortName').sort_index()
1199    # return nothing if no data
1200    if index.empty:
1201        return None, None, None
1202
1203    # define the DimCube
1204    dims = copy(non_geo_dims)
1205
1206    ordered_meta = list(non_geo_dims.keys())
1207    cube = None
1208    ordered_frames = list()
1209    for key in index.index.unique():
1210        frame = index.loc[[key]]
1211        frame = frame.reset_index()
1212        # frame is a dataframe with all records for one variable
1213        c = dict()
1214        # for colname in frame.columns:
1215        for colname in ordered_meta:
1216            uniques = pd.Index(frame[colname]).unique()
1217            if len(uniques) > 1:
1218                c[colname] = uniques.sort_values()
1219            else:
1220                c[colname] = [uniques[0]]
1221
1222        dims = [k for k in ordered_meta if len(c[k]) > 1]
1223
1224        for dim in dims:
1225            if frame[dim].value_counts().nunique() > 1 and not allow_uneven_dims:
1226                raise ValueError(
1227                    f'uneven number of grib msgs associated with dimension: {dim}\n unique values for {dim}: {frame[dim].unique()} ')
1228
1229        if len(dims) >= 1:  # dims may be empty if no extra dims on top of x,y
1230            frame = frame.sort_values(dims)
1231            frame = frame.set_index(dims)
1232
1233        if cube:
1234            if cube != c and not allow_uneven_dims:
1235                raise ValueError(f'{cube},\n {c};\n cubes are not the same; filter to a single cube')
1236        else:
1237            cube = c
1238
1239        # miloc is multi-index integer location of msg in nd DataArray
1240        miloc = list(zip(*[frame.index.unique(level=dim).get_indexer(frame.index.get_level_values(dim))
1241                     for dim in dims]))
1242
1243        # set frame multi index
1244        if len(miloc) >= 1:  # miloc will be empty when no extra dims, thus no multiindex
1245            dim_ix = tuple([n+'_ix' for n in dims])
1246            frame = frame.set_index(pd.MultiIndex.from_tuples(miloc, names=dim_ix))
1247
1248        ordered_frames.append(frame)
1249
1250    # no variables
1251    if cube is None:
1252        cube = dict()
1253
1254    # check geography of data and assign to cube
1255    if len(index.ny.unique()) > 1 or len(index.nx.unique()) > 1:
1256        raise ValueError('multiple grids not accommodated')
1257    cube["y"] = range(int(index.ny.iloc[0]))
1258    cube["x"] = range(int(index.nx.iloc[0]))
1259
1260    extra_geo = None
1261    msg = index.msg.iloc[0]
1262
1263    # we want the lat lons; make them via accessing a record; we are assuming
1264    # all records are the same grid because they have the same shape;
1265    # may want a unique grid identifier from grib2io to avoid assuming this
1266    latitude, longitude = msg.latlons()
1267    latitude = xr.DataArray(latitude, dims=['y', 'x'])
1268    latitude.attrs['standard_name'] = 'latitude'
1269    latitude.attrs['units'] = 'degrees_north'
1270    longitude = xr.DataArray(longitude, dims=['y', 'x'])
1271    longitude.attrs['standard_name'] = 'longitude'
1272    longitude.attrs['units'] = 'degrees_east'
1273    extra_geo = dict(latitude=latitude, longitude=longitude)
1274
1275    return ordered_frames, cube, extra_geo

Create an individual dataframe index and cube for each variable.

Parameters
  • index: Index of cube.
  • f: ?
  • non_geo_dims: Dimensions not associated with the x,y grid
  • allow_uneven_dims: If True, allows uneven dimensions (used for DataTree creation)
Returns
  • ordered_frames: List of dataframes, one for each variable.
  • cube: Cube of grib2 messages.
  • extra_geo: Extra geographic coordinates.
def interp_nd( a, *, method, grid_def_in, grid_def_out, method_options=None, num_threads=1):
1278def interp_nd(a, *, method, grid_def_in, grid_def_out, method_options=None, num_threads=1):
1279    front_shape = a.shape[:-2]
1280    a = a.reshape(-1, a.shape[-2], a.shape[-1])
1281    a = grib2io.interpolate(a, method, grid_def_in, grid_def_out, method_options=method_options,
1282                            num_threads=num_threads)
1283    a = a.reshape(front_shape + (a.shape[-2], a.shape[-1]))
1284    return a
def interp_nd_stations( a, *, method, grid_def_in, lats, lons, method_options=None, num_threads=1):
1287def interp_nd_stations(a, *, method, grid_def_in, lats, lons, method_options=None, num_threads=1):
1288    front_shape = a.shape[:-2]
1289    a = a.reshape(-1, a.shape[-2], a.shape[-1])
1290    a = grib2io.interpolate_to_stations(a, method, grid_def_in, lats, lons, method_options=method_options,
1291                                        num_threads=num_threads)
1292    a = a.reshape(front_shape + (len(lats),))
1293    return a
@xr.register_dataset_accessor('grib2io')
class Grib2ioDataSet:
1296@xr.register_dataset_accessor("grib2io")
1297class Grib2ioDataSet:
1298
1299    def __init__(self, xarray_obj):
1300        self._obj = xarray_obj
1301
1302    def griddef(self):
1303        return Grib2GridDef.from_section3(self._obj[list(self._obj.data_vars)[0]].attrs['GRIB2IO_section3'])
1304
1305    def interp(self, method, grid_def_out, method_options=None, num_threads=1) -> xr.Dataset:
1306        # see interp method of class Grib2ioDataArray
1307        da = self._obj.to_array()
1308        da.attrs['GRIB2IO_section3'] = self._obj[list(self._obj.data_vars)[0]].attrs['GRIB2IO_section3']
1309        da = da.grib2io.interp(method, grid_def_out, method_options=method_options,
1310                               num_threads=num_threads)
1311        ds = da.to_dataset(dim='variable')
1312        return ds
1313
1314    def interp_to_stations(self, method, calls, lats, lons, method_options=None, num_threads=1) -> xr.Dataset:
1315        # see interp_to_stations method of class Grib2ioDataArray
1316        da = self._obj.to_array()
1317        da.attrs['GRIB2IO_section3'] = self._obj[list(self._obj.data_vars)[0]].attrs['GRIB2IO_section3']
1318        da = da.grib2io.interp_to_stations(method, calls, lats, lons, method_options=method_options,
1319                                           num_threads=num_threads)
1320        ds = da.to_dataset(dim='variable')
1321        return ds
1322
1323    def to_grib2(self, filename, mode: typing.Literal["x", "w", "a"] = "x"):
1324        """
1325        Write a DataSet to a grib2 file.
1326
1327        Parameters
1328        ----------
1329        filename
1330            Name of the grib2 file to write to.
1331        mode: {"x", "w", "a"}, optional, default="x"
1332            Persistence mode
1333
1334            | mode | Description                       |
1335            | :---:| :---:                             |
1336            | 'x'  | create (fail if exists)           |
1337            | 'w'  | create (overwrite if exists)      |
1338            | 'a'  | append (create if does not exist) |
1339
1340        """
1341        ds = self._obj
1342
1343        for shortName in sorted(ds):
1344            # make a DataArray from the "Data Variables" in the DataSet
1345            da = ds[shortName]
1346
1347            da.grib2io.to_grib2(filename, mode=mode)
1348            mode = "a"
1349
1350    def update_attrs(self, **kwargs):
1351        """
1352        Raises an error because Datasets don't have a .attrs attribute.
1353
1354        Parameters
1355        ----------
1356        attrs
1357            Attributes to update.
1358        """
1359        raise ValueError(
1360            f"Datasets do not have a .attrs attribute; use .grib2io.update_attrs({kwargs}) on a DataArray instead."
1361        )
1362
1363    def subset(self, lats, lons) -> xr.Dataset:
1364        """
1365        Subset the DataSet to a region defined by latitudes and longitudes.
1366
1367        Parameters
1368        ----------
1369        lats
1370            Latitude bounds of the region.
1371        lons
1372            Longitude bounds of the region.
1373
1374        Returns
1375        -------
1376        subset
1377            DataSet subset to the region.
1378        """
1379        ds = self._obj
1380
1381        newds = xr.Dataset()
1382        for shortName in ds:
1383            newds[shortName] = ds[shortName].grib2io.subset(lats, lons).copy()
1384
1385        return newds
Grib2ioDataSet(xarray_obj)
1299    def __init__(self, xarray_obj):
1300        self._obj = xarray_obj
def griddef(self):
1302    def griddef(self):
1303        return Grib2GridDef.from_section3(self._obj[list(self._obj.data_vars)[0]].attrs['GRIB2IO_section3'])
def interp( self, method, grid_def_out, method_options=None, num_threads=1) -> xarray.core.dataset.Dataset:
1305    def interp(self, method, grid_def_out, method_options=None, num_threads=1) -> xr.Dataset:
1306        # see interp method of class Grib2ioDataArray
1307        da = self._obj.to_array()
1308        da.attrs['GRIB2IO_section3'] = self._obj[list(self._obj.data_vars)[0]].attrs['GRIB2IO_section3']
1309        da = da.grib2io.interp(method, grid_def_out, method_options=method_options,
1310                               num_threads=num_threads)
1311        ds = da.to_dataset(dim='variable')
1312        return ds
def interp_to_stations( self, method, calls, lats, lons, method_options=None, num_threads=1) -> xarray.core.dataset.Dataset:
1314    def interp_to_stations(self, method, calls, lats, lons, method_options=None, num_threads=1) -> xr.Dataset:
1315        # see interp_to_stations method of class Grib2ioDataArray
1316        da = self._obj.to_array()
1317        da.attrs['GRIB2IO_section3'] = self._obj[list(self._obj.data_vars)[0]].attrs['GRIB2IO_section3']
1318        da = da.grib2io.interp_to_stations(method, calls, lats, lons, method_options=method_options,
1319                                           num_threads=num_threads)
1320        ds = da.to_dataset(dim='variable')
1321        return ds
def to_grib2(self, filename, mode: Literal['x', 'w', 'a'] = 'x'):
1323    def to_grib2(self, filename, mode: typing.Literal["x", "w", "a"] = "x"):
1324        """
1325        Write a DataSet to a grib2 file.
1326
1327        Parameters
1328        ----------
1329        filename
1330            Name of the grib2 file to write to.
1331        mode: {"x", "w", "a"}, optional, default="x"
1332            Persistence mode
1333
1334            | mode | Description                       |
1335            | :---:| :---:                             |
1336            | 'x'  | create (fail if exists)           |
1337            | 'w'  | create (overwrite if exists)      |
1338            | 'a'  | append (create if does not exist) |
1339
1340        """
1341        ds = self._obj
1342
1343        for shortName in sorted(ds):
1344            # make a DataArray from the "Data Variables" in the DataSet
1345            da = ds[shortName]
1346
1347            da.grib2io.to_grib2(filename, mode=mode)
1348            mode = "a"

Write a DataSet to a grib2 file.

Parameters
  • filename: Name of the grib2 file to write to.
  • mode ({"x", "w", "a"}, optional, default="x"): Persistence mode

    mode Description
    'x' create (fail if exists)
    'w' create (overwrite if exists)
    'a' append (create if does not exist)
def update_attrs(self, **kwargs):
1350    def update_attrs(self, **kwargs):
1351        """
1352        Raises an error because Datasets don't have a .attrs attribute.
1353
1354        Parameters
1355        ----------
1356        attrs
1357            Attributes to update.
1358        """
1359        raise ValueError(
1360            f"Datasets do not have a .attrs attribute; use .grib2io.update_attrs({kwargs}) on a DataArray instead."
1361        )

Raises an error because Datasets don't have a .attrs attribute.

Parameters
  • attrs: Attributes to update.
def subset(self, lats, lons) -> xarray.core.dataset.Dataset:
1363    def subset(self, lats, lons) -> xr.Dataset:
1364        """
1365        Subset the DataSet to a region defined by latitudes and longitudes.
1366
1367        Parameters
1368        ----------
1369        lats
1370            Latitude bounds of the region.
1371        lons
1372            Longitude bounds of the region.
1373
1374        Returns
1375        -------
1376        subset
1377            DataSet subset to the region.
1378        """
1379        ds = self._obj
1380
1381        newds = xr.Dataset()
1382        for shortName in ds:
1383            newds[shortName] = ds[shortName].grib2io.subset(lats, lons).copy()
1384
1385        return newds

Subset the DataSet to a region defined by latitudes and longitudes.

Parameters
  • lats: Latitude bounds of the region.
  • lons: Longitude bounds of the region.
Returns
  • subset: DataSet subset to the region.
@xr.register_dataarray_accessor('grib2io')
class Grib2ioDataArray:
1388@xr.register_dataarray_accessor("grib2io")
1389class Grib2ioDataArray:
1390
1391    def __init__(self, xarray_obj):
1392        self._obj = xarray_obj
1393
1394    def griddef(self):
1395        return Grib2GridDef.from_section3(self._obj.attrs['GRIB2IO_section3'])
1396
1397    def interp(self, method, grid_def_out, method_options=None, num_threads=1) -> xr.DataArray:
1398        """
1399        Perform grid spatial interpolation.
1400
1401        Uses the [NCEPLIBS-ip library](https://github.com/NOAA-EMC/NCEPLIBS-ip).
1402
1403        Parameters
1404        ----------
1405        method
1406            Interpolate method to use. This can either be an integer or string
1407            using the following mapping:
1408
1409            | Interpolate Scheme | Integer Value |
1410            | :---:              | :---:         |
1411            | 'bilinear'         | 0             |
1412            | 'bicubic'          | 1             |
1413            | 'neighbor'         | 2             |
1414            | 'budget'           | 3             |
1415            | 'spectral'         | 4             |
1416            | 'neighbor-budget'  | 6             |
1417        grid_def_out
1418            Grib2GridDef object of the output grid.
1419        method_options : list of ints, optional
1420            Interpolation options. See the NCEPLIBS-ip documentation for
1421            more information on how these are used.
1422        num_threads : int, optional
1423            Number of OpenMP threads to use for interpolation. The default
1424            value is 1. If grib2io_interp was not built with OpenMP, then
1425            this keyword argument and value will have no impact.
1426
1427        Returns
1428        -------
1429        interp
1430            DataSet interpolated to new grid definition.  The attribute
1431            GRIB2IO_section3 is replaced with the section3 array from the new
1432            grid definition.
1433        """
1434        da = self._obj
1435        # ensure that y, x are rightmost dims; they should be if opening with
1436        # grib2io engine
1437
1438        # gdtn and gdt is not the entirety of the new s3
1439        npoints = grid_def_out.npoints
1440        s3_new = np.array([0, npoints, 0, 0, grid_def_out.gdtn] + list(grid_def_out.gdt))
1441
1442        # make new lat lons
1443        lats, lons = Grib2Message(section3=s3_new, pdtn=0, drtn=0).grid()
1444        latitude = xr.DataArray(lats, dims=['y', 'x'])
1445        longitude = xr.DataArray(lons, dims=['y', 'x'])
1446
1447        # create new coords
1448        new_coords = dict(da.coords)
1449        del new_coords['latitude']
1450        del new_coords['longitude']
1451        new_coords['longitude'] = longitude
1452        new_coords['latitude'] = latitude
1453
1454        # make grid def in from section3 on da.attrs
1455        grid_def_in = self.griddef()
1456
1457        if da.chunks is None:
1458            data = interp_nd(da.data, method=method, grid_def_in=grid_def_in,
1459                             grid_def_out=grid_def_out,
1460                             method_options=method_options, num_threads=num_threads)
1461        else:
1462            import dask
1463            front_shape = da.shape[:-2]
1464            data = da.data.map_blocks(interp_nd, method=method, grid_def_in=grid_def_in,
1465                                      grid_def_out=grid_def_out, method_options=method_options,
1466                                      chunks=da.chunks[:-2]+latitude.shape, dtype=da.dtype)
1467
1468        new_da = xr.DataArray(data, dims=da.dims, coords=new_coords, attrs=da.attrs)
1469
1470        new_da.attrs['GRIB2IO_section3'] = s3_new
1471        new_da.name = da.name
1472        return new_da
1473
1474    def interp_to_stations(self, method, calls, lats, lons, method_options=None, num_threads=1) -> xr.DataArray:
1475        """
1476        Perform spatial interpolation to station points.
1477
1478        Parameters
1479        ----------
1480        method
1481            Interpolate method to use. This can either be an integer or string
1482            using the following mapping:
1483
1484            | Interpolate Scheme | Integer Value |
1485            | :---:              | :---:         |
1486            | 'bilinear'         | 0             |
1487            | 'bicubic'          | 1             |
1488            | 'neighbor'         | 2             |
1489            | 'budget'           | 3             |
1490            | 'spectral'         | 4             |
1491            | 'neighbor-budget'  | 6             |
1492
1493        calls
1494            Station calls used for labeling new station index coordinate
1495        lats
1496            Latitudes of the station points.
1497        lons
1498            Longitudes of the station points.
1499
1500        Returns
1501        -------
1502        interp_to_stations
1503            DataArray interpolated to lat and lon locations and labeled with
1504            dimension and coordinate 'station'. (..., y, x) -> (..., station)
1505        """
1506        da = self._obj
1507        # TODO ensure that y, x are rightmost dims; they should be if opening
1508        # with grib2io engine
1509
1510        calls = np.asarray(calls)
1511        lats = np.asarray(lats)
1512        lons = np.asarray(lons)
1513        latitude = xr.DataArray(lats, dims=['station'])
1514        longitude = xr.DataArray(lons, dims=['station'])
1515
1516        # create new coords
1517        new_coords = dict(da.coords)
1518        del new_coords['latitude']
1519        del new_coords['longitude']
1520        new_coords['longitude'] = longitude
1521        new_coords['latitude'] = latitude
1522        new_coords['station'] = calls
1523
1524        new_dims = da.dims[:-2] + ('station',)
1525
1526        # make grid def in from section3 on da attrs
1527        grid_def_in = self.griddef()
1528
1529        if da.chunks is None:
1530            data = interp_nd_stations(da.data, method=method, grid_def_in=grid_def_in, lats=lats,
1531                                      lons=lons, method_options=method_options, num_threads=num_threads)
1532        else:
1533            import dask
1534            front_shape = da.shape[:-1]
1535            data = da.data.map_blocks(interp_nd_stations, method=method, grid_def_in=grid_def_in,
1536                                      lats=lats, lons=lons, method_options=method_options,
1537                                      drop_axis=-1, chunks=da.chunks[:-2]+latitude.shape,
1538                                      dtype=da.dtype)
1539
1540        new_da = xr.DataArray(data, dims=new_dims, coords=new_coords, attrs=da.attrs)
1541
1542        new_da.name = da.name
1543        return new_da
1544
1545    def to_grib2(self, filename, mode: typing.Literal["x", "w", "a"] = "x"):
1546        """
1547        Write a DataArray to a grib2 file.
1548
1549        Parameters
1550        ----------
1551        filename
1552            Name of the grib2 file to write to.
1553        mode: {"x", "w", "a"}, optional, default="x"
1554            Persistence mode
1555
1556            +------+-----------------------------------+
1557            | mode | Description                       |
1558            +======+===================================+
1559            | x    | create (fail if exists)           |
1560            +------+-----------------------------------+
1561            | w    | create (overwrite if exists)      |
1562            +------+-----------------------------------+
1563            | a    | append (create if does not exist) |
1564            +------+-----------------------------------+
1565
1566        """
1567        da = self._obj.copy(deep=True)
1568
1569        coords_keys = sorted(da.coords.keys())
1570        coords_keys = [k for k in coords_keys if k in AVAILABLE_NON_GEO_COORDS]
1571
1572        # If there are dimension coordinates, the DataArray is a hypercube of
1573        # grib2 messages.
1574
1575        # Create `indexes` which is a list of lists of dictionaries for all
1576        # dimension coordinates. Each dictionary key is the dimension
1577        # coordinate name and the value is a list of the dimension coordinate
1578        # values.  This allows for easy iteration over all possible grib2
1579        # messages in the DataArray by using itertools.product.
1580        #
1581        # For example:
1582        # indexes = [
1583        #     [
1584        #         {"leadTime": 9},
1585        #         {"leadTime": 12},
1586        #     ],
1587        #     [
1588        #         {"valueOfFirstFixedSurface": 900},
1589        #         {"valueOfFirstFixedSurface": 925},
1590        #         {"valueOfFirstFixedSurface": 950},
1591        #     ],
1592        # ]
1593
1594        # assign loc indexes to dimensions without indexes for uniform selection by name
1595        loc_indexes = list()
1596        for dim in da.dims:
1597            if dim not in da.indexes:
1598                da = da.assign_coords({dim: range(da[dim].size)})
1599                loc_indexes.append(dim)
1600
1601        indexes = []
1602        for index in [i for i in AVAILABLE_NON_GEO_DIMS if i in da.dims]:
1603            values = da.coords[index].values
1604            if len(values) != len(set(values)):
1605                raise ValueError(
1606                    f"Dimension coordinate '{index}' has duplicate values, but to_grib2 requires unique values to find each GRIB2 message in the DataArray."
1607                )
1608            listeach = [{index: value} for value in sorted(values)]
1609            indexes.append(listeach)
1610
1611        # If `dim_coords` is [], then the DataArray is a single grib2 message and
1612        # itertools.product(*dim_coords) will run once with `selectors = ()`.
1613        for selectors in itertools.product(*indexes):
1614            # Need to find the correct data in the DataArray based on the
1615            # dimension coordinates.
1616            filters = {k: v for d in selectors for k, v in d.items()}
1617
1618            # If `filters` is {}, then the DataArray is a single grib2 message
1619            # and da.sel(indexers={}) returns the DataArray.
1620            selected = da.sel(indexers=filters)
1621
1622            newmsg = Grib2Message(
1623                selected.attrs["GRIB2IO_section0"],
1624                selected.attrs["GRIB2IO_section1"],
1625                selected.attrs["GRIB2IO_section2"],
1626                selected.attrs["GRIB2IO_section3"],
1627                selected.attrs["GRIB2IO_section4"],
1628                selected.attrs["GRIB2IO_section5"],
1629            )
1630            newmsg.data = np.array(selected.data)
1631
1632            # For dimension coordinates, set the grib2 message metadata to the
1633            # dimension coordinate value.
1634            for index, value in filters.items():
1635                if index not in loc_indexes:
1636                    setattr(newmsg, index, value)
1637
1638            # For non-dimension coordinates, set the grib2 message metadata to
1639            # the DataArray coordinate value.
1640            for index in [i for i in coords_keys if i not in da.dims]:
1641                setattr(newmsg, index, selected.coords[index].values)
1642
1643            # Set section 5 attributes to the da.encoding dictionary.
1644            for key, value in selected.encoding.items():
1645                if key in ["dtype", "chunks", "original_shape"]:
1646                    continue
1647                setattr(newmsg, key, value)
1648
1649            # write the message to file
1650            with grib2io.open(filename, mode=mode) as f:
1651                f.write(newmsg)
1652            mode = "a"
1653
1654    def update_attrs(self, **kwargs):
1655        """
1656        Update many of the attributes of the DataArray.
1657
1658        Parameters
1659        ----------
1660        **kwargs
1661            Attributes to update.  This can include many of the GRIB2IO message
1662            attributes that you can find when you print a GRIB2IO message. For
1663            conflicting updates, the last keyword will be used.
1664
1665            +-----------------------+------------------------------------------+
1666            | kwargs                | Description                              |
1667            +=======================+==========================================+
1668            | shortName="VTMP"      | Set shortName to "VTMP", along with      |
1669            |                       | appropriate discipline,                  |
1670            |                       | parameterCategory, parameterNumber,      |
1671            |                       | fullName and units.                      |
1672            +-----------------------+------------------------------------------+
1673            | discipline=0,         | Set shortName, discipline,               |
1674            | parameterCategory=0,  | parameterCategory, parameterNumber,      |
1675            | parameterNumber=1     | fullName and units appropriate for       |
1676            |                       | "Virtual Temperature".                   |
1677            +-----------------------+------------------------------------------+
1678            | discipline=0,         | Conflicting keywords but                 |
1679            | parameterCategory=0,  | 'shortName="TMP"' wins.  Set shortName,  |
1680            | parameterNumber=1,    | discipline, parameterCategory,           |
1681            | shortName="TMP"       | parameterNumber, fullName and units      |
1682            |                       | appropriate for "Temperature".           |
1683            +-----------------------+------------------------------------------+
1684
1685        Returns
1686        -------
1687        DataArray
1688            DataArray with updated attributes.
1689        """
1690        da = self._obj.copy(deep=True)
1691
1692        newmsg = Grib2Message(
1693            da.attrs["GRIB2IO_section0"],
1694            da.attrs["GRIB2IO_section1"],
1695            da.attrs["GRIB2IO_section2"],
1696            da.attrs["GRIB2IO_section3"],
1697            da.attrs["GRIB2IO_section4"],
1698            da.attrs["GRIB2IO_section5"],
1699        )
1700
1701        coords_keys = [
1702            k
1703            for k in da.coords.keys()
1704            if k in AVAILABLE_NON_GEO_COORDS
1705        ]
1706
1707        for grib2_name, value in kwargs.items():
1708            if grib2_name == "gridDefinitionTemplateNumber":
1709                raise ValueError(
1710                    "The gridDefinitionTemplateNumber attribute cannot be updated.  The best way to change to a different grid is to interpolate the data to a new grid using the grib2io interpolate functions."
1711                )
1712            if grib2_name == "productDefinitionTemplateNumber":
1713                raise ValueError(
1714                    "The productDefinitionTemplateNumber attribute cannot be updated."
1715                )
1716            if grib2_name == "dataRepresentationTemplateNumber":
1717                raise ValueError(
1718                    "The dataRepresentationTemplateNumber attribute cannot be updated."
1719                )
1720            if grib2_name in coords_keys:
1721                warnings.warn(
1722                    f"Skipping attribute '{grib2_name}' because it is a coordinate. Use da.assign_coords() to change coordinate values."
1723                )
1724                continue
1725            if hasattr(newmsg, grib2_name):
1726                setattr(newmsg, grib2_name, value)
1727            else:
1728                warnings.warn(
1729                    f"Skipping attribute '{grib2_name}' because it is not a valid GRIB2 attribute for this message and cannot be updated."
1730                )
1731                continue
1732
1733        da.attrs["GRIB2IO_section0"] = newmsg.section0
1734        da.attrs["GRIB2IO_section1"] = newmsg.section1
1735        da.attrs["GRIB2IO_section2"] = newmsg.section2 or []
1736        da.attrs["GRIB2IO_section3"] = newmsg.section3
1737        da.attrs["GRIB2IO_section4"] = newmsg.section4
1738        da.attrs["GRIB2IO_section5"] = newmsg.section5
1739        da.attrs["fullName"] = newmsg.fullName
1740        da.attrs["shortName"] = newmsg.shortName
1741        da.attrs["units"] = newmsg.units
1742
1743        return da
1744
1745    def subset(self, lats, lons) -> xr.DataArray:
1746        """
1747        Subset the DataArray to a region defined by latitudes and longitudes.
1748
1749        Parameters
1750        ----------
1751        lats
1752            Latitude bounds of the region.
1753        lons
1754            Longitude bounds of the region.
1755
1756        Returns
1757        -------
1758        subset
1759            DataArray subset to the region.
1760        """
1761        da = self._obj.copy(deep=True)
1762
1763        newmsg = Grib2Message(
1764            da.attrs["GRIB2IO_section0"],
1765            da.attrs["GRIB2IO_section1"],
1766            da.attrs["GRIB2IO_section2"],
1767            da.attrs["GRIB2IO_section3"],
1768            da.attrs["GRIB2IO_section4"],
1769            da.attrs["GRIB2IO_section5"],
1770        )
1771
1772        newmsg.data = np.zeros((newmsg.ny, newmsg.nx), dtype=np.float32)
1773
1774        newmsg = newmsg.subset(lats, lons)
1775
1776        da.attrs["GRIB2IO_section3"] = newmsg.section3
1777
1778        mask_lat = (da.latitude >= newmsg.latitudeLastGridpoint) & (
1779            da.latitude <= newmsg.latitudeFirstGridpoint
1780        )
1781        mask_lon = (da.longitude >= newmsg.longitudeFirstGridpoint) & (
1782            da.longitude <= newmsg.longitudeLastGridpoint
1783        )
1784
1785        del newmsg
1786
1787        return da.where((mask_lon & mask_lat).compute(), drop=True)
Grib2ioDataArray(xarray_obj)
1391    def __init__(self, xarray_obj):
1392        self._obj = xarray_obj
def griddef(self):
1394    def griddef(self):
1395        return Grib2GridDef.from_section3(self._obj.attrs['GRIB2IO_section3'])
def interp( self, method, grid_def_out, method_options=None, num_threads=1) -> xarray.core.dataarray.DataArray:
1397    def interp(self, method, grid_def_out, method_options=None, num_threads=1) -> xr.DataArray:
1398        """
1399        Perform grid spatial interpolation.
1400
1401        Uses the [NCEPLIBS-ip library](https://github.com/NOAA-EMC/NCEPLIBS-ip).
1402
1403        Parameters
1404        ----------
1405        method
1406            Interpolate method to use. This can either be an integer or string
1407            using the following mapping:
1408
1409            | Interpolate Scheme | Integer Value |
1410            | :---:              | :---:         |
1411            | 'bilinear'         | 0             |
1412            | 'bicubic'          | 1             |
1413            | 'neighbor'         | 2             |
1414            | 'budget'           | 3             |
1415            | 'spectral'         | 4             |
1416            | 'neighbor-budget'  | 6             |
1417        grid_def_out
1418            Grib2GridDef object of the output grid.
1419        method_options : list of ints, optional
1420            Interpolation options. See the NCEPLIBS-ip documentation for
1421            more information on how these are used.
1422        num_threads : int, optional
1423            Number of OpenMP threads to use for interpolation. The default
1424            value is 1. If grib2io_interp was not built with OpenMP, then
1425            this keyword argument and value will have no impact.
1426
1427        Returns
1428        -------
1429        interp
1430            DataSet interpolated to new grid definition.  The attribute
1431            GRIB2IO_section3 is replaced with the section3 array from the new
1432            grid definition.
1433        """
1434        da = self._obj
1435        # ensure that y, x are rightmost dims; they should be if opening with
1436        # grib2io engine
1437
1438        # gdtn and gdt is not the entirety of the new s3
1439        npoints = grid_def_out.npoints
1440        s3_new = np.array([0, npoints, 0, 0, grid_def_out.gdtn] + list(grid_def_out.gdt))
1441
1442        # make new lat lons
1443        lats, lons = Grib2Message(section3=s3_new, pdtn=0, drtn=0).grid()
1444        latitude = xr.DataArray(lats, dims=['y', 'x'])
1445        longitude = xr.DataArray(lons, dims=['y', 'x'])
1446
1447        # create new coords
1448        new_coords = dict(da.coords)
1449        del new_coords['latitude']
1450        del new_coords['longitude']
1451        new_coords['longitude'] = longitude
1452        new_coords['latitude'] = latitude
1453
1454        # make grid def in from section3 on da.attrs
1455        grid_def_in = self.griddef()
1456
1457        if da.chunks is None:
1458            data = interp_nd(da.data, method=method, grid_def_in=grid_def_in,
1459                             grid_def_out=grid_def_out,
1460                             method_options=method_options, num_threads=num_threads)
1461        else:
1462            import dask
1463            front_shape = da.shape[:-2]
1464            data = da.data.map_blocks(interp_nd, method=method, grid_def_in=grid_def_in,
1465                                      grid_def_out=grid_def_out, method_options=method_options,
1466                                      chunks=da.chunks[:-2]+latitude.shape, dtype=da.dtype)
1467
1468        new_da = xr.DataArray(data, dims=da.dims, coords=new_coords, attrs=da.attrs)
1469
1470        new_da.attrs['GRIB2IO_section3'] = s3_new
1471        new_da.name = da.name
1472        return new_da

Perform grid spatial interpolation.

Uses the NCEPLIBS-ip library.

Parameters
  • method: Interpolate method to use. This can either be an integer or string using the following mapping:
Interpolate Scheme Integer Value
'bilinear' 0
'bicubic' 1
'neighbor' 2
'budget' 3
'spectral' 4
'neighbor-budget' 6

  • grid_def_out: Grib2GridDef object of the output grid.
  • method_options (list of ints, optional): Interpolation options. See the NCEPLIBS-ip documentation for more information on how these are used.
  • num_threads (int, optional): Number of OpenMP threads to use for interpolation. The default value is 1. If grib2io_interp was not built with OpenMP, then this keyword argument and value will have no impact.
Returns
  • interp: DataSet interpolated to new grid definition. The attribute GRIB2IO_section3 is replaced with the section3 array from the new grid definition.
def interp_to_stations( self, method, calls, lats, lons, method_options=None, num_threads=1) -> xarray.core.dataarray.DataArray:
1474    def interp_to_stations(self, method, calls, lats, lons, method_options=None, num_threads=1) -> xr.DataArray:
1475        """
1476        Perform spatial interpolation to station points.
1477
1478        Parameters
1479        ----------
1480        method
1481            Interpolate method to use. This can either be an integer or string
1482            using the following mapping:
1483
1484            | Interpolate Scheme | Integer Value |
1485            | :---:              | :---:         |
1486            | 'bilinear'         | 0             |
1487            | 'bicubic'          | 1             |
1488            | 'neighbor'         | 2             |
1489            | 'budget'           | 3             |
1490            | 'spectral'         | 4             |
1491            | 'neighbor-budget'  | 6             |
1492
1493        calls
1494            Station calls used for labeling new station index coordinate
1495        lats
1496            Latitudes of the station points.
1497        lons
1498            Longitudes of the station points.
1499
1500        Returns
1501        -------
1502        interp_to_stations
1503            DataArray interpolated to lat and lon locations and labeled with
1504            dimension and coordinate 'station'. (..., y, x) -> (..., station)
1505        """
1506        da = self._obj
1507        # TODO ensure that y, x are rightmost dims; they should be if opening
1508        # with grib2io engine
1509
1510        calls = np.asarray(calls)
1511        lats = np.asarray(lats)
1512        lons = np.asarray(lons)
1513        latitude = xr.DataArray(lats, dims=['station'])
1514        longitude = xr.DataArray(lons, dims=['station'])
1515
1516        # create new coords
1517        new_coords = dict(da.coords)
1518        del new_coords['latitude']
1519        del new_coords['longitude']
1520        new_coords['longitude'] = longitude
1521        new_coords['latitude'] = latitude
1522        new_coords['station'] = calls
1523
1524        new_dims = da.dims[:-2] + ('station',)
1525
1526        # make grid def in from section3 on da attrs
1527        grid_def_in = self.griddef()
1528
1529        if da.chunks is None:
1530            data = interp_nd_stations(da.data, method=method, grid_def_in=grid_def_in, lats=lats,
1531                                      lons=lons, method_options=method_options, num_threads=num_threads)
1532        else:
1533            import dask
1534            front_shape = da.shape[:-1]
1535            data = da.data.map_blocks(interp_nd_stations, method=method, grid_def_in=grid_def_in,
1536                                      lats=lats, lons=lons, method_options=method_options,
1537                                      drop_axis=-1, chunks=da.chunks[:-2]+latitude.shape,
1538                                      dtype=da.dtype)
1539
1540        new_da = xr.DataArray(data, dims=new_dims, coords=new_coords, attrs=da.attrs)
1541
1542        new_da.name = da.name
1543        return new_da

Perform spatial interpolation to station points.

Parameters
  • method: Interpolate method to use. This can either be an integer or string using the following mapping:
Interpolate Scheme Integer Value
'bilinear' 0
'bicubic' 1
'neighbor' 2
'budget' 3
'spectral' 4
'neighbor-budget' 6

  • calls: Station calls used for labeling new station index coordinate
  • lats: Latitudes of the station points.
  • lons: Longitudes of the station points.
Returns
  • interp_to_stations: DataArray interpolated to lat and lon locations and labeled with dimension and coordinate 'station'. (..., y, x) -> (..., station)
def to_grib2(self, filename, mode: Literal['x', 'w', 'a'] = 'x'):
1545    def to_grib2(self, filename, mode: typing.Literal["x", "w", "a"] = "x"):
1546        """
1547        Write a DataArray to a grib2 file.
1548
1549        Parameters
1550        ----------
1551        filename
1552            Name of the grib2 file to write to.
1553        mode: {"x", "w", "a"}, optional, default="x"
1554            Persistence mode
1555
1556            +------+-----------------------------------+
1557            | mode | Description                       |
1558            +======+===================================+
1559            | x    | create (fail if exists)           |
1560            +------+-----------------------------------+
1561            | w    | create (overwrite if exists)      |
1562            +------+-----------------------------------+
1563            | a    | append (create if does not exist) |
1564            +------+-----------------------------------+
1565
1566        """
1567        da = self._obj.copy(deep=True)
1568
1569        coords_keys = sorted(da.coords.keys())
1570        coords_keys = [k for k in coords_keys if k in AVAILABLE_NON_GEO_COORDS]
1571
1572        # If there are dimension coordinates, the DataArray is a hypercube of
1573        # grib2 messages.
1574
1575        # Create `indexes` which is a list of lists of dictionaries for all
1576        # dimension coordinates. Each dictionary key is the dimension
1577        # coordinate name and the value is a list of the dimension coordinate
1578        # values.  This allows for easy iteration over all possible grib2
1579        # messages in the DataArray by using itertools.product.
1580        #
1581        # For example:
1582        # indexes = [
1583        #     [
1584        #         {"leadTime": 9},
1585        #         {"leadTime": 12},
1586        #     ],
1587        #     [
1588        #         {"valueOfFirstFixedSurface": 900},
1589        #         {"valueOfFirstFixedSurface": 925},
1590        #         {"valueOfFirstFixedSurface": 950},
1591        #     ],
1592        # ]
1593
1594        # assign loc indexes to dimensions without indexes for uniform selection by name
1595        loc_indexes = list()
1596        for dim in da.dims:
1597            if dim not in da.indexes:
1598                da = da.assign_coords({dim: range(da[dim].size)})
1599                loc_indexes.append(dim)
1600
1601        indexes = []
1602        for index in [i for i in AVAILABLE_NON_GEO_DIMS if i in da.dims]:
1603            values = da.coords[index].values
1604            if len(values) != len(set(values)):
1605                raise ValueError(
1606                    f"Dimension coordinate '{index}' has duplicate values, but to_grib2 requires unique values to find each GRIB2 message in the DataArray."
1607                )
1608            listeach = [{index: value} for value in sorted(values)]
1609            indexes.append(listeach)
1610
1611        # If `dim_coords` is [], then the DataArray is a single grib2 message and
1612        # itertools.product(*dim_coords) will run once with `selectors = ()`.
1613        for selectors in itertools.product(*indexes):
1614            # Need to find the correct data in the DataArray based on the
1615            # dimension coordinates.
1616            filters = {k: v for d in selectors for k, v in d.items()}
1617
1618            # If `filters` is {}, then the DataArray is a single grib2 message
1619            # and da.sel(indexers={}) returns the DataArray.
1620            selected = da.sel(indexers=filters)
1621
1622            newmsg = Grib2Message(
1623                selected.attrs["GRIB2IO_section0"],
1624                selected.attrs["GRIB2IO_section1"],
1625                selected.attrs["GRIB2IO_section2"],
1626                selected.attrs["GRIB2IO_section3"],
1627                selected.attrs["GRIB2IO_section4"],
1628                selected.attrs["GRIB2IO_section5"],
1629            )
1630            newmsg.data = np.array(selected.data)
1631
1632            # For dimension coordinates, set the grib2 message metadata to the
1633            # dimension coordinate value.
1634            for index, value in filters.items():
1635                if index not in loc_indexes:
1636                    setattr(newmsg, index, value)
1637
1638            # For non-dimension coordinates, set the grib2 message metadata to
1639            # the DataArray coordinate value.
1640            for index in [i for i in coords_keys if i not in da.dims]:
1641                setattr(newmsg, index, selected.coords[index].values)
1642
1643            # Set section 5 attributes to the da.encoding dictionary.
1644            for key, value in selected.encoding.items():
1645                if key in ["dtype", "chunks", "original_shape"]:
1646                    continue
1647                setattr(newmsg, key, value)
1648
1649            # write the message to file
1650            with grib2io.open(filename, mode=mode) as f:
1651                f.write(newmsg)
1652            mode = "a"

Write a DataArray to a grib2 file.

Parameters
  • filename: Name of the grib2 file to write to.
  • mode ({"x", "w", "a"}, optional, default="x"): Persistence mode

    +------+-----------------------------------+ | mode | Description | +======+===================================+ | x | create (fail if exists) | +------+-----------------------------------+ | w | create (overwrite if exists) | +------+-----------------------------------+ | a | append (create if does not exist) | +------+-----------------------------------+

def update_attrs(self, **kwargs):
1654    def update_attrs(self, **kwargs):
1655        """
1656        Update many of the attributes of the DataArray.
1657
1658        Parameters
1659        ----------
1660        **kwargs
1661            Attributes to update.  This can include many of the GRIB2IO message
1662            attributes that you can find when you print a GRIB2IO message. For
1663            conflicting updates, the last keyword will be used.
1664
1665            +-----------------------+------------------------------------------+
1666            | kwargs                | Description                              |
1667            +=======================+==========================================+
1668            | shortName="VTMP"      | Set shortName to "VTMP", along with      |
1669            |                       | appropriate discipline,                  |
1670            |                       | parameterCategory, parameterNumber,      |
1671            |                       | fullName and units.                      |
1672            +-----------------------+------------------------------------------+
1673            | discipline=0,         | Set shortName, discipline,               |
1674            | parameterCategory=0,  | parameterCategory, parameterNumber,      |
1675            | parameterNumber=1     | fullName and units appropriate for       |
1676            |                       | "Virtual Temperature".                   |
1677            +-----------------------+------------------------------------------+
1678            | discipline=0,         | Conflicting keywords but                 |
1679            | parameterCategory=0,  | 'shortName="TMP"' wins.  Set shortName,  |
1680            | parameterNumber=1,    | discipline, parameterCategory,           |
1681            | shortName="TMP"       | parameterNumber, fullName and units      |
1682            |                       | appropriate for "Temperature".           |
1683            +-----------------------+------------------------------------------+
1684
1685        Returns
1686        -------
1687        DataArray
1688            DataArray with updated attributes.
1689        """
1690        da = self._obj.copy(deep=True)
1691
1692        newmsg = Grib2Message(
1693            da.attrs["GRIB2IO_section0"],
1694            da.attrs["GRIB2IO_section1"],
1695            da.attrs["GRIB2IO_section2"],
1696            da.attrs["GRIB2IO_section3"],
1697            da.attrs["GRIB2IO_section4"],
1698            da.attrs["GRIB2IO_section5"],
1699        )
1700
1701        coords_keys = [
1702            k
1703            for k in da.coords.keys()
1704            if k in AVAILABLE_NON_GEO_COORDS
1705        ]
1706
1707        for grib2_name, value in kwargs.items():
1708            if grib2_name == "gridDefinitionTemplateNumber":
1709                raise ValueError(
1710                    "The gridDefinitionTemplateNumber attribute cannot be updated.  The best way to change to a different grid is to interpolate the data to a new grid using the grib2io interpolate functions."
1711                )
1712            if grib2_name == "productDefinitionTemplateNumber":
1713                raise ValueError(
1714                    "The productDefinitionTemplateNumber attribute cannot be updated."
1715                )
1716            if grib2_name == "dataRepresentationTemplateNumber":
1717                raise ValueError(
1718                    "The dataRepresentationTemplateNumber attribute cannot be updated."
1719                )
1720            if grib2_name in coords_keys:
1721                warnings.warn(
1722                    f"Skipping attribute '{grib2_name}' because it is a coordinate. Use da.assign_coords() to change coordinate values."
1723                )
1724                continue
1725            if hasattr(newmsg, grib2_name):
1726                setattr(newmsg, grib2_name, value)
1727            else:
1728                warnings.warn(
1729                    f"Skipping attribute '{grib2_name}' because it is not a valid GRIB2 attribute for this message and cannot be updated."
1730                )
1731                continue
1732
1733        da.attrs["GRIB2IO_section0"] = newmsg.section0
1734        da.attrs["GRIB2IO_section1"] = newmsg.section1
1735        da.attrs["GRIB2IO_section2"] = newmsg.section2 or []
1736        da.attrs["GRIB2IO_section3"] = newmsg.section3
1737        da.attrs["GRIB2IO_section4"] = newmsg.section4
1738        da.attrs["GRIB2IO_section5"] = newmsg.section5
1739        da.attrs["fullName"] = newmsg.fullName
1740        da.attrs["shortName"] = newmsg.shortName
1741        da.attrs["units"] = newmsg.units
1742
1743        return da

Update many of the attributes of the DataArray.

Parameters
  • **kwargs: Attributes to update. This can include many of the GRIB2IO message attributes that you can find when you print a GRIB2IO message. For conflicting updates, the last keyword will be used.

+-----------------------+------------------------------------------+ | kwargs | Description | +=======================+==========================================+ | shortName="VTMP" | Set shortName to "VTMP", along with | | | appropriate discipline, | | | parameterCategory, parameterNumber, | | | fullName and units. | +-----------------------+------------------------------------------+ | discipline=0, | Set shortName, discipline, | | parameterCategory=0, | parameterCategory, parameterNumber, | | parameterNumber=1 | fullName and units appropriate for | | | "Virtual Temperature". | +-----------------------+------------------------------------------+ | discipline=0, | Conflicting keywords but | | parameterCategory=0, | 'shortName="TMP"' wins. Set shortName, | | parameterNumber=1, | discipline, parameterCategory, | | shortName="TMP" | parameterNumber, fullName and units | | | appropriate for "Temperature". | +-----------------------+------------------------------------------+

Returns
  • DataArray: DataArray with updated attributes.
def subset(self, lats, lons) -> xarray.core.dataarray.DataArray:
1745    def subset(self, lats, lons) -> xr.DataArray:
1746        """
1747        Subset the DataArray to a region defined by latitudes and longitudes.
1748
1749        Parameters
1750        ----------
1751        lats
1752            Latitude bounds of the region.
1753        lons
1754            Longitude bounds of the region.
1755
1756        Returns
1757        -------
1758        subset
1759            DataArray subset to the region.
1760        """
1761        da = self._obj.copy(deep=True)
1762
1763        newmsg = Grib2Message(
1764            da.attrs["GRIB2IO_section0"],
1765            da.attrs["GRIB2IO_section1"],
1766            da.attrs["GRIB2IO_section2"],
1767            da.attrs["GRIB2IO_section3"],
1768            da.attrs["GRIB2IO_section4"],
1769            da.attrs["GRIB2IO_section5"],
1770        )
1771
1772        newmsg.data = np.zeros((newmsg.ny, newmsg.nx), dtype=np.float32)
1773
1774        newmsg = newmsg.subset(lats, lons)
1775
1776        da.attrs["GRIB2IO_section3"] = newmsg.section3
1777
1778        mask_lat = (da.latitude >= newmsg.latitudeLastGridpoint) & (
1779            da.latitude <= newmsg.latitudeFirstGridpoint
1780        )
1781        mask_lon = (da.longitude >= newmsg.longitudeFirstGridpoint) & (
1782            da.longitude <= newmsg.longitudeLastGridpoint
1783        )
1784
1785        del newmsg
1786
1787        return da.where((mask_lon & mask_lat).compute(), drop=True)

Subset the DataArray to a region defined by latitudes and longitudes.

Parameters
  • lats: Latitude bounds of the region.
  • lons: Longitude bounds of the region.
Returns
  • subset: DataArray subset to the region.
def build_datatree_from_grib(filename, file_index, filters=None, stack_vertical=False):
1790def build_datatree_from_grib(filename, file_index, filters=None, stack_vertical=False):
1791    """
1792    Build a DataTree from GRIB2 messages.
1793
1794    Parameters
1795    ----------
1796    filename : str
1797        Path to the GRIB2 file.
1798    file_index : pandas.DataFrame
1799        DataFrame of GRIB2 message index.
1800    filters : dict, optional
1801        Filter criteria for GRIB2 messages.
1802    stack_vertical : bool, optional
1803        If True, vertical levels will be stacked in a single dataset
1804        instead of being organized in separate tree nodes.
1805
1806    Returns
1807    -------
1808    xarray.DataTree
1809        A hierarchical DataTree representation of the GRIB2 data.
1810    """
1811    if filters is None:
1812        filters = {}
1813
1814    # Apply any filters from user
1815    for k, v in filters.items():
1816        if k not in file_index.columns:
1817            file_index = file_index.copy()
1818            file_index[k] = file_index.msg.apply(lambda msg: getattr(msg, k, None))
1819        file_index = filter_index(file_index, k, v)
1820
1821    # Make a copy to avoid the SettingWithCopyWarning
1822    file_index = file_index.copy()
1823
1824    # Extract metadata needed for tree organization
1825    # Use a safer approach to handle missing attributes
1826    def safe_getattr(obj, name):
1827        try:
1828            attr = getattr(obj, name)
1829            # Need to test if the attribute is Grib2Metadata. If so,
1830            # then get the value attribute.
1831            if isinstance(attr, grib2io.templates.Grib2Metadata):
1832                attr = attr.value
1833            return attr
1834        except (AttributeError, KeyError):
1835            return None
1836
1837    for attr in _TREE_HIERARCHY_LEVELS:
1838        if (attr not in file_index.columns) and (attr != 'valueOfFirstFixedSurface'):
1839            file_index[attr] = file_index.msg.apply(lambda msg: safe_getattr(msg, attr))
1840
1841    # Also extract shortName for variable naming
1842    if 'shortName' not in file_index.columns:
1843        file_index = file_index.assign(shortName=file_index.msg.apply(lambda msg: getattr(msg, 'shortName', None)))
1844        file_index = file_index.assign(nx=file_index.msg.apply(lambda msg: getattr(msg, 'nx', None)))
1845        file_index = file_index.assign(ny=file_index.msg.apply(lambda msg: getattr(msg, 'ny', None)))
1846
1847    # Create root DataTree
1848    root = xr.DataTree()
1849
1850    # Adjust hierarchy levels if we're stacking vertical levels
1851    hierarchy_levels = list(_TREE_HIERARCHY_LEVELS) # This makes a copy
1852    if stack_vertical and "valueOfFirstFixedSurface" in hierarchy_levels:
1853        hierarchy_levels.remove("valueOfFirstFixedSurface")
1854
1855    # First group by level type
1856    level_groups = {}
1857
1858    # Create a dictionary to group data by level type
1859    for level_type in file_index['typeOfFirstFixedSurface'].unique():
1860        if pd.notna(level_type):  # Skip None/NaN values
1861            level_info = _LEVEL_NAME_MAPPING.get(level_type, f"level_{level_type}")
1862            level_name = level_info[0]
1863            level_source = level_info[1]
1864            # Get all rows for this level type
1865            level_data = file_index[file_index['typeOfFirstFixedSurface'] == level_type]
1866            level_groups[level_type] = {'name': level_name, 'data': level_data}
1867
1868    # Process each level group
1869    for level_type, group_info in level_groups.items():
1870        level_name = group_info['name']
1871        level_df = group_info['data']
1872
1873        # Create a branch for this level type
1874        level_tree = xr.DataTree()
1875
1876        # Process this branch based on PDTN, perturbation number, etc.
1877        process_level_branch(level_tree, level_df, filename)
1878
1879        # Add this branch to the main tree
1880        root[level_name] = level_tree
1881
1882    return root

Build a DataTree from GRIB2 messages.

Parameters
  • filename (str): Path to the GRIB2 file.
  • file_index (pandas.DataFrame): DataFrame of GRIB2 message index.
  • filters (dict, optional): Filter criteria for GRIB2 messages.
  • stack_vertical (bool, optional): If True, vertical levels will be stacked in a single dataset instead of being organized in separate tree nodes.
Returns
  • xarray.DataTree: A hierarchical DataTree representation of the GRIB2 data.
def process_level_branch(level_tree, df, filename):
1885def process_level_branch(level_tree, df, filename):
1886    """
1887    Process a level type branch of the data tree, organizing by PDTN and other attributes.
1888
1889    Parameters
1890    ----------
1891    level_tree : xarray.DataTree
1892        The DataTree node for this level type
1893    df : pandas.DataFrame
1894        DataFrame of messages for this level type
1895    filename : str
1896        Path to the GRIB2 file
1897    """
1898    # Group by PDTN
1899    pdtn_groups = {}
1900
1901    # Group data by PDTN first
1902    for pdtn_value in df['productDefinitionTemplateNumber'].unique():
1903        if pd.notna(pdtn_value):
1904            pdtn_df = df[df['productDefinitionTemplateNumber'] == pdtn_value]
1905            pdtn_groups[pdtn_value] = pdtn_df
1906
1907    # If there's only one PDTN value, skip creating PDTN branch level
1908    if len(pdtn_groups) == 1:
1909        pdtn, pdtn_df = next(iter(pdtn_groups.items()))
1910
1911        pdtn_name = f"pdtn_{int(pdtn)}"
1912
1913        # Check if we need to further subdivide by perturbation number
1914        has_perturbations = ('perturbationNumber' in pdtn_df.columns and
1915                             len(pdtn_df['perturbationNumber'].dropna().unique()) > 1)
1916
1917        # Check if we need to further subdivide by probabilities unique for each variable.
1918        has_probabilities = ('typeOfProbability' in pdtn_df.columns and
1919                             len(pdtn_df['typeOfProbability'].dropna().unique()) > 1)
1920
1921        if has_perturbations:
1922            # Process perturbations directly on the level tree
1923            process_perturbation_groups(level_tree, pdtn_df, filename)
1924        elif has_probabilities:
1925            # Process probability groups
1926            process_probability_groups(level_tree, pdtn_df, filename)
1927        else:
1928            # Try to create dataset directly on level
1929            try:
1930                dss = create_datasets_from_df(pdtn_df, filename)
1931                if dss is not None:
1932                    dt = xr.DataTree()
1933                    if len(dss) == 1:
1934                        dt.ds = dss[0]
1935                    else:
1936                        for ds in dss:
1937                            varname = list(ds.data_vars)[0]
1938                            dt[f"var_{varname}"] = ds
1939                    level_tree[pdtn_name] = dt
1940            except Exception as e:
1941                print(f"Error creating dataset for level with pdtn {int(pdtn)}: {e}")
1942
1943                # Try to separate by variable name as a fallback
1944                try_process_by_variables(level_tree, pdtn_df, filename)
1945    else:
1946        # Multiple PDTN values, process each group with PDTN branch nodes
1947        for pdtn, pdtn_df in pdtn_groups.items():
1948            # Use a simple node name that's easy to use in code
1949            pdtn_name = f"pdtn_{int(pdtn)}"
1950
1951            # Check if we need to further subdivide by perturbation number
1952            has_perturbations = ('perturbationNumber' in pdtn_df.columns and
1953                                 len(pdtn_df['perturbationNumber'].dropna().unique()) > 1)
1954
1955            # Check if we need to further subdivide by probabilities unique for each variable.
1956            has_probabilities = ('typeOfProbability' in pdtn_df.columns and
1957                                 len(pdtn_df['typeOfProbability'].dropna().unique()) > 1)
1958
1959            if has_perturbations:
1960                # Create a branch for this PDTN
1961                pdtn_tree = xr.DataTree()
1962
1963                # Process perturbation groups
1964                process_perturbation_groups(pdtn_tree, pdtn_df, filename)
1965
1966                # Only add the PDTN branch if it has children
1967                if len(pdtn_tree.children) > 0 or pdtn_tree.ds is not None:
1968                    level_tree[pdtn_name] = pdtn_tree
1969            elif has_probabilities:
1970                # Create a branch for this PDTN
1971                pdtn_tree = xr.DataTree()
1972
1973                # Process probability groups
1974                process_probability_groups(pdtn_tree, pdtn_df, filename)
1975
1976                # Only add the PDTN branch if it has children
1977                if len(pdtn_tree.children) > 0 or pdtn_tree.ds is not None:
1978                    level_tree[pdtn_name] = pdtn_tree
1979            else:
1980                # Create a subtree for this PDTN
1981                pdtn_tree = xr.DataTree()
1982
1983                # Try to create dataset directly on level
1984                try:
1985                    dss = create_datasets_from_df(pdtn_df, filename)
1986                    if dss is not None:
1987                        if len(dss) == 1:
1988                            pdtn_tree.ds = dss[0]
1989                        else:
1990                            for ds in dss:
1991                                varname = list(ds.data_vars)[0]
1992                                pdtn_tree[f"var_{varname}"] = ds
1993                        level_tree[pdtn_name] = pdtn_tree
1994                except Exception as e:
1995                    print(f"Error creating dataset for level with pdtn {int(pdtn)}: {e}")
1996
1997                    # Try to separate by variable name as a fallback
1998                    try_process_by_variables(pdtn_tree, pdtn_df, filename)
1999                    level_tree[pdtn_name] = pdtn_tree

Process a level type branch of the data tree, organizing by PDTN and other attributes.

Parameters
  • level_tree (xarray.DataTree): The DataTree node for this level type
  • df (pandas.DataFrame): DataFrame of messages for this level type
  • filename (str): Path to the GRIB2 file
def process_probability_groups(target_tree, pdtn_df, filename):
2002def process_probability_groups(target_tree, pdtn_df, filename):
2003    """
2004    """
2005    success = False
2006    # Group by type of probability
2007    prob_groups = {}
2008    for prob_value in pdtn_df['typeOfProbability'].unique():
2009        if pd.notna(prob_value):
2010            prob_df = pdtn_df[pdtn_df['typeOfProbability'] == prob_value]
2011            prob_groups[prob_value] = prob_df
2012
2013    # Process each probability group
2014    prob_dict = {}
2015    for prob_num, prob_df in prob_groups.items():
2016        prob_name = f"prob_{int(prob_num)}"
2017
2018        # Try to create dataset for this probability group
2019        try:
2020            dss = create_datasets_from_df(prob_df, filename)
2021            dt = xr.DataTree()
2022            if len(dss) == 1:
2023                dt.ds = dss[0]
2024                target_tree[prob_name] = dt
2025            elif len(dss) > 1:
2026                for ds in dss:
2027                    dt[f"var_{ds.data_vars[0]}"] = ds
2028            target_tree[prob_name] = dt
2029        except Exception as e:
2030            # Log error but continue processing other groups
2031            print(f"Error creating dataset for type of probability {prob_name}: {e}")
2032
2033    return success
def process_perturbation_groups(target_tree, pdtn_df, filename):
2036def process_perturbation_groups(target_tree, pdtn_df, filename):
2037    """
2038    Process perturbation groups and add them to the target tree.
2039
2040    Parameters
2041    ----------
2042    target_tree : xarray.DataTree
2043        The tree node to add perturbation groups to
2044    pdtn_df : pandas.DataFrame
2045        DataFrame of messages for a specific PDTN
2046    filename : str
2047        Path to the GRIB2 file
2048
2049    Returns
2050    -------
2051    bool
2052        True if at least one perturbation was successfully processed
2053    """
2054    success = False
2055    # Group by perturbation number
2056    pert_groups = {}
2057    for pert_value in pdtn_df['perturbationNumber'].unique():
2058        if pd.notna(pert_value):
2059            pert_df = pdtn_df[pdtn_df['perturbationNumber'] == pert_value]
2060            pert_groups[pert_value] = pert_df
2061
2062    # Process each perturbation group
2063    for pert_num, pert_df in pert_groups.items():
2064        pert_name = f"pert_{int(pert_num)}"
2065
2066        ## Try to create dataset for this perturbation group
2067        #try:
2068        #    dss = create_datasets_from_df(pert_df, filename)
2069        #    if dss is not None:
2070        #        if len(dss) == 1:
2071        #            target_tree.ds = dss[0]
2072        #        else:
2073        #            dss_dict = {f"ds_{i}": ds for i, ds in enumerate(dss)}
2074        #            atree = xr.DataTree(dss_dict)
2075        #            target_tree[prob_name] = atree
2076        #        success = True
2077        #except Exception as e:
2078        #    # Log error but continue processing other groups
2079        #    print(f"Error creating dataset for perturbation {pert_name}: {e}")
2080
2081        # Try to create dataset for this perturbation group
2082        try:
2083            dss = create_datasets_from_df(pert_df, filename)
2084            dt = xr.DataTree()
2085            if len(dss) == 1:
2086                dt.ds = dss[0]
2087                target_tree[pert_name] = dt
2088            elif len(dss) > 1:
2089                for ds in dss:
2090                    dt[f"pert{ds.data_vars[0]}"] = ds
2091            target_tree[pert_name] = dt
2092        except Exception as e:
2093            # Log error but continue processing other groups
2094            print(f"Error creating dataset for perturbation {pert_name}: {e}")
2095
2096    return success

Process perturbation groups and add them to the target tree.

Parameters
  • target_tree (xarray.DataTree): The tree node to add perturbation groups to
  • pdtn_df (pandas.DataFrame): DataFrame of messages for a specific PDTN
  • filename (str): Path to the GRIB2 file
Returns
  • bool: True if at least one perturbation was successfully processed
def try_process_by_variables(target_tree, df, filename):
2099def try_process_by_variables(target_tree, df, filename):
2100    """
2101    Try to separate data by variable names and create datasets.
2102
2103    Parameters
2104    ----------
2105    target_tree : xarray.DataTree
2106        The tree node to add variable datasets to
2107    df : pandas.DataFrame
2108        DataFrame of messages
2109    filename : str
2110        Path to the GRIB2 file
2111
2112    Returns
2113    -------
2114    bool
2115        True if at least one variable was successfully processed
2116    """
2117    success = False
2118
2119    try:
2120        for var_name in df['shortName'].unique():
2121            if pd.notna(var_name):
2122                var_df = df[df['shortName'] == var_name]
2123                try:
2124                    var_ds = create_datasets_from_df(var_df, filename)
2125                    if var_ds is not None:
2126                        target_tree[f"var_{var_name}"] = var_ds[0]
2127                        success = True
2128                except Exception as var_e:
2129                    print(f"Error creating dataset for variable {var_name}: {var_e}")
2130    except Exception as nested_e:
2131        print(f"Failed to process variables: {nested_e}")
2132
2133    return success

Try to separate data by variable names and create datasets.

Parameters
  • target_tree (xarray.DataTree): The tree node to add variable datasets to
  • df (pandas.DataFrame): DataFrame of messages
  • filename (str): Path to the GRIB2 file
Returns
  • bool: True if at least one variable was successfully processed
def create_datasets_from_df( df, filename, verbose=False) -> Optional[List[xarray.core.dataset.Dataset]]:
2136def create_datasets_from_df(
2137    df,
2138    filename,
2139    verbose=False
2140) -> typing.Optional[typing.List[xr.Dataset]]:
2141    """
2142    Create a list of xarray Datasets from a DataFrame of messages.
2143
2144    Parameters
2145    ----------
2146    df : pandas.DataFrame
2147        DataFrame of GRIB messages
2148    filename : str
2149        Path to the GRIB2 file
2150    verbose : bool, optional
2151        If True, prints detailed debugging information
2152
2153    Returns
2154    -------
2155    dss
2156        List of Datasets, or None if creation failed
2157    """
2158    try:
2159        if verbose:
2160            print(f"\n==== VERBOSE DEBUG INFO ====")
2161            print(f"Creating dataset from DataFrame with {len(df)} messages")
2162            print(f"DataFrame columns: {df.columns.tolist()}")
2163
2164            if 'shortName' in df.columns:
2165                print(f"Variables in group: {df['shortName'].unique().tolist()}")
2166
2167            if 'valueOfFirstFixedSurface' in df.columns:
2168                print(f"Vertical levels: {df['valueOfFirstFixedSurface'].unique().tolist()}")
2169
2170        # Process by variables
2171        datasets = {}
2172
2173        # Process each variable separately, regardless of whether there are vertical levels
2174        for var_name, var_df in df.groupby('shortName'):
2175            if verbose:
2176                print(
2177                    f"\n  Processing variable: {var_name} with {len(var_df)} messages, with pdtn(s) = {var_df['productDefinitionTemplateNumber'].unique()}")
2178
2179            # Process vertical levels if present
2180            if 'valueOfFirstFixedSurface' in var_df.columns and len(var_df['valueOfFirstFixedSurface'].unique()) > 1:
2181                if verbose:
2182                    print(f"  Variable {var_name} has multiple vertical levels")
2183                # Process each level separately
2184                level_das = []
2185
2186                for level, level_df in var_df.groupby('valueOfFirstFixedSurface'):
2187                    if verbose:
2188                        print(f"    Processing level {level} with {len(level_df)} messages")
2189                    try:
2190                        # Parse the index and get dimensions for this level
2191                        file_index, non_geo_dims, attrs, coord_attrs = parse_grib_index(level_df, {})
2192                        # Remove valueOfFirstFixedSurface from dimensions since we're handling it separately
2193                        non_geo_dims = [d for d in non_geo_dims if d.__name__ != "ValueOfFirstFixedSurfaceDim"]
2194
2195                        frames, cube, extra_geo = make_variables(
2196                            file_index, filename, non_geo_dims, allow_uneven_dims=True)
2197
2198                        if frames is not None and len(frames) == 1:
2199                            level_da = build_da_without_coords(frames[0], cube, filename, attrs)
2200                            # Add this level to the list with its level value as coord
2201                            level_da = level_da.assign_coords(valueOfFirstFixedSurface=level)
2202                            level_das.append(level_da)
2203                    except Exception as e:
2204                        if verbose:
2205                            print(f"    Error processing level {level} for {var_name}: {e}")
2206
2207                if level_das:
2208                    # Combine all levels into a single DataArray along the valueOfFirstFixedSurface dimension
2209                    if verbose:
2210                        print(f"    Combining {len(level_das)} levels for {var_name}")
2211                    try:
2212                        combined_da = xr.concat(level_das, dim='valueOfFirstFixedSurface')
2213                        # Create a simple dataset with just this variable
2214                        var_ds = xr.Dataset({var_name: combined_da})
2215                        # Assign the coords from the first level's cube
2216                        var_ds = assign_xr_meta(var_ds, frames, cube, non_geo_dims, extra_geo, coord_attrs)
2217                       # TODO: is the below code all now in assign_xr_meta? was there instances where refDate and leadTime were not coords?
2218                       # var_ds = var_ds.assign_coords(coords_from_cube(cube))
2219                       # Add extra geo coords
2220                       # if extra_geo:
2221                       #    var_ds = var_ds.assign_coords(extra_geo)
2222                       # Add valid date coords if available
2223                       # if 'refDate' in var_ds.coords and 'leadTime' in var_ds.coords:
2224                       #    var_ds = var_ds.assign_coords(dict(validDate=var_ds.coords['refDate']+var_ds.coords['leadTime']))
2225
2226                        # Store this variable's dataset
2227                        datasets[var_name] = var_ds
2228                        if verbose:
2229                            print(f"    Created dataset for {var_name} with levels")
2230                    except Exception as e:
2231                        if verbose:
2232                            print(f"    Error combining levels for {var_name}: {e}")
2233            else:
2234                # Single level or no vertical levels
2235                if verbose:
2236                    print(f"  Variable {var_name} is a single level or has no vertical dimension")
2237                try:
2238                    # Parse the index and get dimensions
2239                    file_index, non_geo_dims, attrs, coord_attrs = parse_grib_index(var_df, {})
2240                    frames, cube, extra_geo = make_variables(file_index, filename, non_geo_dims, allow_uneven_dims=True)
2241
2242                    if frames is not None and len(frames) == 1:
2243                        # Create dataset with this variable
2244                        var_ds = xr.Dataset()
2245                        da = build_da_without_coords(frames[0], cube, filename, attrs)
2246                        var_ds[da.name] = da
2247
2248                        # Assign coords
2249                        var_ds = assign_xr_meta(var_ds, frames, cube, non_geo_dims, extra_geo, coord_attrs)
2250                       # TODO: is the below code all now in assign_xr_meta? was there instances where refDate and leadTime were not coords?
2251                       # var_ds = var_ds.assign_coords(coords_from_cube(cube))
2252                       # if extra_geo:
2253                       #    var_ds = var_ds.assign_coords(extra_geo)
2254                       # if 'refDate' in var_ds.coords and 'leadTime' in var_ds.coords:
2255                       #    var_ds = var_ds.assign_coords(dict(validDate=var_ds.coords['refDate']+var_ds.coords['leadTime']))
2256
2257                        # Store this variable's dataset
2258                        datasets[var_name] = var_ds
2259                        if verbose:
2260                            print(f"  Created dataset for {var_name}")
2261                    elif frames is not None and len(frames) > 1:
2262                        if verbose:
2263                            print(f"  Variable {var_name} has multiple frames, possibly different parameters")
2264                        # Just use the first frame for now (simplified approach)
2265                        var_ds = xr.Dataset()
2266                        da = build_da_without_coords(frames[0], cube, filename, attrs)
2267                        var_ds[da.name] = da
2268
2269                        # Assign coords
2270                        var_ds = assign_xr_meta(var_ds, frames, cube, non_geo_dims, extra_geo, coord_attrs)
2271                       # TODO: is the below code all now in assign_xr_meta? was there instances where refDate and leadTime were not coords?
2272                       # var_ds = var_ds.assign_coords(coords_from_cube(cube))
2273                       # if extra_geo:
2274                       #    var_ds = var_ds.assign_coords(extra_geo)
2275                       # if 'refDate' in var_ds.coords and 'leadTime' in var_ds.coords:
2276                       #    var_ds = var_ds.assign_coords(dict(validDate=var_ds.coords['refDate']+var_ds.coords['leadTime']))
2277
2278                        datasets[var_name] = var_ds
2279                        if verbose:
2280                            print(f"  Created dataset with first frame for {var_name}")
2281                except Exception as e:
2282                    if verbose:
2283                        print(f"  Error processing variable {var_name}: {e}")
2284
2285        # Attempt to merge all the variable datasets
2286        if datasets:
2287            try:
2288                if verbose:
2289                    print(f"\nMerging {len(datasets)} datasets...")
2290                # Get the list of datasets to merge
2291                ds_list = list(datasets.values())
2292
2293                # Try merging them all at once
2294                try:
2295                    combined_ds = xr.merge(ds_list)
2296                    if verbose:
2297                        print(f"Successfully merged all datasets into one.")
2298                        print(f"Final dataset has variables: {list(combined_ds.data_vars)}")
2299                        print(f"==== END VERBOSE DEBUG INFO ====\n")
2300                    return [combined_ds]
2301                except Exception as merge_error:
2302                    if verbose:
2303                        print(f"Error merging all datasets: {merge_error}")
2304                    return ds_list
2305            except Exception as e:
2306                if verbose:
2307                    print(f"Error in final merge process: {e}")
2308                    print(f"==== END VERBOSE DEBUG INFO ====\n")
2309                return None
2310        else:
2311            if verbose:
2312                print(f"No datasets were created for any variables")
2313                print(f"==== END VERBOSE DEBUG INFO ====\n")
2314            return None
2315
2316    except Exception as e:
2317        # If there's an error, log it and return None
2318        if verbose:
2319            print(f"Error creating dataset: {e}")
2320            import traceback
2321            traceback.print_exc()
2322            print(f"==== END VERBOSE DEBUG INFO ====\n")
2323        return None

Create a list of xarray Datasets from a DataFrame of messages.

Parameters
  • df (pandas.DataFrame): DataFrame of GRIB messages
  • filename (str): Path to the GRIB2 file
  • verbose (bool, optional): If True, prints detailed debugging information
Returns
  • dss: List of Datasets, or None if creation failed
@xr.register_datatree_accessor('grib2io')
class Grib2ioDataTree:
2328    @xr.register_datatree_accessor("grib2io")
2329    class Grib2ioDataTree:
2330        """
2331        DataTree accessor for GRIB2 files.
2332
2333        This accessor provides methods for working with GRIB2 data organized
2334        in a hierarchical tree structure.
2335        """
2336
2337        def __init__(self, datatree_obj):
2338            self._obj = datatree_obj
2339
2340        def to_grib2(self, filename, mode: typing.Literal["x", "w", "a"] = "x"):
2341            """
2342            Write all datasets in the DataTree to a GRIB2 file.
2343
2344            Parameters
2345            ----------
2346            filename : str
2347                Name of the GRIB2 file to write to.
2348            mode : {"x", "w", "a"}, optional
2349                Persistence mode, default is "x" (create, fail if exists)
2350            """
2351            # Start with the specified mode
2352            current_mode = mode
2353
2354            # Function to recursively process the tree
2355            def process_tree(node):
2356                nonlocal current_mode
2357
2358                # If this is a Dataset node with data variables
2359                if node.ds is not None and node.ds.data_vars:
2360                    # Write dataset to GRIB2 file
2361                    node.ds.grib2io.to_grib2(filename, mode=current_mode)
2362                    # Switch to append mode after first write
2363                    current_mode = "a"
2364
2365                # Process children
2366                for child_name, child_node in node.children.items():
2367                    process_tree(child_node)
2368
2369            # Start processing from the root
2370            process_tree(self._obj)
2371
2372        def griddef(self):
2373            """
2374            Get the grid definition from the first dataset in the tree that has one.
2375
2376            Returns
2377            -------
2378            grib2io.Grib2GridDef
2379                Grid definition object
2380            """
2381            # Function to find first dataset with GRIB2IO_section3
2382            def find_griddef(node):
2383                if node.ds is not None and node.ds.data_vars:
2384                    for var_name in node.ds.data_vars:
2385                        if 'GRIB2IO_section3' in node.ds[var_name].attrs:
2386                            return Grib2GridDef.from_section3(node.ds[var_name].attrs['GRIB2IO_section3'])
2387
2388                # Check children
2389                for child_name, child_node in node.children.items():
2390                    griddef = find_griddef(child_node)
2391                    if griddef is not None:
2392                        return griddef
2393
2394                return None
2395
2396            return find_griddef(self._obj)
2397
2398        def interp(self, method, grid_def_out, method_options=None, num_threads=1):
2399            """
2400            Interpolate all datasets in the tree to a new grid.
2401
2402            Parameters
2403            ----------
2404            method : str or int
2405                Interpolation method to use
2406            grid_def_out : grib2io.Grib2GridDef
2407                Target grid definition
2408            method_options : list, optional
2409                Options for interpolation method
2410            num_threads : int, optional
2411                Number of threads to use for interpolation
2412
2413            Returns
2414            -------
2415            xarray.DataTree
2416                New DataTree with interpolated data
2417            """
2418            new_tree = xr.DataTree()
2419
2420            # Function to recursively process the tree
2421            def process_tree(node, new_parent):
2422                # If this is a Dataset node with data variables
2423                if node.ds is not None and node.ds.data_vars:
2424                    # Interpolate dataset
2425                    interp_ds = node.ds.grib2io.interp(method, grid_def_out,
2426                                                       method_options=method_options,
2427                                                       num_threads=num_threads)
2428
2429                    # Add to new tree at the same path
2430                    if node == self._obj:  # Root node
2431                        new_parent.ds = interp_ds
2432                    else:
2433                        new_parent.ds = interp_ds
2434
2435                # Process children
2436                for child_name, child_node in node.children.items():
2437                    # Create same child in new tree
2438                    new_child = xr.DataTree()
2439                    new_parent[child_name] = new_child
2440                    process_tree(child_node, new_child)
2441
2442            # Start processing from the root
2443            process_tree(self._obj, new_tree)
2444
2445            return new_tree
2446
2447        def subset(self, lats, lons):
2448            """
2449            Subset all datasets in the tree to a region.
2450
2451            Parameters
2452            ----------
2453            lats : list or tuple
2454                Latitude bounds [min_lat, max_lat]
2455            lons : list or tuple
2456                Longitude bounds [min_lon, max_lon]
2457
2458            Returns
2459            -------
2460            xarray.DataTree
2461                New DataTree with subset data
2462            """
2463            new_tree = xr.DataTree()
2464
2465            # Function to recursively process the tree
2466            def process_tree(node, new_parent):
2467                # If this is a Dataset node with data variables
2468                if node.ds is not None and node.ds.data_vars:
2469                    # Subset dataset
2470                    subset_ds = node.ds.grib2io.subset(lats, lons)
2471
2472                    # Add to new tree at the same path
2473                    if node == self._obj:  # Root node
2474                        new_parent.ds = subset_ds
2475                    else:
2476                        new_parent.ds = subset_ds
2477
2478                # Process children
2479                for child_name, child_node in node.children.items():
2480                    # Create same child in new tree
2481                    new_child = xr.DataTree()
2482                    new_parent[child_name] = new_child
2483                    process_tree(child_node, new_child)
2484
2485            # Start processing from the root
2486            process_tree(self._obj, new_tree)
2487
2488            return new_tree

DataTree accessor for GRIB2 files.

This accessor provides methods for working with GRIB2 data organized in a hierarchical tree structure.

Grib2ioDataTree(datatree_obj)
2337        def __init__(self, datatree_obj):
2338            self._obj = datatree_obj
def to_grib2(self, filename, mode: Literal['x', 'w', 'a'] = 'x'):
2340        def to_grib2(self, filename, mode: typing.Literal["x", "w", "a"] = "x"):
2341            """
2342            Write all datasets in the DataTree to a GRIB2 file.
2343
2344            Parameters
2345            ----------
2346            filename : str
2347                Name of the GRIB2 file to write to.
2348            mode : {"x", "w", "a"}, optional
2349                Persistence mode, default is "x" (create, fail if exists)
2350            """
2351            # Start with the specified mode
2352            current_mode = mode
2353
2354            # Function to recursively process the tree
2355            def process_tree(node):
2356                nonlocal current_mode
2357
2358                # If this is a Dataset node with data variables
2359                if node.ds is not None and node.ds.data_vars:
2360                    # Write dataset to GRIB2 file
2361                    node.ds.grib2io.to_grib2(filename, mode=current_mode)
2362                    # Switch to append mode after first write
2363                    current_mode = "a"
2364
2365                # Process children
2366                for child_name, child_node in node.children.items():
2367                    process_tree(child_node)
2368
2369            # Start processing from the root
2370            process_tree(self._obj)

Write all datasets in the DataTree to a GRIB2 file.

Parameters
  • filename (str): Name of the GRIB2 file to write to.
  • mode ({"x", "w", "a"}, optional): Persistence mode, default is "x" (create, fail if exists)
def griddef(self):
2372        def griddef(self):
2373            """
2374            Get the grid definition from the first dataset in the tree that has one.
2375
2376            Returns
2377            -------
2378            grib2io.Grib2GridDef
2379                Grid definition object
2380            """
2381            # Function to find first dataset with GRIB2IO_section3
2382            def find_griddef(node):
2383                if node.ds is not None and node.ds.data_vars:
2384                    for var_name in node.ds.data_vars:
2385                        if 'GRIB2IO_section3' in node.ds[var_name].attrs:
2386                            return Grib2GridDef.from_section3(node.ds[var_name].attrs['GRIB2IO_section3'])
2387
2388                # Check children
2389                for child_name, child_node in node.children.items():
2390                    griddef = find_griddef(child_node)
2391                    if griddef is not None:
2392                        return griddef
2393
2394                return None
2395
2396            return find_griddef(self._obj)

Get the grid definition from the first dataset in the tree that has one.

Returns
def interp(self, method, grid_def_out, method_options=None, num_threads=1):
2398        def interp(self, method, grid_def_out, method_options=None, num_threads=1):
2399            """
2400            Interpolate all datasets in the tree to a new grid.
2401
2402            Parameters
2403            ----------
2404            method : str or int
2405                Interpolation method to use
2406            grid_def_out : grib2io.Grib2GridDef
2407                Target grid definition
2408            method_options : list, optional
2409                Options for interpolation method
2410            num_threads : int, optional
2411                Number of threads to use for interpolation
2412
2413            Returns
2414            -------
2415            xarray.DataTree
2416                New DataTree with interpolated data
2417            """
2418            new_tree = xr.DataTree()
2419
2420            # Function to recursively process the tree
2421            def process_tree(node, new_parent):
2422                # If this is a Dataset node with data variables
2423                if node.ds is not None and node.ds.data_vars:
2424                    # Interpolate dataset
2425                    interp_ds = node.ds.grib2io.interp(method, grid_def_out,
2426                                                       method_options=method_options,
2427                                                       num_threads=num_threads)
2428
2429                    # Add to new tree at the same path
2430                    if node == self._obj:  # Root node
2431                        new_parent.ds = interp_ds
2432                    else:
2433                        new_parent.ds = interp_ds
2434
2435                # Process children
2436                for child_name, child_node in node.children.items():
2437                    # Create same child in new tree
2438                    new_child = xr.DataTree()
2439                    new_parent[child_name] = new_child
2440                    process_tree(child_node, new_child)
2441
2442            # Start processing from the root
2443            process_tree(self._obj, new_tree)
2444
2445            return new_tree

Interpolate all datasets in the tree to a new grid.

Parameters
  • method (str or int): Interpolation method to use
  • grid_def_out (grib2io.Grib2GridDef): Target grid definition
  • method_options (list, optional): Options for interpolation method
  • num_threads (int, optional): Number of threads to use for interpolation
Returns
  • xarray.DataTree: New DataTree with interpolated data
def subset(self, lats, lons):
2447        def subset(self, lats, lons):
2448            """
2449            Subset all datasets in the tree to a region.
2450
2451            Parameters
2452            ----------
2453            lats : list or tuple
2454                Latitude bounds [min_lat, max_lat]
2455            lons : list or tuple
2456                Longitude bounds [min_lon, max_lon]
2457
2458            Returns
2459            -------
2460            xarray.DataTree
2461                New DataTree with subset data
2462            """
2463            new_tree = xr.DataTree()
2464
2465            # Function to recursively process the tree
2466            def process_tree(node, new_parent):
2467                # If this is a Dataset node with data variables
2468                if node.ds is not None and node.ds.data_vars:
2469                    # Subset dataset
2470                    subset_ds = node.ds.grib2io.subset(lats, lons)
2471
2472                    # Add to new tree at the same path
2473                    if node == self._obj:  # Root node
2474                        new_parent.ds = subset_ds
2475                    else:
2476                        new_parent.ds = subset_ds
2477
2478                # Process children
2479                for child_name, child_node in node.children.items():
2480                    # Create same child in new tree
2481                    new_child = xr.DataTree()
2482                    new_parent[child_name] = new_child
2483                    process_tree(child_node, new_child)
2484
2485            # Start processing from the root
2486            process_tree(self._obj, new_tree)
2487
2488            return new_tree

Subset all datasets in the tree to a region.

Parameters
  • lats (list or tuple): Latitude bounds [min_lat, max_lat]
  • lons (list or tuple): Longitude bounds [min_lon, max_lon]
Returns
  • xarray.DataTree: New DataTree with subset data