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"""
  18
  19from grib2io._grib2io import _data
  20from grib2io import Grib2Message, Grib2GridDef, msgs_from_index, tables, templates
  21from grib2io.utils.spatial import snap_to_nearest_cell_center
  22import grib2io
  23from xarray.backends.locks import SerializableLock
  24from xarray.core import indexing
  25from xarray.backends import (
  26    BackendArray,
  27    BackendEntrypoint,
  28)
  29from copy import copy
  30from dataclasses import dataclass, field, astuple
  31import datetime
  32import glob
  33import itertools
  34import json
  35import logging
  36import os
  37import re
  38import textwrap
  39import typing
  40from numpy.typing import NDArray
  41import warnings
  42
  43from . import tables
  44
  45import numpy as np
  46import pandas as pd
  47import xarray as xr
  48from pathlib import Path
  49from pyproj import CRS
  50
  51# Check if xarray version supports DataTree
  52_HAS_DATATREE = hasattr(xr, "DataTree")
  53
  54# Check for NumPy 2.0+ StringDType
  55_HAS_STRINGDTYPE = hasattr(np, "dtypes") and hasattr(np.dtypes, "StringDType")
  56
  57_logger = logging.getLogger(__name__)
  58
  59_DEFAULT_REMOTE_STORAGE_OPTIONS = {
  60    "config_kwargs": {
  61        "connect_timeout": 30,
  62        "read_timeout": 120,
  63        "retries": {"max_attempts": 10, "mode": "adaptive"},
  64    }
  65}
  66
  67
  68def _merge_default_storage_options(storage_options):
  69    """Merge user storage options over conservative remote defaults."""
  70    merged = {
  71        "config_kwargs": {
  72            "connect_timeout": _DEFAULT_REMOTE_STORAGE_OPTIONS["config_kwargs"]["connect_timeout"],
  73            "read_timeout": _DEFAULT_REMOTE_STORAGE_OPTIONS["config_kwargs"]["read_timeout"],
  74            "retries": dict(_DEFAULT_REMOTE_STORAGE_OPTIONS["config_kwargs"]["retries"]),
  75        }
  76    }
  77    user = storage_options or {}
  78    if not isinstance(user, dict):
  79        return merged
  80
  81    for key, value in user.items():
  82        if key == "config_kwargs" and isinstance(value, dict):
  83            merged_cfg = merged.setdefault("config_kwargs", {})
  84            for cfg_key, cfg_val in value.items():
  85                if cfg_key == "retries" and isinstance(cfg_val, dict):
  86                    retries = merged_cfg.setdefault("retries", {})
  87                    retries.update(cfg_val)
  88                else:
  89                    merged_cfg[cfg_key] = cfg_val
  90        else:
  91            merged[key] = value
  92    return merged
  93
  94
  95_LOCK = SerializableLock()
  96
  97_LEVEL_NAME_MAPPING = grib2io.tables.get_table("4.5.grib2io.level.name")
  98
  99_TREE_HIERARCHY_LEVELS = [
 100    "typeOfFirstFixedSurface",
 101    "valueOfFirstFixedSurface",
 102    "productDefinitionTemplateNumber",
 103    "perturbationNumber",
 104    "leadTime",
 105    "duration",
 106    "percentileValue",
 107    "typeOfProbability",
 108    "thresholdLowerLimit",
 109    "thresholdUpperLimit",
 110]
 111
 112
 113def _decode_ptype(values: np.ndarray) -> np.ndarray:
 114    """
 115    Decode numeric precipitation type codes into human-readable strings.
 116
 117    Parameters
 118    ----------
 119    values : np.ndarray
 120        Array of numeric precipitation type codes.
 121
 122    Returns
 123    -------
 124    np.ndarray
 125        Array of decoded precipitation type strings.
 126    """
 127    return _decode_code(values, "4.201")
 128
 129
 130def _decode_code(values: np.ndarray, table: str) -> np.ndarray:
 131    """
 132    Decode numeric codes into human-readable strings using a GRIB2 table.
 133
 134    Parameters
 135    ----------
 136    values : np.ndarray
 137    Array of numeric codes.
 138    table : str
 139    The GRIB2 table to use for decoding (e.g., "4.201").
 140
 141    Returns
 142    -------
 143    np.ndarray
 144    Array of decoded string definitions.
 145    """
 146
 147    def _lookup(val):
 148        res = tables.get_value_from_table(str(int(val)), table)
 149        if isinstance(res, list):
 150            return str(res[0])
 151        return str(res)
 152
 153    # Pass otypes to avoid string truncation based on the first element
 154    # Use StringDType if available (NumPy 2.0+), otherwise fallback to object
 155    if _HAS_STRINGDTYPE:
 156        vlookup = np.vectorize(_lookup, otypes=[np.dtypes.StringDType])
 157    else:
 158        vlookup = np.vectorize(_lookup, otypes=[object])
 159    return vlookup(values)
 160
 161
 162AVAILABLE_NON_GEO_COORDS = [
 163    "duration",
 164    "leadTime",
 165    "percentileValue",
 166    "perturbationNumber",
 167    "refDate",
 168    "thresholdLowerLimit",
 169    "thresholdUpperLimit",
 170    "valueOfFirstFixedSurface",
 171    "valueOfSecondFixedSurface",
 172    "typeOfAerosol",
 173    "constituentType",
 174    "sourceSinkIndicator",
 175    "firstWavelength",
 176    "secondWavelength",
 177    "firstSizeOfAerosol",
 178    "secondSizeOfAerosol",
 179    "scaledValueOfFirstWavelength",
 180    "scaledValueOfSecondWavelength",
 181    "scaledValueOfCentralWaveNumber",
 182    "scaledValueOfFirstSize",
 183    "scaledValueOfSecondSize",
 184]
 185"""Available non-geographic coordinate names."""
 186
 187AVAILABLE_NON_GEO_DIMS = [
 188    "duration",
 189    "leadTime",
 190    "percentileValue",
 191    "perturbationNumber",
 192    "refDate",
 193    "threshold",
 194    "level",
 195    "typeOfAerosol",
 196    "constituentType",
 197    "sourceSinkIndicator",
 198    "firstWavelength",
 199    "secondWavelength",
 200    "firstSizeOfAerosol",
 201    "secondSizeOfAerosol",
 202]
 203"""Available non-geographic dimension names."""
 204
 205# Lookup table to define surface types that should be parsed as vertical coordinates
 206VERTICAL_COORDINATE_SURFACES = [
 207    "Ground or Water Surface",
 208    "Isothermal Level",
 209    "Specified radius from the centre of the Sun",
 210    "Isobaric Surface",
 211    "Mean Sea Level",
 212    "Specific Altitude Above Mean Sea Level",
 213    "Specified Height Level Above Ground",
 214    "Sigma Level",
 215    "Hybrid Level",
 216    "Depth Below Land Surface",
 217    "Isentropic (theta) Level",
 218    "Level at Specified Pressure Difference from Ground to Level",
 219    "Potential Vorticity Surface",
 220    "Eta Level",
 221    "Logarithmic Hybrid Level",
 222    "Sigma height level",
 223    "Hybrid Height Level",
 224    "Hybrid Pressure Level",
 225    "Soil level",
 226    "Sea-ice level",
 227    "Depth Below Sea Level",
 228    "Depth Below Water Surface",
 229    "Ocean Model Level",
 230    "Ocean level defined by water density (sigma-theta) difference from near-surface to level",
 231    "Ocean level defined by water potential temperature difference from near-surface to level",
 232    "Ocean level defined by vertical eddy diffusivity difference from near-surface to level",
 233    "Ocean level defined by water density (rho) difference from near-surface to level",
 234]
 235"""
 236Lookup table to define surface types that should be parsed as vertical coordinates
 237when `data_model="nws-viz"`.
 238"""
 239
 240
 241def parse_data_model(ds: xr.Dataset, data_model: str) -> xr.Dataset:
 242    """
 243    Normalize a GRIB2-derived Dataset to a target data model (currently ``"nws-viz"``).
 244
 245    When ``data_model == "nws-viz"``, this function converts coordinate and
 246    variable names to snake_case, derives CF-like metadata, promotes select
 247    GRIB-derived quantities to coordinates, optionally swaps dimensions, and
 248    standardizes units/attributes. If ``data_model`` is anything else, the
 249    input dataset is returned unchanged.
 250
 251    Parameters
 252    ----------
 253    ds : xarray.Dataset
 254        GRIB2-derived dataset whose variables and attributes follow the
 255        conventions emitted by ``grib2io``. Expected to contain GRIB-related
 256        attributes such as ``typeOfFirstFixedSurface``,
 257        ``typeOfSecondFixedSurface``, and (for probabilistic variables)
 258        ``typeOfProbability``.
 259    data_model : str
 260        Target data model name. Only the value ``"nws-viz"`` triggers
 261        transformations.
 262
 263    Returns
 264    -------
 265    xarray.Dataset
 266        A new dataset with:
 267        * Selected coordinates renamed:
 268          ``refDate -> forecast_reference_time``,
 269          ``leadTime -> lead_time``,
 270          ``validDate -> time``,
 271          ``percentileValue -> percentile``,
 272          ``thresholdLowerLimit -> threshold_lower_limit``,
 273          ``thresholdUpperLimit -> threshold_upper_limit``.
 274        * Vertical coordinates derived from
 275          ``valueOfFirstFixedSurface`` / ``valueOfSecondFixedSurface`` and their
 276          corresponding ``typeOf*FixedSurface`` definitions. New coordinate
 277          names are generated from the surface definition (lowercased, spaces
 278          to underscores, punctuation removed). If the name already exists, a
 279          ``"_2"`` suffix is appended.
 280        * Possible dimension swaps:
 281          ``level -> <derived_vertical_coord>`` when present; and for
 282          probabilistic variables, ``threshold -> threshold_lower_limit`` or
 283          ``threshold -> threshold_upper_limit`` when
 284          ``typeOfProbability`` indicates the appropriate semantics.
 285        * Variable names lowercased; dataset- and variable-level attributes
 286          converted to snake_case (except GRIB section attributes which are
 287          normalized to ``grib...``).
 288        * CF-adjacent metadata populated: ``standard_name`` and
 289          ``cell_methods`` are set via the shortname→CF lookup table.
 290        * Percent units normalized from ``"%"`` to ``"percent"`` on coordinates.
 291        * For precipitation type (``PTYPE``) thresholds, numeric codes are
 292          decoded to strings (GRIB2 Table 4.201) in relevant attrs/coords.
 293
 294    Notes
 295    -----
 296    - Precipitation type decoding uses GRIB2 Table 4.201 via
 297      ``tables.get_value_from_table(code, "4.201")`` and returns a NumPy
 298      array with ``np.dtypes.StringDType``.
 299    - CF-related lookups are performed using
 300      ``tables.get_table("shortname_to_cf")``.
 301    - Vertical coordinate surface names are validated against
 302      ``VERTICAL_COORDINATE_SURFACES`` before promotion to coordinates.
 303
 304    Warnings
 305    --------
 306    This function assumes the presence of certain GRIB-derived attributes on the
 307    first data variable (e.g., ``typeOfFirstFixedSurface``,
 308    ``typeOfSecondFixedSurface``, and possibly ``typeOfProbability``).
 309    If these are absent or malformed, errors (e.g., ``KeyError``) may occur.
 310
 311    Examples
 312    --------
 313    >>> ds2 = parse_data_model(ds, "nws-viz")
 314    >>> list(ds2.coords)
 315    ['forecast_reference_time', 'lead_time', 'time', 'percentile', ...]
 316    """
 317    # convert coordinates and attributes to CF if requested
 318    if data_model == "nws-viz":
 319        # define regex to convert to snake case
 320        pattern = re.compile(r"(?<!^)(?=[A-Z])")
 321
 322        # check for coordinates and rename
 323        for coord in ds.coords:
 324            if coord == "refDate":
 325                ds = ds.rename({"refDate": "forecast_reference_time"})
 326
 327            elif coord == "leadTime":
 328                ds = ds.rename({"leadTime": "lead_time"})
 329
 330            elif coord == "validDate":
 331                ds = ds.rename({"validDate": "time"})
 332
 333            elif coord == "percentileValue":
 334                ds = ds.rename({"percentileValue": "percentile"})
 335
 336            elif coord == "perturbationNumber":
 337                ds = ds.rename({"perturbationNumber": "perturbation"})
 338                ds["perturbation"].attrs["long_name"] = "Ensemble Perturbation Number"
 339
 340            elif coord == "thresholdLowerLimit":
 341                ds = ds.rename({"thresholdLowerLimit": "threshold_lower_limit"})
 342                ds["threshold_lower_limit"].attrs["long_name"] = "Threshold Lower Limit"
 343                ds["threshold_lower_limit"].attrs["units"] = ds[list(ds.data_vars.keys())[0]].attrs["units"]
 344
 345                if "PTYPE" in ds.data_vars:
 346                    ds["threshold_lower_limit"] = xr.apply_ufunc(
 347                        _decode_ptype,
 348                        ds["threshold_lower_limit"],
 349                        dask="parallelized",
 350                        output_dtypes=[np.dtypes.StringDType] if _HAS_STRINGDTYPE else [object],
 351                    )
 352
 353                # check if thresholdLowerLimit should be a dimension coordinate
 354                if "threshold" in ds.dims:
 355                    var_key = list(ds.data_vars.keys())[0]
 356                    prob_types = [
 357                        "Probability of event below lower limit",
 358                        "Probability of event above lower limit",
 359                        "Probability of event equal to lower limit",
 360                        "Probability of event between upper and lower limits (the range includes lower limit but not the upper limit)",
 361                    ]
 362                    if ds[var_key].attrs["typeOfProbability"] in prob_types:
 363                        ds = ds.swap_dims({"threshold": "threshold_lower_limit"})
 364
 365            elif coord == "thresholdUpperLimit":
 366                ds = ds.rename({"thresholdUpperLimit": "threshold_upper_limit"})
 367                ds["threshold_upper_limit"].attrs["long_name"] = "Threshold Upper Limit"
 368                ds["threshold_upper_limit"].attrs["units"] = ds[list(ds.data_vars.keys())[0]].attrs["units"]
 369
 370                if "PTYPE" in ds.data_vars:
 371                    ds["threshold_upper_limit"] = xr.apply_ufunc(
 372                        _decode_ptype,
 373                        ds["threshold_upper_limit"],
 374                        dask="parallelized",
 375                        output_dtypes=[np.dtypes.StringDType] if _HAS_STRINGDTYPE else [object],
 376                    )
 377
 378                if "threshold" in ds.dims:
 379                    var_key = list(ds.data_vars.keys())[0]
 380                    prob_types = [
 381                        "Probability of event below upper limit",
 382                        "Probability of event above upper limit",
 383                    ]
 384                    if ds[var_key].attrs["typeOfProbability"] in prob_types:
 385                        ds = ds.swap_dims({"threshold": "threshold_upper_limit"})
 386
 387            elif coord == "typeOfAerosol":
 388                ds = ds.rename({"typeOfAerosol": "aerosol_type"})
 389                ds["aerosol_type"].attrs["long_name"] = "Aerosol Type"
 390                ds["aerosol_type"] = xr.apply_ufunc(
 391                    _decode_code,
 392                    ds["aerosol_type"],
 393                    "4.233",
 394                    dask="parallelized",
 395                    output_dtypes=[np.dtypes.StringDType] if _HAS_STRINGDTYPE else [object],
 396                )
 397
 398            elif coord == "constituentType":
 399                ds = ds.rename({"constituentType": "constituent_type"})
 400                ds["constituent_type"].attrs["long_name"] = "Chemical Constituent Type"
 401                ds["constituent_type"] = xr.apply_ufunc(
 402                    _decode_code,
 403                    ds["constituent_type"],
 404                    "4.230",
 405                    dask="parallelized",
 406                    output_dtypes=[np.dtypes.StringDType] if _HAS_STRINGDTYPE else [object],
 407                )
 408
 409            elif coord == "sourceSinkIndicator":
 410                ds = ds.rename({"sourceSinkIndicator": "source_sink_indicator"})
 411                ds["source_sink_indicator"].attrs["long_name"] = "Source/Sink Indicator"
 412                ds["source_sink_indicator"] = xr.apply_ufunc(
 413                    _decode_code,
 414                    ds["source_sink_indicator"],
 415                    "4.238",
 416                    dask="parallelized",
 417                    output_dtypes=[np.dtypes.StringDType] if _HAS_STRINGDTYPE else [object],
 418                )
 419
 420            elif coord == "firstWavelength":
 421                ds = ds.rename({"firstWavelength": "first_wavelength"})
 422                ds["first_wavelength"].attrs["long_name"] = "First Wavelength"
 423                ds["first_wavelength"].attrs["units"] = "m"
 424
 425            elif coord == "secondWavelength":
 426                ds = ds.rename({"secondWavelength": "second_wavelength"})
 427                ds["second_wavelength"].attrs["long_name"] = "Second Wavelength"
 428                ds["second_wavelength"].attrs["units"] = "m"
 429
 430            elif coord == "firstSizeOfAerosol":
 431                ds = ds.rename({"firstSizeOfAerosol": "first_size_of_aerosol"})
 432                ds["first_size_of_aerosol"].attrs["long_name"] = "First Size of Aerosol"
 433                ds["first_size_of_aerosol"].attrs["units"] = "m"
 434
 435            elif coord == "secondSizeOfAerosol":
 436                ds = ds.rename({"secondSizeOfAerosol": "second_size_of_aerosol"})
 437                ds["second_size_of_aerosol"].attrs["long_name"] = "Second Size of Aerosol"
 438                ds["second_size_of_aerosol"].attrs["units"] = "m"
 439
 440            elif coord == "scaledValueOfFirstWavelength":
 441                ds = ds.rename({"scaledValueOfFirstWavelength": "scaled_first_wavelength"})
 442                ds["scaled_first_wavelength"].attrs["long_name"] = "Scaled Value of First Wavelength"
 443
 444            elif coord == "scaledValueOfSecondWavelength":
 445                ds = ds.rename({"scaledValueOfSecondWavelength": "scaled_second_wavelength"})
 446                ds["scaled_second_wavelength"].attrs["long_name"] = "Scaled Value of Second Wavelength"
 447
 448            elif coord == "scaledValueOfCentralWaveNumber":
 449                ds = ds.rename({"scaledValueOfCentralWaveNumber": "scaled_central_wave_number"})
 450                ds["scaled_central_wave_number"].attrs["long_name"] = "Scaled Value of Central Wave Number"
 451
 452            elif coord == "scaledValueOfFirstSize":
 453                ds = ds.rename({"scaledValueOfFirstSize": "scaled_first_size"})
 454                ds["scaled_first_size"].attrs["long_name"] = "Scaled Value of First Size"
 455
 456            elif coord == "scaledValueOfSecondSize":
 457                ds = ds.rename({"scaledValueOfSecondSize": "scaled_second_size"})
 458                ds["scaled_second_size"].attrs["long_name"] = "Scaled Value of Second Size"
 459
 460            # If the dataset has valueOfFirstFixedSurface as a coordinate
 461            elif coord == "valueOfFirstFixedSurface":
 462                # Get the valueOfFirstFixedSurface coordinate
 463                da = ds.valueOfFirstFixedSurface
 464
 465                # Get the definition and units from typeOfFirstFixedSurface
 466                var_key = list(ds.data_vars.keys())[0]
 467                definition, units = ds[var_key].attrs["typeOfFirstFixedSurface"]
 468
 469                if definition in VERTICAL_COORDINATE_SURFACES:
 470                    # Convert definition to lowercase and replace spaces with underscores
 471                    key = definition.lower().replace(" ", "_")
 472
 473                    # remove special characters
 474                    key = re.sub(r"[^a-z0-9_]", "", key)
 475
 476                    # Add units and grib_name attributes
 477                    da.attrs["units"] = units
 478                    da.attrs["grib_name"] = [
 479                        "valueOfFirstFixedSurface",
 480                        "typeOfFirstFixedSurface",
 481                    ]
 482
 483                    # Assign the coordinate with the new key name
 484                    ds = ds.assign_coords({key: da})
 485
 486                    # If valueOfFirstFixedSurface is a dimension, swap it with the new key
 487                    if "level" in ds.dims:
 488                        ds = ds.swap_dims({"level": key})
 489
 490                # Remove the original coordinates
 491                del ds["valueOfFirstFixedSurface"]
 492
 493            # If the dataset has valueOfSecondFixedSurface as a coordinate
 494            elif coord == "valueOfSecondFixedSurface":
 495                # Get the valueOfSecondFixedSurface coordinate
 496                da = ds.valueOfSecondFixedSurface
 497
 498                # Get the definition and units from typeOfSecondFixedSurface
 499                var_key = list(ds.data_vars.keys())[0]
 500                definition, units = ds[var_key].attrs["typeOfSecondFixedSurface"]
 501
 502                if definition in VERTICAL_COORDINATE_SURFACES:
 503                    # Convert definition to lowercase and replace spaces with underscores
 504                    key = definition.lower().replace(" ", "_")
 505
 506                    # remove special characters
 507                    key = re.sub(r"[^a-z0-9_]", "", key)
 508
 509                    # check if key is already in coords
 510                    if key in ds.coords:
 511                        key = key + "_2"
 512
 513                    # Add units and grib_name attributes
 514                    da.attrs["units"] = units
 515                    da.attrs["grib_name"] = [
 516                        "valueOfSecondFixedSurface",
 517                        "typeOfSecondFixedSurface",
 518                    ]
 519
 520                    # Assign the coordinate with the new key name
 521                    ds = ds.assign_coords({key: da})
 522
 523                # Remove the original coordinates
 524                del ds["valueOfSecondFixedSurface"]
 525            else:
 526                # change coord name to snake case
 527                new_coord_name = pattern.sub("_", coord).lower()
 528                ds = ds.rename({coord: new_coord_name})
 529
 530        # convert all attributes and variable names to snake case
 531        for var in ds.data_vars:
 532            da = ds[var]
 533            record = tables.get_table("shortname_to_cf").get(da.name)
 534            da.attrs["standard_name"] = "unknown" if record is None else record["cf_standard_name"]
 535            da.attrs["cell_methods"] = "unknown" if record is None else record["cf_cell_methods"]
 536
 537            ds[var] = da
 538
 539            # rename variable
 540            new_var_name = var.lower()
 541            ds = ds.rename({var: new_var_name})
 542
 543            # remove attr for typeOfFirstFixedSurface (applied as coordinate above)
 544            if "typeOfFirstFixedSurface" in ds[new_var_name].attrs:
 545                definition, units = ds[new_var_name].attrs["typeOfFirstFixedSurface"]
 546                ds[new_var_name].attrs["typeOfFirstFixedSurface"] = f"{definition} ({units})"
 547
 548            if "typeOfSecondFixedSurface" in ds[new_var_name].attrs:
 549                definition, units = ds[new_var_name].attrs["typeOfSecondFixedSurface"]
 550                ds[new_var_name].attrs["typeOfSecondFixedSurface"] = f"{definition} ({units})"
 551
 552            ds[new_var_name].attrs.pop("percentileValue", None)
 553
 554            if "threshold_lower_limit" in ds.coords:
 555                ds[new_var_name].attrs.pop("thresholdLowerLimit", None)
 556
 557            if "threshold_upper_limit" in ds.coords:
 558                ds[new_var_name].attrs.pop("thresholdUpperLimit", None)
 559
 560            for attr in list(ds[new_var_name].attrs.keys()):
 561                # skip grib section attrs
 562                if "GRIB2IO_section" in attr:
 563                    # replace GRIB2IO with grib in attr
 564                    new_attr_name = attr.replace("GRIB2IO", "grib")
 565                else:
 566                    # change attr name to snake case
 567                    new_attr_name = pattern.sub("_", attr).lower()
 568
 569                # update new attr name for specific CF names
 570                if new_attr_name == "full_name":
 571                    new_attr_name = "long_name"
 572
 573                # change % to percent
 574                if attr == "units" and ds[new_var_name].attrs[attr] == "%":
 575                    ds[new_var_name].attrs[attr] = "percent"
 576
 577                if new_var_name == "ptype" and "threshold" in new_attr_name:
 578                    value = ds[new_var_name].attrs.pop(attr)
 579                    ds[new_var_name].attrs[attr] = _decode_ptype(value)
 580                else:
 581                    # change attr name in attrs
 582                    ds[new_var_name].attrs[new_attr_name] = ds[new_var_name].attrs.pop(attr)
 583
 584            try:
 585                new_cell_methods = section4_to_cell_methods(ds[new_var_name].attrs["grib_section4"])
 586            except KeyError:
 587                pass
 588            else:
 589                if new_cell_methods is not None:
 590                    if ds[new_var_name].attrs["cell_methods"] is None:
 591                        ds[new_var_name].attrs["cell_methods"] = new_cell_methods
 592                    else:
 593                        ds[new_var_name].attrs["cell_methods"] = " ".join(ds[new_var_name].attrs["cell_methods"], new_cell_methods)
 594
 595        # change dataset attrs to snake case
 596        for attr in list(ds.attrs.keys()):
 597            # change attr name to snake case
 598            new_attr_name = pattern.sub("_", attr).lower()
 599
 600            # change attr name in attrs
 601            ds.attrs[new_attr_name] = ds.attrs.pop(attr)
 602
 603        # change % to percent
 604        for coord in ds.coords:
 605            if "units" in ds[coord].attrs and ds[coord].attrs["units"] == "%":
 606                ds[coord].attrs["units"] = "percent"
 607
 608        # Update history for provenance
 609        history = ds.attrs.get("history", "")
 610        now = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
 611        ds.attrs["history"] = f"{now}: Parsed to data model {data_model}\n{history}"
 612
 613    # Update history for provenance
 614    history = ds.attrs.get("history", "")
 615    now = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
 616    ds.attrs["history"] = f"{now}: Normalized to {data_model} data model\n{history}"
 617
 618    return ds
 619
 620
 621def section4_to_cell_methods(section4_array: np.ndarray) -> typing.Optional[str]:
 622    cell_methods = None
 623    if section4_array[1] == 0:
 624        cell_methods = f"{cell_methods:s} lead_time: point"
 625    elif section4_array[1] == 8:
 626        to_join = []
 627        # interval_end = datetime.datetime(*section4_array[17:23]
 628        time_unit_table = tables.get_table("4.4")
 629        for i in reversed(range(section4_array[23])):
 630            offset = 6 * i
 631            method = tables.get_table("4.10")[str(section4_array[25 + offset])]
 632            method = method.replace("Average", "mean").lower()
 633            if section4_array[26 + offset] == 1:
 634                dim = "forecast_reference_time"
 635            elif section4_array[26 + offset] == 2:
 636                dim = "lead_time"
 637            # duration_unit = time_unit_table[str(section4_array[27 + offset])].lower()
 638            # duration_value = section4_array[28 + offset]
 639            input_interval_units = time_unit_table[str(section4_array[29 + offset])].lower()
 640            input_interval_value = section4_array[30 + offset]
 641            to_join.append(f"{dim:s}: {method:s} (interval: {input_interval_value:d} {input_interval_units:s})")
 642            # comment: duration {duration_value:d} {duration_unit:s} ending {interval_end:%Y-%m-%dT%H:%M:%s}
 643        cell_methods = " ".join(to_join)
 644    return cell_methods
 645
 646
 647# ---------------------------------------------------------------------------
 648# Lazy import guards for optional dependencies
 649# ---------------------------------------------------------------------------
 650
 651
 652class GribBackendEntrypoint(BackendEntrypoint):
 653    """
 654    xarray backend engine entrypoint for opening and decoding grib2 files.
 655
 656    .. warning::
 657
 658       This backend is experimental and the API/behavior may change without
 659       backward compatibility.
 660    """
 661
 662    def open_dataset(
 663        self,
 664        filename_or_obj,
 665        drop_variables=None,
 666        save_index=True,
 667        filters=None,
 668        data_model=None,
 669        chunks=None,
 670        storage_options=None,
 671    ) -> xr.Dataset:
 672        """
 673        Read and parse metadata from a GRIB2 file.
 674
 675        Parameters
 676        ----------
 677        filename_or_obj : str or file-like
 678            GRIB2 file to be opened. Can be a local path or a remote URI.
 679        drop_variables : list of str, optional
 680            List of variables to exclude from the dataset.
 681        save_index : bool, optional
 682            Whether to save the GRIB2 index to a file (default is True).
 683        filters : dict, optional
 684            Filter GRIB2 messages to a single hypercube. Dictionary keys can
 685            be any GRIB2 metadata attribute name.
 686        data_model : str, optional
 687            Parse GRIB metadata following a defined data model convention
 688            (e.g., "nws-viz").
 689        chunks : int, dict or 'auto', optional
 690            If chunks is provided, it is used to load the dataset into a
 691            dask-backed dataset.
 692        storage_options : dict, optional
 693            Extra options passed to the storage backend.
 694
 695        Returns
 696        -------
 697        xarray.Dataset
 698            Xarray dataset of GRIB2 messages.
 699        """
 700        if filters is None:
 701            filters = {}
 702
 703        with grib2io.open(
 704            filename_or_obj,
 705            save_index=save_index,
 706            _xarray_backend=True,
 707            **(storage_options or {}),
 708        ) as f:
 709            file_index = pd.DataFrame(f._index)
 710            file_index = file_index.assign(msg=list(f))
 711
 712        ds = _open_dataset_from_index(
 713            file_index,
 714            filename_or_obj,
 715            filters,
 716            data_model,
 717            drop_variables=drop_variables,
 718            chunks=chunks,
 719        )
 720
 721        # Update history for provenance
 722        history = ds.attrs.get("history", "")
 723        now = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
 724        ds.attrs["history"] = f"{now}: Initialized via grib2io.open_dataset from {filename_or_obj}\n{history}"
 725
 726        return ds
 727
 728    def open_datatree(
 729        self,
 730        filename_or_obj,
 731        drop_variables=None,
 732        save_index=True,
 733        filters=None,
 734        stack_vertical=False,
 735        chunks=None,
 736    ) -> typing.Any:
 737        """
 738        Open a GRIB2 file as an xarray DataTree.
 739
 740        Parameters
 741        ----------
 742        filename : str
 743            Path to the GRIB2 file.
 744        drop_variables : list, optional
 745            List of variables to exclude.
 746        filters : dict, optional
 747            Filter criteria for GRIB2 messages.
 748        stack_vertical : bool, optional
 749            If True, organize the tree with vertical layers stacked in a single dataset.
 750        chunks : int, dict or 'auto', optional
 751            If chunks is provided, it is used to load the dataset into a
 752            dask-backed dataset.
 753
 754        Returns
 755        -------
 756        xarray.DataTree
 757            A hierarchical DataTree representation of the GRIB2 data.
 758        """
 759        if not _HAS_DATATREE:
 760            raise ImportError("xarray version does not support DataTree functionality.")
 761
 762        if filters is None:
 763            filters = {}
 764
 765        # Open the file without any filters first to get all messages
 766        with grib2io.open(filename_or_obj, save_index=save_index, _xarray_backend=True) as f:
 767            file_index = pd.DataFrame(f._index)
 768            file_index = file_index.assign(msg=list(f))
 769
 770        # Build tree structure from GRIB messages with specified options
 771        tree = build_datatree_from_grib(
 772            filename_or_obj,
 773            file_index,
 774            filters,
 775            stack_vertical=stack_vertical,
 776            drop_variables=drop_variables,
 777            chunks=chunks,
 778        )
 779
 780        # Update history for provenance
 781        now = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
 782        history = f"{now}: Initialized via grib2io.open_datatree\n"
 783
 784        def _add_history(node):
 785            if node.ds is not None:
 786                node.ds.attrs["history"] = history + node.ds.attrs.get("history", "")
 787            for child in node.children.values():
 788                _add_history(child)
 789
 790        _add_history(tree)
 791
 792        # Put warning here so it is the last message from likely other Xarray warnings.
 793        warnings.warn(
 794            "grib2io’s xarray backend DataTree support is experimental. The DataTree structure or attributes may change in future releases.",
 795            UserWarning,
 796            stacklevel=2,
 797        )
 798
 799        return tree
 800
 801
 802class GribBackendArray(BackendArray):
 803    """
 804    BackendArray implementation for GRIB2 data.
 805    """
 806
 807    def __init__(self, array: "OnDiskArray", lock: SerializableLock):
 808        """
 809        Initialize the GribBackendArray.
 810
 811        Parameters
 812        ----------
 813        array : OnDiskArray
 814            The on-disk array object.
 815        lock : SerializableLock
 816            The lock to use for thread-safe access.
 817        """
 818        self.array = array
 819        self.shape = array.shape
 820        self.dtype = np.dtype(array.dtype)
 821        self.lock = lock
 822
 823    def __getitem__(self, key: xr.core.indexing.ExplicitIndexer) -> np.typing.ArrayLike:
 824        return xr.core.indexing.explicit_indexing_adapter(
 825            key,
 826            self.shape,
 827            indexing.IndexingSupport.BASIC,
 828            self._raw_getitem,
 829        )
 830
 831    def _raw_getitem(self, key: tuple) -> np.ndarray:
 832        """
 833        Implement thread-safe access to data on disk.
 834
 835        Parameters
 836        ----------
 837        key : tuple
 838            The indexing key.
 839
 840        Returns
 841        -------
 842        np.ndarray
 843            The indexed array.
 844        """
 845        with self.lock:
 846            return self.array[key]
 847
 848
 849class Grid:
 850    def __new__(cls, section3):
 851        gdtn = section3[4]
 852        Gdt = templates.gdt_class_by_gdtn(gdtn)
 853
 854        @dataclass
 855        class _Grid(Gdt):
 856            section3: NDArray = field(init=True, repr=True)
 857            # Section 3 looked up common attributes.  Other looked up attributes are available according
 858            # to the Grid Definition Template.
 859            gridDefinitionSection: NDArray = field(init=False, repr=False, default=templates.GridDefinitionSection())
 860            sourceOfGridDefinition: int = field(init=False, repr=False, default=templates.SourceOfGridDefinition())
 861            numberOfDataPoints: int = field(init=False, repr=False, default=templates.NumberOfDataPoints())
 862            interpretationOfListOfNumbers: templates.Grib2Metadata = field(
 863                init=False,
 864                repr=False,
 865                default=templates.InterpretationOfListOfNumbers(),
 866            )
 867            gridDefinitionTemplateNumber: templates.Grib2Metadata = field(init=False, repr=False, default=templates.GridDefinitionTemplateNumber())
 868            gridDefinitionTemplate: list = field(init=False, repr=False, default=templates.GridDefinitionTemplate())
 869            _earthparams: dict = field(init=False, repr=False, default=templates.EarthParams())
 870            _dxsign: float = field(init=False, repr=False, default=templates.DxSign())
 871            _dysign: float = field(init=False, repr=False, default=templates.DySign())
 872            _llscalefactor: float = field(init=False, repr=False, default=templates.LLScaleFactor())
 873            _lldivisor: float = field(init=False, repr=False, default=templates.LLDivisor())
 874            _xydivisor: float = field(init=False, repr=False, default=templates.XYDivisor())
 875            shapeOfEarth: templates.Grib2Metadata = field(init=False, repr=False, default=templates.ShapeOfEarth())
 876            earthShape: str = field(init=False, repr=False, default=templates.EarthShape())
 877            earthRadius: float = field(init=False, repr=False, default=templates.EarthRadius())
 878            earthMajorAxis: float = field(init=False, repr=False, default=templates.EarthMajorAxis())
 879            earthMinorAxis: float = field(init=False, repr=False, default=templates.EarthMinorAxis())
 880            resolutionAndComponentFlags: list = field(init=False, repr=False, default=templates.ResolutionAndComponentFlags())
 881            ny: int = field(init=False, repr=False, default=templates.Ny())
 882            nx: int = field(init=False, repr=False, default=templates.Nx())
 883            scanModeFlags: list = field(init=False, repr=False, default=templates.ScanModeFlags())
 884            projParameters: dict = field(init=False, repr=False, default=templates.ProjParameters())
 885
 886            def __post_init__(self):
 887                self.gdtn = self.section3[4]
 888
 889        grid = _Grid(section3)
 890        return grid
 891
 892
 893def exclusive_slice_to_inclusive(item: slice):
 894    """
 895    Convert a slice with exclusive stop to an inclusive slice.
 896
 897    If the slice has a step, the stop is reduced by the step, so that both
 898    interpretations would yield the same result.
 899
 900    The means that [start, stop) is converted to [start, stop - step].
 901
 902    Parameters
 903    ----------
 904    item
 905        The slice to convert.
 906
 907    Returns
 908    -------
 909    slice
 910        The converted slice.
 911    """
 912    # return the None slice
 913    if item.start is None and item.stop is None and item.step is None:
 914        return item
 915    if not isinstance(item, slice):
 916        raise ValueError(f"item must be a slice; it was of type {type(item)}")
 917    # if step is None, it's one
 918    step = 1 if item.step is None else item.step
 919    if item.stop < item.start or step < 1:
 920        raise ValueError(f"slice {item} not accounted for")
 921    # handle case where slice has one item
 922    if abs(item.stop - item.start) == step:
 923        return [item.start]
 924    # other cases require reducing the stop by the step
 925    s = slice(item.start, item.stop - step, step)
 926    return s
 927
 928
 929class Validator:
 930    def __set_name__(self, owner, name):
 931        self.private_name = f"_{name}"
 932        self.name = name
 933
 934    def __get__(self, obj, objtype=None):
 935        try:
 936            value = getattr(obj, self.private_name)
 937        except AttributeError:
 938            value = None
 939        return value
 940
 941
 942class PdIndex(Validator):
 943    def __set__(self, obj, value):
 944        try:
 945            value = pd.Index(value)
 946        except TypeError:
 947            value = pd.Index([value])
 948        setattr(obj, self.private_name, value)
 949
 950
 951def _asarray_tuplesafe(values: typing.Any) -> np.ndarray:
 952    """
 953    Convert values to a numpy array of at most 1-dimension and preserve tuples.
 954
 955    Adapted from ``pandas.core.common._asarray_tuplesafe``.
 956
 957    Parameters
 958    ----------
 959    values : any
 960        The values to convert.
 961
 962    Returns
 963    -------
 964    np.ndarray
 965        The converted numpy array.
 966    """
 967    if isinstance(values, tuple):
 968        result = np.empty(1, dtype=object)
 969        result[0] = values
 970    else:
 971        result = np.asarray(values)
 972        if result.ndim == 2:
 973            result = np.empty(len(values), dtype=object)
 974            result[:] = values
 975
 976    return result
 977
 978
 979def array_safe_eq(a: typing.Any, b: typing.Any) -> bool:
 980    """
 981    Check if a and b are equal, even if they are numpy arrays.
 982
 983    Parameters
 984    ----------
 985    a : any
 986        First object to compare.
 987    b : any
 988        Second object to compare.
 989
 990    Returns
 991    -------
 992    bool
 993        True if equal, False otherwise.
 994    """
 995    if a is b:
 996        return True
 997    if hasattr(a, "equals"):
 998        return a.equals(b)
 999    if hasattr(a, "all") and hasattr(b, "all"):
1000        return a.shape == b.shape and (a == b).all()
1001    if hasattr(a, "all") or hasattr(b, "all"):
1002        return False
1003    try:
1004        return a == b
1005    except TypeError:
1006        return NotImplementedError
1007
1008
1009def dc_eq(dc1: typing.Any, dc2: typing.Any) -> bool:
1010    """
1011    Check if two dataclasses which hold numpy arrays are equal.
1012
1013    Parameters
1014    ----------
1015    dc1 : any
1016        First dataclass to compare.
1017    dc2 : any
1018        Second dataclass to compare.
1019
1020    Returns
1021    -------
1022    bool
1023        True if equal, False otherwise.
1024    """
1025    if dc1 is dc2:
1026        return True
1027    if dc1.__class__ is not dc2.__class__:
1028        return NotImplementedError
1029    t1 = astuple(dc1)
1030    t2 = astuple(dc2)
1031    return all(array_safe_eq(a1, a2) for a1, a2 in zip(t1, t2))
1032
1033
1034def coords_from_cube(cube: dict) -> typing.Dict[str, xr.Variable]:
1035    """
1036    Create a dictionary of xarray Variables from a cube definition.
1037
1038    Parameters
1039    ----------
1040    cube : dict
1041        Dimension cube definition.
1042
1043    Returns
1044    -------
1045    dict of str to xarray.Variable
1046        Coordinates for the Dataset/DataArray.
1047    """
1048    keys = list(cube.keys())
1049    keys.remove("x")
1050    keys.remove("y")
1051    coords = dict()
1052    for k in keys:
1053        if k is not None:
1054            if len(cube[k]) > 1:
1055                coords[k] = xr.Variable(dims=k, data=cube[k], attrs=dict(grib_name=k))
1056            elif len(cube[k]) == 1:
1057                coords[k] = xr.Variable(dims=tuple(), data=cube[k][0], attrs=dict(grib_name=k))
1058    return coords
1059
1060
1061@dataclass
1062class OnDiskArray:
1063    """
1064    On-disk array representation for GRIB2 messages.
1065    """
1066
1067    file_name: typing.Union[str, typing.List[str]]
1068    index: pd.DataFrame = field(repr=False)
1069    cube: dict = field(repr=False)
1070    shape: typing.Tuple[int, ...] = field(init=False)
1071    ndim: int = field(init=False)
1072    geo_ndim: int = field(init=False)
1073    dtype: str = "float32"
1074
1075    def __post_init__(self):
1076        # multiple grids not allowed so can just use first
1077        geo_shape = (self.index.iloc[0].ny, self.index.iloc[0].nx)
1078
1079        self.geo_shape = geo_shape
1080        self.geo_ndim = len(geo_shape)
1081
1082        if len(self.index) == 1:
1083            self.shape = geo_shape
1084        else:
1085            if self.index.index.nlevels == 1:
1086                self.shape = tuple([len(self.index.index)]) + geo_shape
1087            else:
1088                self.shape = tuple([len(i) for i in self.index.index.levels]) + geo_shape
1089        self.ndim = len(self.shape)
1090
1091        cols = ["msg", "sectionOffset"]
1092        if "file_index" in self.index.columns:
1093            cols.append("file_index")
1094        self.index = self.index[cols]
1095
1096    def __getitem__(self, item: tuple) -> np.ndarray:
1097        """
1098        Retrieve data from disk for the specified slices.
1099
1100        Parameters
1101        ----------
1102        item : tuple
1103            The slicing tuple.
1104
1105        Returns
1106        -------
1107        np.ndarray
1108            The retrieved data array.
1109        """
1110        # dimensions not in index are internal to tdlpack records; 2 dims for
1111        # grids; 1 dim for stations
1112
1113        index_slicer = item[: -self.geo_ndim]
1114        # maintain all multindex levels
1115        index_slicer = tuple([[i] if isinstance(i, int) else i for i in index_slicer])
1116
1117        # pandas loc slicing is inclusive, therefore convert slices into
1118        # explicit lists
1119        index_slicer_inclusive = tuple([exclusive_slice_to_inclusive(i) if isinstance(i, slice) else i for i in index_slicer])
1120
1121        # get records selected by item in new index dataframe
1122        if len(index_slicer_inclusive) == 1:
1123            index = self.index.loc[index_slicer_inclusive]
1124        elif len(index_slicer_inclusive) > 1:
1125            index = self.index.loc[index_slicer_inclusive, :]
1126        else:
1127            index = self.index
1128        index = index.set_index(index.index)
1129
1130        # set miloc to new relative locations in sub array
1131        index["miloc"] = list(zip(*[index.index.unique(level=dim).get_indexer(index.index.get_level_values(dim)) for dim in index.index.names]))
1132
1133        if len(index_slicer_inclusive) == 1:
1134            array_field_shape = tuple([len(index.index)]) + self.geo_shape
1135        elif len(index_slicer_inclusive) > 1:
1136            array_field_shape = index.index.levshape + self.geo_shape
1137        else:
1138            array_field_shape = self.geo_shape
1139
1140        array_field = np.full(array_field_shape, fill_value=np.nan, dtype="float32")
1141
1142        if "file_index" in index.columns:
1143            for file_idx, group in index.groupby("file_index"):
1144                filename = self.file_name[file_idx] if isinstance(self.file_name, list) else self.file_name
1145                with open(filename, mode="rb") as filehandle:
1146                    for key, row in group.iterrows():
1147                        bitmap_offset = None if pd.isna(row["sectionOffset"][6]) else int(row["sectionOffset"][6])
1148                        values = _data(filehandle, row.msg, bitmap_offset, row["sectionOffset"][7])
1149
1150                        if len(index_slicer_inclusive) >= 1:
1151                            array_field[row.miloc] = values
1152                        else:
1153                            array_field = values
1154        else:
1155            with open(self.file_name, mode="rb") as filehandle:
1156                for key, row in index.iterrows():
1157                    bitmap_offset = None if pd.isna(row["sectionOffset"][6]) else int(row["sectionOffset"][6])
1158                    values = _data(filehandle, row.msg, bitmap_offset, row["sectionOffset"][7])
1159
1160                    if len(index_slicer_inclusive) >= 1:
1161                        array_field[row.miloc] = values
1162                    else:
1163                        array_field = values
1164
1165        # handle geo dim slicing
1166        array_field = array_field[(Ellipsis,) + item[-self.geo_ndim :]]
1167
1168        # squeeze array dimensions expressed as integer
1169        for i, it in reversed(list(enumerate(item[: -self.geo_ndim]))):
1170            if isinstance(it, int):
1171                array_field = array_field[(slice(None, None, None),) * i + (0,)]
1172
1173        return array_field
1174
1175
1176def dims_to_shape(d: dict) -> tuple:
1177    """
1178    Convert dimension metadata to a shape tuple.
1179
1180    Parameters
1181    ----------
1182    d : dict
1183        Dimension metadata dictionary.
1184
1185    Returns
1186    -------
1187    tuple
1188        Shape tuple.
1189    """
1190    if "nx" in d:
1191        t = (d["ny"], d["nx"])
1192    else:
1193        t = (d["nsta"],)
1194    return t
1195
1196
1197def filter_index(index: pd.DataFrame, k: str, v: typing.Any) -> pd.DataFrame:
1198    """
1199    Filter a GRIB2 index DataFrame by a key-value pair.
1200
1201    Supports slice and vectorized-indexing similar to xarray's ``sel``.
1202
1203    Parameters
1204    ----------
1205    index : pandas.DataFrame
1206        The GRIB2 index DataFrame to filter.
1207    k : str
1208        Column name to filter by.
1209    v : any
1210        Value(s) or slice to filter for.
1211
1212    Returns
1213    -------
1214    pandas.DataFrame
1215        Filtered index.
1216    """
1217    if isinstance(v, slice):
1218        index = index.set_index(k)
1219        index = index.loc[v]
1220        index = index.reset_index()
1221    else:
1222        label = (
1223            v
1224            if getattr(v, "ndim", 1) > 1  # vectorized-indexing
1225            else _asarray_tuplesafe(v)
1226        )
1227        if label.ndim == 0:
1228            # see https://github.com/pydata/xarray/pull/4292 for details
1229            label_value = label[()] if label.dtype.kind in "mM" else label.item()
1230            try:
1231                indexer = pd.Index(index[k]).get_loc(label_value)
1232                if isinstance(indexer, int):
1233                    index = index.iloc[[indexer]]
1234                else:
1235                    index = index.iloc[indexer]
1236            except KeyError:
1237                index = index.iloc[[]]
1238        else:
1239            indexer = pd.Index(index[k]).get_indexer_for(np.ravel(v))
1240            index = index.iloc[indexer[indexer >= 0]]
1241
1242    return index
1243
1244
1245def parse_grib_index(
1246    index: pd.DataFrame,
1247    filters: typing.Mapping[str, typing.Any] = dict(),
1248) -> typing.Tuple[pd.DataFrame, typing.Dict[str, typing.List[str]], dict, typing.Dict[str, dict]]:
1249    """
1250    Apply filters.
1251
1252    Evaluate remaining dimensions based on pdtn and parse each out.
1253
1254    Parameters
1255    ----------
1256    index
1257        Pandas DataFrame containing the GRIB2 message index.
1258    filters
1259        Filter GRIB2 messages to single hypercube. Dict keys can be any
1260        GRIB2 metadata attribute name.
1261
1262    Returns
1263    -------
1264    index
1265        Modified Pandas DataFrame with added GRIB2 metadata columns.
1266    dim_coords
1267        List of GRIB2 attributes that will be used for coordinates and/or dimensions.
1268    attrs
1269        Dict of metadata attributes (non-coordinates, non-geo)
1270    """
1271
1272    # make a copy of filters, remove filters as they are applied
1273    filters = copy(filters)
1274
1275    for k, v in filters.items():
1276        if k not in index.columns:
1277            kwarg = {k: index.msg.apply(lambda msg: getattr(msg, k))}
1278            index = index.assign(**kwarg)
1279        # adopt parts of xarray's sel logic  so that filters behave similarly
1280        # allowed to filter to nothing to make empty dataset
1281        index = filter_index(index, k, v)
1282
1283    if len(index) == 0:
1284        return index, list(), dict(), dict()
1285
1286    dim_coords = dict()  # key=name of dim, value=list of coord names
1287    attrs = dict()
1288    coord_attrs = dict()
1289
1290    # expand index
1291    index = index.assign(shortName=index.msg.apply(lambda msg: msg.shortName))
1292    index = index.assign(nx=index.msg.apply(lambda msg: msg.nx))
1293    index = index.assign(ny=index.msg.apply(lambda msg: msg.ny))
1294    index = index.astype({"ny": "int", "nx": "int"})
1295
1296    # apply common filters(to all definition templates) to reduce dataset to
1297    # single cube
1298    # ensure only one of each of the below exists after filters applied
1299    required_uniques = [
1300        "productDefinitionTemplateNumber",
1301        "typeOfGeneratingProcess",
1302        "typeOfFirstFixedSurface",
1303        "typeOfSecondFixedSurface",
1304    ]
1305
1306    def meta_check(index, attrs, meta):
1307        """
1308        add meta to the datframe index
1309        check that there is a single type
1310        add the type to attrs
1311
1312        returns index, attrs
1313        """
1314        index = index.assign(**{meta: index.msg.apply(lambda msg: getattr(msg, meta))})
1315
1316        unique = index[meta].unique()
1317        if len(index[meta].unique()) > 1:
1318            raise ValueError(f"filter to a single {meta}; found: {[str(i) for i in unique]}")
1319        value = unique.item()
1320        if isinstance(value, grib2io.templates.Grib2Metadata):
1321            value = value.definition
1322
1323        # None is returned if no value found,
1324        # check and change to string None
1325        if value is None:
1326            value = "None"
1327
1328        attrs[meta] = value
1329        return index, attrs
1330
1331    for meta in required_uniques:
1332        index, attrs = meta_check(index, attrs, meta)
1333
1334    pdtn = index.productDefinitionTemplateNumber.iloc[0].value
1335
1336    # determine which non geo dimensions can be created from data by this point
1337    # the index is filtered down to a single type for all required_uniques
1338
1339    # Dim Name     # matching dim_name for using this data as index coordinate
1340    dim_coords["refDate"] = ["refDate"]
1341    coord_attrs["refDate"] = dict(standard_name="forecast_reference_time")
1342    #   dim_coords["refDate"] = ["refDate", "hour"] # non dim name matching items in list are used as non-index coordinates
1343
1344    dim_coords["leadTime"] = ["leadTime"]
1345    coord_attrs["leadTime"] = dict(standard_name="forecast_period")
1346
1347    if "valueOfFirstFixedSurface" not in index.columns:
1348        index = index.assign(valueOfFirstFixedSurface=index.msg.apply(lambda msg: msg.valueOfFirstFixedSurface))
1349    if "valueOfsecondFixedSurface" not in index.columns:
1350        index = index.assign(valueOfSecondFixedSurface=index.msg.apply(lambda msg: msg.valueOfSecondFixedSurface))
1351
1352    # dim name api change, user could run ds = ds.swap_dims(fixedSurface="valueOfFirstFixedSurface")
1353    index = index.assign(level=list(zip(index["valueOfFirstFixedSurface"], index["valueOfSecondFixedSurface"])))
1354    #   index = index.assign(level=index.msg.apply(lambda msg: msg.level))
1355    # lack of "level" indeicates don't create extra index coordinate "level"
1356    dim_coords["level"] = ["valueOfFirstFixedSurface", "valueOfSecondFixedSurface"]
1357
1358    # logic for parsing possible dims from specific product definition section
1359
1360    if pdtn in {5, 9}:
1361        # Probability forecasts at a horizontal level or in a horizontal layer
1362        # in a continuous or non-continuous time interval.  (see Template
1363        # 4.9)
1364        #       AVAILABLE_THRESHOLD = {
1365        #           0: {'has_lower': True, 'has_upper': False},
1366        #           1: {'has_lower': False, 'has_upper': True},
1367        #           2: {'has_lower': True, 'has_upper': True},
1368        #           3: {'has_lower': True, 'has_upper': False},
1369        #           4: {'has_lower': False, 'has_upper': True},
1370        #           5: {'has_lower': True, 'has_upper': False},
1371        #       }
1372
1373        index, attrs = meta_check(index, attrs, "typeOfProbability")
1374        if "thresholdLowerLimit" not in index.columns:
1375            index = index.assign(thresholdLowerLimit=index.msg.apply(lambda msg: msg.thresholdLowerLimit))
1376        if "thresholdUpperLimit" not in index.columns:
1377            index = index.assign(thresholdUpperLimit=index.msg.apply(lambda msg: msg.thresholdUpperLimit))
1378        if "threshold" not in index.columns:
1379            # 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
1380            index = index.assign(threshold=list(zip(index["thresholdLowerLimit"], index["thresholdUpperLimit"])))
1381        #           index = index.assign(threshold = index.msg.apply(lambda msg: msg.threshold))
1382
1383        # ommiting threshold results in no index being assigned for this possible dim
1384        dim_coords["threshold"] = ["thresholdLowerLimit", "thresholdUpperLimit"]
1385
1386    if pdtn in {6, 10}:
1387        # Percentile forecasts at a horizontal level or in a horizontal layer
1388        # in a continuous or non-continuous time interval.  (see Template
1389        # 4.10)
1390        dim_coords["percentileValue"] = ["percentileValue"]
1391        coord_attrs["percentileValue"] = dict(long_name="percentile", units="percent")
1392
1393    if pdtn in {
1394        8,
1395        9,
1396        10,
1397        11,
1398        12,
1399        13,
1400        14,
1401        42,
1402        43,
1403        45,
1404        46,
1405        47,
1406        61,
1407        62,
1408        63,
1409        67,
1410        68,
1411        72,
1412        73,
1413        78,
1414        79,
1415        82,
1416        83,
1417        84,
1418        85,
1419        87,
1420        91,
1421    }:
1422        dim_coords["duration"] = ["duration"]
1423
1424    if pdtn in {
1425        1,
1426        11,
1427        33,
1428        34,
1429        41,
1430        43,
1431        45,
1432        47,
1433        49,
1434        54,
1435        56,
1436        58,
1437        59,
1438        63,
1439        68,
1440        77,
1441        79,
1442        81,
1443        83,
1444        84,
1445        85,
1446        92,
1447    }:
1448        dim_coords["perturbationNumber"] = ["perturbationNumber"]
1449
1450    if pdtn in {2, 3, 4, 12, 13, 14}:
1451        index, attrs = meta_check(index, attrs, "typeOfDerivedForecast")
1452
1453    if pdtn in {5, 9}:
1454        dim_coords["typeOfProbability"] = ["typeOfProbability"]
1455
1456    if pdtn in {6, 10}:
1457        dim_coords["percentileValue"] = ["percentileValue"]
1458
1459    if pdtn in {8, 15, 42, 46, 62, 67, 72, 78, 82, 1001, 1002, 1100, 1101}:
1460        index, attrs = meta_check(index, attrs, "statisticalProcess")
1461
1462    # Logic for Trace Gas and Aerosol dimensions
1463    if pdtn in {40, 41, 42, 43, 76, 77, 78, 79}:
1464        dim_coords["constituentType"] = ["constituentType"]
1465
1466    if pdtn in {76, 77, 78, 79}:
1467        dim_coords["sourceSinkIndicator"] = ["sourceSinkIndicator"]
1468
1469    if pdtn in {44, 45, 46, 47, 48, 49, 50, 80, 81, 82, 83, 84, 85}:
1470        dim_coords["typeOfAerosol"] = ["typeOfAerosol"]
1471
1472    if pdtn in {80, 81, 82, 83, 84}:
1473        dim_coords["sourceSinkIndicator"] = ["sourceSinkIndicator"]
1474
1475    if pdtn in {48, 49, 80, 81}:
1476        dim_coords["firstWavelength"] = ["firstWavelength"]
1477        dim_coords["secondWavelength"] = ["secondWavelength"]
1478        dim_coords["firstSizeOfAerosol"] = ["firstSizeOfAerosol"]
1479        dim_coords["secondSizeOfAerosol"] = ["secondSizeOfAerosol"]
1480
1481    # Finish logic by pdtn
1482
1483    for k, v in dim_coords.items():
1484        for meta in v:
1485            if meta not in index.columns:
1486                index = index.assign(**{meta: index.msg.apply(lambda msg: getattr(msg, meta))})
1487
1488    return index, dim_coords, attrs, coord_attrs
1489
1490
1491# Custom open_datatree function to open grib files as DataTree
1492def open_datatree(
1493    filename: str,
1494    *,
1495    drop_variables: typing.Optional[typing.List[str]] = None,
1496    filters: typing.Optional[typing.Mapping[str, typing.Any]] = None,
1497    engine: str = "grib2io",
1498    chunks: typing.Optional[typing.Union[int, typing.Dict[typing.Any, typing.Any], typing.Literal["auto"]]] = None,
1499    **kwargs,
1500) -> typing.Any:
1501    """
1502    Open a GRIB2 file as an xarray DataTree.
1503
1504    Parameters
1505    ----------
1506    filename : str
1507        Path to the GRIB2 file.
1508    drop_variables : list, optional
1509        List of variables to exclude.
1510    filters : dict, optional
1511        Filter criteria for GRIB2 messages.
1512    engine : str, optional
1513        Engine to use for opening the file, defaults to "grib2io".
1514    chunks : int, dict or 'auto', optional
1515        If chunks is provided, it is used to load the dataset into a
1516        dask-backed dataset.
1517    **kwargs : optional
1518        Additional keyword arguments passed to the xarray backend.
1519
1520    Returns
1521    -------
1522    xarray.DataTree
1523        A hierarchical DataTree representation of the GRIB2 data.
1524    """
1525    if not _HAS_DATATREE:
1526        raise ImportError("xarray version does not support DataTree functionality.")
1527
1528    if filters is None:
1529        filters = {}
1530
1531    # Open the file without any filters first to get all messages
1532    with grib2io.open(filename, _xarray_backend=True) as f:
1533        file_index = pd.DataFrame(f._index)
1534        file_index = file_index.assign(msg=msgs_from_index(f._index))
1535
1536    # Build tree structure from GRIB messages
1537    root = build_datatree_from_grib(
1538        filename,
1539        file_index,
1540        filters,
1541        drop_variables=drop_variables,
1542        chunks=chunks,
1543    )
1544
1545    # Update history for provenance
1546    now = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
1547    existing_history = root.attrs.get("history", "") if hasattr(root, "attrs") else ""
1548    history = f"{now}: Initialized via grib2io.open_datatree from {filename}\n{existing_history}"
1549    if hasattr(root, "attrs"):
1550        root.attrs["history"] = history
1551    # Also add to all datasets in the tree
1552    for node in root.subtree:
1553        if node.ds is not None:
1554            node.ds.attrs["history"] = history + node.ds.attrs.get("history", "")
1555
1556    return root
1557
1558    return root
1559
1560
1561def build_da_without_coords(index: pd.DataFrame, cube: dict, filename: str, attrs: dict) -> xr.DataArray:
1562    """
1563    Build a DataArray without coordinates from a cube of grib2 messages.
1564
1565    Parameters
1566    ----------
1567    index : pd.DataFrame
1568        Index of cube.
1569    cube : dict
1570        Cube of grib2 messages.
1571    filename : str
1572        Filename of grib2 file.
1573    attrs : dict
1574        Attributes for the DataArray.
1575
1576    Returns
1577    -------
1578    xr.DataArray
1579        DataArray without coordinates.
1580    """
1581
1582    dim_names = [k for k in cube.keys() if cube[k] is not None and len(cube[k]) > 1]
1583    constant_meta_names = [k for k in cube.keys() if cube[k] is None]
1584    dims = {k: len(cube[k]) for k in dim_names}
1585
1586    # guard against bad datarrays being formed
1587    dims_total = 1
1588    dims_to_filter = []
1589    for (
1590        dim_name,
1591        dim_len,
1592    ) in dims.items():
1593        if dim_name not in {"x", "y", "station"}:
1594            dims_total *= dim_len
1595            dims_to_filter.append(dim_name)
1596
1597    # Check number of GRIB2 message indexed compared to non-X/Y
1598    # dimensions.
1599    if dims_total != len(index):
1600        raise ValueError(
1601            f"DataArray dimensions are not compatible with number of GRIB2 messages; DataArray has {dims_total} "
1602            f"and GRIB2 index has {len(index)}. Consider applying a filter for dimensions: {dims_to_filter}"
1603        )
1604
1605    data = OnDiskArray(filename, index, cube)
1606    lock = _LOCK
1607    data = GribBackendArray(data, lock)
1608    data = indexing.LazilyIndexedArray(data)
1609    if len(dim_names) != len(data.shape):
1610        raise ValueError(
1611            "different number of dimensions on data "
1612            f"and dims: {len(data.shape)} vs {len(dim_names)}\n"
1613            "Grib2 messages could not be formed into a data cube; "
1614            "It's possible extra messages exist along a non-accounted for dimension based on PDTN\n"
1615            "It might be possible to get around this by applying a filter on the non-accounted for dimension"
1616        )
1617    da = xr.DataArray(data, dims=dim_names)
1618
1619    da.encoding["original_shape"] = data.shape
1620
1621    da.encoding["preferred_chunks"] = {"y": -1, "x": -1}
1622    msg1 = index.msg.iloc[0]
1623
1624    # plain language metadata is minimized
1625    # add grib section metadata
1626    da.attrs["GRIB2IO_section0"] = msg1.section0
1627    da.attrs["GRIB2IO_section1"] = msg1.section1
1628    da.attrs["GRIB2IO_section2"] = msg1.section2 if msg1.section2 else []
1629    da.attrs["GRIB2IO_section3"] = msg1.section3
1630    da.attrs["GRIB2IO_section4"] = msg1.section4
1631    da.attrs["GRIB2IO_section5"] = msg1.section5
1632    da.attrs["fullName"] = str(msg1.fullName)
1633    da.attrs["shortName"] = str(msg1.shortName)
1634    da.attrs["units"] = str(msg1.units)
1635    da.attrs["originatingCenter"] = str(msg1.originatingCenter.definition)
1636    da.attrs["originatingSubCenter"] = str(msg1.originatingSubCenter.definition)
1637
1638    # add master table
1639    da.attrs["masterTableInfo"] = str(msg1.masterTableInfo.definition)
1640
1641    da.name = index.shortName.iloc[0]
1642    for meta_name in constant_meta_names:
1643        if meta_name in index.columns:
1644            da.attrs[meta_name] = index[meta_name].iloc[0]
1645
1646    da.attrs.update(attrs)
1647
1648    return da
1649
1650
1651def assign_xr_meta(
1652    ds: xr.Dataset,
1653    frames: typing.List[pd.DataFrame],
1654    cube: dict,
1655    non_geo_dims: typing.Dict[str, typing.List[str]],
1656    extra_geo: dict,
1657    coord_attrs: typing.Dict[str, dict],
1658) -> xr.Dataset:
1659    """
1660    Assign coordinates and attributes to the dataset.
1661
1662    Parameters
1663    ----------
1664    ds : xr.Dataset
1665        The dataset to update.
1666    frames : list of pd.DataFrame
1667        The dataframes for each variable.
1668    cube : dict
1669        The dimensions cube.
1670    non_geo_dims : dict
1671        The non-geographic dimensions.
1672    extra_geo : dict
1673        Extra geographic coordinates.
1674    coord_attrs : dict
1675        Attributes for coordinates.
1676
1677    Returns
1678    -------
1679    xr.Dataset
1680        The updated dataset.
1681    """
1682    df = frames[0]
1683
1684    # assign extra geo coords
1685    ds = ds.assign_coords(extra_geo)
1686    # add crs data from first grib message to each data variable and the dataset
1687    geo_attrs = {
1688        "crs_wkt": CRS.from_dict(df.msg.iloc[0].projParameters).to_wkt(),
1689        "gridlengthXDirection": df.msg.iloc[0].gridlengthXDirection,
1690        "gridlengthYDirection": df.msg.iloc[0].gridlengthYDirection,
1691        "latitudeFirstGridpoint": df.msg.iloc[0].latitudeFirstGridpoint,
1692        "longitudeFirstGridpoint": df.msg.iloc[0].longitudeFirstGridpoint,
1693    }
1694    for data_var in ds.data_vars:
1695        ds[data_var].attrs.update(geo_attrs)
1696    ds.attrs.update(geo_attrs)
1697
1698    # add coordinate specific attributes
1699    for coord, attrs in coord_attrs.items():
1700        ds[coord].attrs.update(attrs)
1701
1702    # assign valid date coords
1703    try:
1704        ds = ds.assign_coords(dict(validDate=ds.coords["refDate"] + ds.coords["leadTime"]))
1705        ds.validDate.attrs["standard_name"] = "time"
1706        ds.validDate.attrs["long_name"] = "time"
1707    except Exception as e:
1708        warnings.warn(f"could not parse validTime: {e}")
1709
1710    # assign attributes
1711    ds.attrs["engine"] = "grib2io"
1712
1713    return ds
1714
1715
1716def make_variables(
1717    index: pd.DataFrame,
1718    f: str,
1719    non_geo_dims: typing.Dict[str, typing.List[str]],
1720    allow_uneven_dims: bool = False,
1721) -> typing.Tuple[
1722    typing.Optional[typing.List[pd.DataFrame]],
1723    typing.Optional[typing.List[dict]],
1724    typing.Optional[dict],
1725]:
1726    """
1727    Create an individual dataframe index and cube for each variable.
1728
1729    Parameters
1730    ----------
1731    index : pd.DataFrame
1732        Index of messages.
1733    f : str
1734        Filename.
1735    non_geo_dims : dict
1736        Dimensions not associated with the x,y grid.
1737    allow_uneven_dims : bool, optional
1738        If True, allows uneven dimensions (used for DataTree creation).
1739
1740    Returns
1741    -------
1742    ordered_frames : list of pd.DataFrame, optional
1743        List of dataframes, one for each variable.
1744    cubes : list of dict, optional
1745        List of cubes, one for each variable.
1746    extra_geo : dict, optional
1747        Extra geographic coordinates.
1748    """
1749    # let shortName determine the variables
1750
1751    # set the index to the name
1752    index = index.set_index("shortName").sort_index()
1753    # return nothing if no data
1754    if index.empty:
1755        return None, None, None
1756
1757    # define the DimCube
1758    dims = copy(non_geo_dims)
1759
1760    ordered_meta = list(non_geo_dims.keys())
1761    cubes = list()
1762    ordered_frames = list()
1763    for key in index.index.unique():
1764        frame = index.loc[[key]]
1765        frame = frame.reset_index()
1766        # frame is a dataframe with all records for one variable
1767        c = dict()
1768        # for colname in frame.columns:
1769        for colname in ordered_meta:
1770            uniques = pd.Index(frame[colname]).unique()
1771            if len(uniques) > 1:
1772                c[colname] = uniques.sort_values()
1773            else:
1774                c[colname] = [uniques[0]]
1775
1776        dims = [k for k in ordered_meta if len(c[k]) > 1]
1777
1778        for dim in dims:
1779            if frame[dim].value_counts().nunique() > 1 and not allow_uneven_dims:
1780                raise ValueError(f"uneven number of grib msgs associated with dimension: {dim}\n unique values for {dim}: {frame[dim].unique()} ")
1781
1782        if len(dims) >= 1:  # dims may be empty if no extra dims on top of x,y
1783            frame = frame.sort_values(dims)
1784            frame = frame.set_index(dims)
1785
1786        cubes.append(c)
1787
1788        # miloc is multi-index integer location of msg in nd DataArray
1789        miloc = list(zip(*[frame.index.unique(level=dim).get_indexer(frame.index.get_level_values(dim)) for dim in dims]))
1790
1791        # set frame multi index
1792        if len(miloc) >= 1:  # miloc will be empty when no extra dims, thus no multiindex
1793            dim_ix = tuple([n + "_ix" for n in dims])
1794            frame = frame.set_index(pd.MultiIndex.from_tuples(miloc, names=dim_ix))
1795
1796        ordered_frames.append(frame)
1797
1798    # no variables
1799    if not cubes:
1800        cubes = [dict()]
1801
1802    # check geography of data and assign to cube
1803    if len(index.ny.unique()) > 1 or len(index.nx.unique()) > 1:
1804        raise ValueError("multiple grids not accommodated")
1805    for cube in cubes:
1806        cube["y"] = range(int(index.ny.iloc[0]))
1807        cube["x"] = range(int(index.nx.iloc[0]))
1808
1809    extra_geo = None
1810    msg = index.msg.iloc[0]
1811
1812    # we want the lat lons; make them via accessing a record; we are assuming
1813    # all records are the same grid because they have the same shape;
1814    # may want a unique grid identifier from grib2io to avoid assuming this
1815    latitude, longitude = msg.latlons()
1816    latitude = xr.DataArray(latitude, dims=["y", "x"])
1817    latitude.attrs["standard_name"] = "latitude"
1818    latitude.attrs["units"] = "degrees_north"
1819    longitude = xr.DataArray(longitude, dims=["y", "x"])
1820    longitude.attrs["standard_name"] = "longitude"
1821    longitude.attrs["units"] = "degrees_east"
1822    extra_geo = dict(latitude=latitude, longitude=longitude)
1823
1824    return ordered_frames, cubes, extra_geo
1825
1826
1827def interp_nd(
1828    a: np.ndarray,
1829    *,
1830    method: typing.Union[str, int],
1831    grid_def_in: grib2io.Grib2GridDef,
1832    grid_def_out: grib2io.Grib2GridDef,
1833    method_options: typing.Optional[typing.List[int]] = None,
1834    num_threads: int = 1,
1835) -> np.ndarray:
1836    """
1837    Perform multi-dimensional interpolation on a horizontal grid.
1838
1839    This function reshapes the input array to (N, ny, nx) before performing
1840    interpolation and then reshapes it back to its original dimensions plus
1841    the new grid dimensions.
1842
1843    Parameters
1844    ----------
1845    a : np.ndarray
1846        Input array with horizontal dimensions (..., ny, nx).
1847    method : str or int
1848        Interpolation method.
1849    grid_def_in : grib2io.Grib2GridDef
1850        Input grid definition.
1851    grid_def_out : grib2io.Grib2GridDef
1852        Output grid definition.
1853    method_options : list of int, optional
1854        Interpolation options.
1855    num_threads : int, optional
1856        Number of threads for parallel interpolation.
1857
1858    Returns
1859    -------
1860    np.ndarray
1861        Interpolated array with horizontal dimensions of the output grid.
1862    """
1863    front_shape = a.shape[:-2]
1864    a = a.reshape(-1, a.shape[-2], a.shape[-1])
1865    a = grib2io.interpolate(
1866        a,
1867        method,
1868        grid_def_in,
1869        grid_def_out,
1870        method_options=method_options,
1871        num_threads=num_threads,
1872    )
1873    a = a.reshape(front_shape + (a.shape[-2], a.shape[-1]))
1874    return a
1875
1876
1877def interp_nd_stations(
1878    a: np.ndarray,
1879    *,
1880    method: typing.Union[str, int],
1881    grid_def_in: grib2io.Grib2GridDef,
1882    lats: typing.Sequence[float],
1883    lons: typing.Sequence[float],
1884    method_options: typing.Optional[typing.List[int]] = None,
1885    num_threads: int = 1,
1886) -> np.ndarray:
1887    """
1888    Perform multi-dimensional interpolation to station points.
1889
1890    This function reshapes the input array to (N, ny, nx) before performing
1891    interpolation and then reshapes it back to its original dimensions plus
1892    the station dimension.
1893
1894    Parameters
1895    ----------
1896    a : np.ndarray
1897        Input array with horizontal dimensions (..., ny, nx).
1898    method : str or int
1899        Interpolation method.
1900    grid_def_in : grib2io.Grib2GridDef
1901        Input grid definition.
1902    lats : sequence of float
1903        Station latitudes.
1904    lons : sequence of float
1905        Station longitudes.
1906    method_options : list of int, optional
1907        Interpolation options.
1908    num_threads : int, optional
1909        Number of threads for parallel interpolation.
1910
1911    Returns
1912    -------
1913    np.ndarray
1914        Interpolated array with the last dimension representing stations.
1915    """
1916    front_shape = a.shape[:-2]
1917    a = a.reshape(-1, a.shape[-2], a.shape[-1])
1918    a = grib2io.interpolate_to_stations(
1919        a,
1920        method,
1921        grid_def_in,
1922        lats,
1923        lons,
1924        method_options=method_options,
1925        num_threads=num_threads,
1926    )
1927    a = a.reshape(front_shape + (len(lats),))
1928    return a
1929
1930
1931@xr.register_dataset_accessor("grib2io")
1932class Grib2ioDataSet:
1933    def __init__(self, xarray_obj):
1934        self._obj = xarray_obj
1935
1936    def griddef(self):
1937        return Grib2GridDef.from_section3(self._obj[list(self._obj.data_vars)[0]].attrs["GRIB2IO_section3"])
1938
1939    def interp(
1940        self,
1941        method: typing.Union[str, int],
1942        grid_def_out: grib2io.Grib2GridDef,
1943        method_options: typing.Optional[typing.List[int]] = None,
1944        num_threads: int = 1,
1945    ) -> xr.Dataset:
1946        """
1947        Perform grid spatial interpolation on all variables in the Dataset.
1948
1949        Parameters
1950        ----------
1951        method : str or int
1952            Interpolation method.
1953        grid_def_out : grib2io.Grib2GridDef
1954            Output grid definition.
1955        method_options : list of int, optional
1956            Interpolation options.
1957        num_threads : int, optional
1958            Number of threads.
1959
1960        Returns
1961        -------
1962        xarray.Dataset
1963            Interpolated dataset.
1964        """
1965        da = self._obj.to_array()
1966        da.attrs["GRIB2IO_section3"] = self._obj[list(self._obj.data_vars)[0]].attrs["GRIB2IO_section3"]
1967        da = da.grib2io.interp(method, grid_def_out, method_options=method_options, num_threads=num_threads)
1968        ds = da.to_dataset(dim="variable")
1969
1970        # Update history for provenance
1971        history = ds.attrs.get("history", "")
1972        now = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
1973        ds.attrs["history"] = f"{now}: Interpolated via {method} to {grid_def_out}\n{history}"
1974
1975        return ds
1976
1977    def interp_to_stations(
1978        self,
1979        method: typing.Union[str, int],
1980        calls: typing.Sequence[str],
1981        lats: typing.Sequence[float],
1982        lons: typing.Sequence[float],
1983        method_options: typing.Optional[typing.List[int]] = None,
1984        num_threads: int = 1,
1985    ) -> xr.Dataset:
1986        """
1987        Perform spatial interpolation to station points on all variables.
1988
1989        Parameters
1990        ----------
1991        method : str or int
1992            Interpolation method.
1993        calls : sequence of str
1994            Station call signs.
1995        lats : sequence of float
1996            Station latitudes.
1997        lons : sequence of float
1998            Station longitudes.
1999        method_options : list of int, optional
2000            Interpolation options.
2001        num_threads : int, optional
2002            Number of threads.
2003
2004        Returns
2005        -------
2006        xarray.Dataset
2007            Dataset interpolated to stations.
2008        """
2009        da = self._obj.to_array()
2010        da.attrs["GRIB2IO_section3"] = self._obj[list(self._obj.data_vars)[0]].attrs["GRIB2IO_section3"]
2011        da = da.grib2io.interp_to_stations(
2012            method,
2013            calls,
2014            lats,
2015            lons,
2016            method_options=method_options,
2017            num_threads=num_threads,
2018        )
2019        ds = da.to_dataset(dim="variable")
2020
2021        # Update history for provenance
2022        history = ds.attrs.get("history", "")
2023        now = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
2024        ds.attrs["history"] = f"{now}: Interpolated to {len(calls)} stations via {method}\n{history}"
2025
2026        return ds
2027
2028    def to_grib2(self, filename, mode: typing.Literal["x", "w", "a"] = "x"):
2029        """
2030        Write a DataSet to a grib2 file.
2031
2032        Parameters
2033        ----------
2034        filename
2035            Name of the grib2 file to write to.
2036        mode: {"x", "w", "a"}, optional, default="x"
2037            Persistence mode
2038
2039            | mode | Description                       |
2040            | :---:| :---:                             |
2041            | 'x'  | create (fail if exists)           |
2042            | 'w'  | create (overwrite if exists)      |
2043            | 'a'  | append (create if does not exist) |
2044
2045        """
2046        ds = self._obj
2047
2048        for shortName in sorted(ds):
2049            # make a DataArray from the "Data Variables" in the DataSet
2050            da = ds[shortName]
2051
2052            da.grib2io.to_grib2(filename, mode=mode)
2053            mode = "a"
2054
2055    def update_attrs(self, **kwargs):
2056        """
2057        Raises an error because Datasets don't have a .attrs attribute.
2058
2059        Parameters
2060        ----------
2061        attrs
2062            Attributes to update.
2063        """
2064        raise ValueError(f"Datasets do not have a .attrs attribute; use .grib2io.update_attrs({kwargs}) on a DataArray instead.")
2065
2066    def subset(self, *, lats=None, lons=None) -> xr.Dataset:
2067        """
2068        Subset the Dataset to a box defined by latitudes and/or longitudes.
2069
2070        Parameters
2071        ----------
2072        lats
2073            Two item list or tuple of latitudes.  Default is None which will
2074            return a subset unbounded by latitude.  The first term defines the
2075            southern boundary and the second term defines the northern
2076            boundary.
2077        lons
2078            Two item list or tuple of longitudes.  Default is None which will
2079            return a subset unbounded by longitude.  The first term defines the
2080            western boundary and the second term defines the eastern
2081            boundary.  Can follow either: 0 to 360 postive eastward, or 0 to
2082            -180 westward / 0 to 180 eastward conventions.  The longitude
2083            boundaries cannot cross 0.
2084
2085        Returns
2086        -------
2087        subset
2088            Dataset subset to the bounding box created by input 'lats'/'lons'.
2089            All gridpoints with lat/lon matching contraints are included within
2090            subset.
2091        """
2092        ds = self._obj
2093
2094        newds = xr.Dataset()
2095        for shortName in ds:
2096            newds[shortName] = ds[shortName].grib2io.subset(lats=lats, lons=lons).copy()
2097
2098        # Update history for provenance
2099        history = newds.attrs.get("history", "")
2100        now = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
2101        newds.attrs["history"] = f"{now}: Subsetted to lats={lats}, lons={lons}\n{history}"
2102
2103        return newds
2104
2105    def compute(self, **kwargs):
2106        """
2107        Compute the Dask-backed Dataset with retries for transient errors.
2108
2109        Wraps :func:`grib2io.utils.compute_with_retries`.
2110
2111        Parameters
2112        ----------
2113        **kwargs
2114            Arguments passed to :func:`grib2io.utils.compute_with_retries`,
2115            e.g., `max_attempts` or `base_sleep`.
2116
2117        Returns
2118        -------
2119        xarray.Dataset
2120            The computed Dataset with NumPy-backed data.
2121        """
2122        from .utils import compute_with_retries
2123
2124        return compute_with_retries(self._obj, **kwargs)
2125
2126
2127@xr.register_dataarray_accessor("grib2io")
2128class Grib2ioDataArray:
2129    def __init__(self, xarray_obj):
2130        self._obj = xarray_obj
2131
2132    def griddef(self):
2133        return Grib2GridDef.from_section3(self._obj.attrs["GRIB2IO_section3"])
2134
2135    def interp(self, method, grid_def_out, method_options=None, num_threads=1) -> xr.DataArray:
2136        """
2137        Perform grid spatial interpolation.
2138
2139        Uses the [NCEPLIBS-ip library](https://github.com/NOAA-EMC/NCEPLIBS-ip).
2140
2141        Parameters
2142        ----------
2143        method
2144            Interpolate method to use. This can either be an integer or string
2145            using the following mapping:
2146
2147            | Interpolate Scheme | Integer Value |
2148            | :---:              | :---:         |
2149            | 'bilinear'         | 0             |
2150            | 'bicubic'          | 1             |
2151            | 'neighbor'         | 2             |
2152            | 'budget'           | 3             |
2153            | 'spectral'         | 4             |
2154            | 'neighbor-budget'  | 6             |
2155        grid_def_out
2156            Grib2GridDef object of the output grid.
2157        method_options : list of ints, optional
2158            Interpolation options. See the NCEPLIBS-ip documentation for
2159            more information on how these are used.
2160        num_threads : int, optional
2161            Number of OpenMP threads to use for interpolation. The default
2162            value is 1. If grib2io_interp was not built with OpenMP, then
2163            this keyword argument and value will have no impact.
2164
2165        Returns
2166        -------
2167        interp
2168            DataSet interpolated to new grid definition.  The attribute
2169            GRIB2IO_section3 is replaced with the section3 array from the new
2170            grid definition.
2171        """
2172        da = self._obj
2173        # ensure that y, x are rightmost dims; they should be if opening with
2174        # grib2io engine
2175
2176        # gdtn and gdt is not the entirety of the new s3
2177        npoints = grid_def_out.npoints
2178        s3_new = np.array([0, npoints, 0, 0, grid_def_out.gdtn] + list(grid_def_out.gdt))
2179
2180        # make new lat lons
2181        lats, lons = Grib2Message(section3=s3_new, pdtn=0, drtn=0).grid()
2182        latitude = xr.DataArray(lats, dims=["y", "x"])
2183        longitude = xr.DataArray(lons, dims=["y", "x"])
2184
2185        # create new coords
2186        new_coords = dict(da.coords)
2187        del new_coords["latitude"]
2188        del new_coords["longitude"]
2189        new_coords["longitude"] = longitude
2190        new_coords["latitude"] = latitude
2191
2192        # make grid def in from section3 on da.attrs
2193        grid_def_in = self.griddef()
2194
2195        if da.chunks is None:
2196            data = interp_nd(
2197                da.data,
2198                method=method,
2199                grid_def_in=grid_def_in,
2200                grid_def_out=grid_def_out,
2201                method_options=method_options,
2202                num_threads=num_threads,
2203            )
2204        else:
2205            data = da.data.map_blocks(
2206                interp_nd,
2207                method=method,
2208                grid_def_in=grid_def_in,
2209                grid_def_out=grid_def_out,
2210                method_options=method_options,
2211                chunks=da.chunks[:-2] + latitude.shape,
2212                dtype=da.dtype,
2213            )
2214
2215        new_da = xr.DataArray(data, dims=da.dims, coords=new_coords, attrs=da.attrs)
2216
2217        new_da.attrs["GRIB2IO_section3"] = s3_new
2218        new_da.name = da.name
2219
2220        # Update history for provenance
2221        history = new_da.attrs.get("history", "")
2222        now = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
2223        new_da.attrs["history"] = f"{now}: Interpolated via {method} to {grid_def_out}\n{history}"
2224
2225        return new_da
2226
2227    def interp_to_stations(
2228        self,
2229        method: typing.Union[str, int],
2230        calls: typing.Sequence[str],
2231        lats: typing.Sequence[float],
2232        lons: typing.Sequence[float],
2233        method_options: typing.Optional[typing.List[int]] = None,
2234        num_threads: int = 1,
2235    ) -> xr.DataArray:
2236        """
2237        Perform spatial interpolation to station points.
2238
2239        Parameters
2240        ----------
2241        method : str or int
2242            Interpolate method to use. This can either be an integer or string
2243            using the following mapping:
2244
2245            | Interpolate Scheme | Integer Value |
2246            | :---:              | :---:         |
2247            | 'bilinear'         | 0             |
2248            | 'bicubic'          | 1             |
2249            | 'neighbor'         | 2             |
2250            | 'budget'           | 3             |
2251            | 'spectral'         | 4             |
2252            | 'neighbor-budget'  | 6             |
2253
2254        calls : sequence of str
2255            Station calls used for labeling new station index coordinate
2256        lats : sequence of float
2257            Latitudes of the station points.
2258        lons : sequence of float
2259            Longitudes of the station points.
2260        method_options : list of int, optional
2261            Interpolation options.
2262        num_threads : int, optional
2263            Number of threads.
2264
2265        Returns
2266        -------
2267        xarray.DataArray
2268            DataArray interpolated to lat and lon locations and labeled with
2269            dimension and coordinate 'station'. (..., y, x) -> (..., station)
2270        """
2271        da = self._obj
2272        # TODO ensure that y, x are rightmost dims; they should be if opening
2273        # with grib2io engine
2274
2275        calls = np.asarray(calls)
2276        lats = np.asarray(lats)
2277        lons = np.asarray(lons)
2278        latitude = xr.DataArray(lats, dims=["station"])
2279        longitude = xr.DataArray(lons, dims=["station"])
2280
2281        # create new coords
2282        new_coords = dict(da.coords)
2283        del new_coords["latitude"]
2284        del new_coords["longitude"]
2285        new_coords["longitude"] = longitude
2286        new_coords["latitude"] = latitude
2287        new_coords["station"] = calls
2288
2289        new_dims = da.dims[:-2] + ("station",)
2290
2291        # make grid def in from section3 on da attrs
2292        grid_def_in = self.griddef()
2293
2294        if da.chunks is None:
2295            data = interp_nd_stations(
2296                da.data,
2297                method=method,
2298                grid_def_in=grid_def_in,
2299                lats=lats,
2300                lons=lons,
2301                method_options=method_options,
2302                num_threads=num_threads,
2303            )
2304        else:
2305            data = da.data.map_blocks(
2306                interp_nd_stations,
2307                method=method,
2308                grid_def_in=grid_def_in,
2309                lats=lats,
2310                lons=lons,
2311                method_options=method_options,
2312                drop_axis=-1,
2313                chunks=da.chunks[:-2] + latitude.shape,
2314                dtype=da.dtype,
2315            )
2316
2317        new_da = xr.DataArray(data, dims=new_dims, coords=new_coords, attrs=da.attrs)
2318
2319        new_da.name = da.name
2320
2321        # Update history for provenance
2322        history = new_da.attrs.get("history", "")
2323        now = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
2324        new_da.attrs["history"] = f"{now}: Interpolated to {len(calls)} stations via {method}\n{history}"
2325
2326        return new_da
2327
2328    def to_grib2(self, filename, mode: typing.Literal["x", "w", "a"] = "x"):
2329        """
2330        Write a DataArray to a grib2 file.
2331
2332        Parameters
2333        ----------
2334        filename
2335            Name of the grib2 file to write to.
2336        mode: {"x", "w", "a"}, optional, default="x"
2337            Persistence mode
2338
2339            +------+-----------------------------------+
2340            | mode | Description                       |
2341            +======+===================================+
2342            | x    | create (fail if exists)           |
2343            +------+-----------------------------------+
2344            | w    | create (overwrite if exists)      |
2345            +------+-----------------------------------+
2346            | a    | append (create if does not exist) |
2347            +------+-----------------------------------+
2348
2349        """
2350        da = self._obj.copy(deep=True)
2351
2352        coords_keys = sorted(da.coords.keys())
2353        coords_keys = [k for k in coords_keys if k in AVAILABLE_NON_GEO_COORDS]
2354
2355        # If there are dimension coordinates, the DataArray is a hypercube of
2356        # grib2 messages.
2357
2358        # Create `indexes` which is a list of lists of dictionaries for all
2359        # dimension coordinates. Each dictionary key is the dimension
2360        # coordinate name and the value is a list of the dimension coordinate
2361        # values.  This allows for easy iteration over all possible grib2
2362        # messages in the DataArray by using itertools.product.
2363        #
2364        # For example:
2365        # indexes = [
2366        #     [
2367        #         {"leadTime": 9},
2368        #         {"leadTime": 12},
2369        #     ],
2370        #     [
2371        #         {"valueOfFirstFixedSurface": 900},
2372        #         {"valueOfFirstFixedSurface": 925},
2373        #         {"valueOfFirstFixedSurface": 950},
2374        #     ],
2375        # ]
2376
2377        # assign loc indexes to dimensions without indexes for uniform selection by name
2378        loc_indexes = list()
2379        for dim in da.dims:
2380            if dim not in da.indexes:
2381                da = da.assign_coords({dim: range(da[dim].size)})
2382                loc_indexes.append(dim)
2383
2384        indexes = []
2385        for index in [i for i in AVAILABLE_NON_GEO_DIMS if i in da.dims]:
2386            values = da.coords[index].values
2387            if len(values) != len(set(values)):
2388                raise ValueError(
2389                    f"Dimension coordinate '{index}' has duplicate values, but to_grib2 requires unique values to find each GRIB2 message in the DataArray."
2390                )
2391            listeach = [{index: value} for value in sorted(values)]
2392            indexes.append(listeach)
2393
2394        # If `dim_coords` is [], then the DataArray is a single grib2 message and
2395        # itertools.product(*dim_coords) will run once with `selectors = ()`.
2396        for selectors in itertools.product(*indexes):
2397            # Need to find the correct data in the DataArray based on the
2398            # dimension coordinates.
2399            filters = {k: v for d in selectors for k, v in d.items()}
2400
2401            # If `filters` is {}, then the DataArray is a single grib2 message
2402            # and da.sel(indexers={}) returns the DataArray.
2403            selected = da.sel(indexers=filters)
2404
2405            newmsg = Grib2Message(
2406                selected.attrs["GRIB2IO_section0"],
2407                selected.attrs["GRIB2IO_section1"],
2408                selected.attrs["GRIB2IO_section2"],
2409                selected.attrs["GRIB2IO_section3"],
2410                selected.attrs["GRIB2IO_section4"],
2411                selected.attrs["GRIB2IO_section5"],
2412            )
2413            newmsg.data = np.array(selected.data)
2414
2415            # For dimension coordinates, set the grib2 message metadata to the
2416            # dimension coordinate value.
2417            for index, value in filters.items():
2418                if index not in loc_indexes:
2419                    setattr(newmsg, index, value)
2420
2421            # For non-dimension coordinates, set the grib2 message metadata to
2422            # the DataArray coordinate value.
2423            for index in [i for i in coords_keys if i not in da.dims]:
2424                setattr(newmsg, index, selected.coords[index].values)
2425
2426            # Set section 5 attributes to the da.encoding dictionary.
2427            for key, value in selected.encoding.items():
2428                if key in ["dtype", "chunks", "original_shape"]:
2429                    continue
2430                setattr(newmsg, key, value)
2431
2432            # write the message to file
2433            with grib2io.open(filename, mode=mode) as f:
2434                f.write(newmsg)
2435            mode = "a"
2436
2437    def update_attrs(self, **kwargs):
2438        """
2439        Update many of the attributes of the DataArray.
2440
2441        Parameters
2442        ----------
2443        **kwargs
2444            Attributes to update.  This can include many of the GRIB2IO message
2445            attributes that you can find when you print a GRIB2IO message. For
2446            conflicting updates, the last keyword will be used.
2447
2448            +-----------------------+------------------------------------------+
2449            | kwargs                | Description                              |
2450            +=======================+==========================================+
2451            | shortName="VTMP"      | Set shortName to "VTMP", along with      |
2452            |                       | appropriate discipline,                  |
2453            |                       | parameterCategory, parameterNumber,      |
2454            |                       | fullName and units.                      |
2455            +-----------------------+------------------------------------------+
2456            | discipline=0,         | Set shortName, discipline,               |
2457            | parameterCategory=0,  | parameterCategory, parameterNumber,      |
2458            | parameterNumber=1     | fullName and units appropriate for       |
2459            |                       | "Virtual Temperature".                   |
2460            +-----------------------+------------------------------------------+
2461            | discipline=0,         | Conflicting keywords but                 |
2462            | parameterCategory=0,  | 'shortName="TMP"' wins.  Set shortName,  |
2463            | parameterNumber=1,    | discipline, parameterCategory,           |
2464            | shortName="TMP"       | parameterNumber, fullName and units      |
2465            |                       | appropriate for "Temperature".           |
2466            +-----------------------+------------------------------------------+
2467
2468        Returns
2469        -------
2470        DataArray
2471            DataArray with updated attributes.
2472        """
2473        da = self._obj.copy(deep=True)
2474
2475        newmsg = Grib2Message(
2476            da.attrs["GRIB2IO_section0"],
2477            da.attrs["GRIB2IO_section1"],
2478            da.attrs["GRIB2IO_section2"],
2479            da.attrs["GRIB2IO_section3"],
2480            da.attrs["GRIB2IO_section4"],
2481            da.attrs["GRIB2IO_section5"],
2482        )
2483
2484        coords_keys = [k for k in da.coords.keys() if k in AVAILABLE_NON_GEO_COORDS]
2485
2486        for grib2_name, value in kwargs.items():
2487            if grib2_name == "gridDefinitionTemplateNumber":
2488                raise ValueError(
2489                    "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."
2490                )
2491            if grib2_name == "productDefinitionTemplateNumber":
2492                raise ValueError("The productDefinitionTemplateNumber attribute cannot be updated.")
2493            if grib2_name == "dataRepresentationTemplateNumber":
2494                raise ValueError("The dataRepresentationTemplateNumber attribute cannot be updated.")
2495            if grib2_name in coords_keys:
2496                warnings.warn(f"Skipping attribute '{grib2_name}' because it is a coordinate. Use da.assign_coords() to change coordinate values.")
2497                continue
2498            if hasattr(newmsg, grib2_name):
2499                setattr(newmsg, grib2_name, value)
2500            else:
2501                warnings.warn(f"Skipping attribute '{grib2_name}' because it is not a valid GRIB2 attribute for this message and cannot be updated.")
2502                continue
2503
2504        da.attrs["GRIB2IO_section0"] = newmsg.section0
2505        da.attrs["GRIB2IO_section1"] = newmsg.section1
2506        da.attrs["GRIB2IO_section2"] = newmsg.section2 or []
2507        da.attrs["GRIB2IO_section3"] = newmsg.section3
2508        da.attrs["GRIB2IO_section4"] = newmsg.section4
2509        da.attrs["GRIB2IO_section5"] = newmsg.section5
2510        da.attrs["fullName"] = newmsg.fullName
2511        da.attrs["shortName"] = newmsg.shortName
2512        da.attrs["units"] = newmsg.units
2513
2514        return da
2515
2516    def update_section3(self) -> xr.DataArray:
2517        """
2518        Update section3 attributes based on the latitude and longitude corners.
2519
2520        This makes the GRIB2IO_section3 attribute consistent with the grid's
2521        new corners after a change in the spatial extent.
2522        """
2523        da = self._obj
2524        if "GRIB2IO_section3" not in da.attrs:
2525            raise ValueError(
2526                "DataArray has no attr 'GRIB2IO_section3'.  This function only works with Datasets/DataArrrays opened with the 'grib2io' backend."
2527            )
2528        if "latitude" not in da.coords:
2529            raise ValueError("DataArray has no coord 'latitude'")
2530        if "longitude" not in da.coords:
2531            raise ValueError("DataArray has no coord 'longitude'")
2532
2533        grid = Grid(da.attrs["GRIB2IO_section3"])
2534
2535        if grid.gdtn not in [0, 1, 10, 20, 30, 31, 40, 110]:
2536            raise ValueError(
2537                textwrap.dedent("""\
2538                    update_section3 only works for:
2539
2540                    Latitude/Longitude, Equidistant Cylindrical, or Plate Carree (gdtn=0)
2541                    Rotated Latitude/Longitude (gdtn=1)
2542                    Mercator (gdtn=10)
2543                    Polar Stereographic (gdtn=20)
2544                    Lambert Conformal (gdtn=30)
2545                    Albers Equal-Area (gdtn=31)
2546                    Gaussian Latitude/Longitude (gdtn=40)
2547                    Equatorial Azimuthal Equidistant Projection (gdtn=110)
2548                    """)
2549            )
2550
2551        grid.latitudeFirstGridpoint = da.latitude.isel(y=0, x=0)
2552        grid.longitudeFirstGridpoint = da.longitude.isel(y=0, x=0)
2553        grid.nx = len(da.x)
2554        grid.ny = len(da.y)
2555
2556        # last gridpoint does not affect section3 for some gdt but set anyway
2557        grid.latitudeLastGridpoint = da.latitude.isel(y=-1, x=-1)
2558        grid.longitudeLastGridpoint = da.longitude.isel(y=-1, x=-1)
2559
2560        da.attrs["GRIB2IO_section3"] = grid.section3
2561
2562        return da
2563
2564    def subset(self, *, lats=None, lons=None) -> xr.DataArray:
2565        """
2566        Subset the DataArray to a box defined by latitudes and/or longitudes.
2567
2568        Parameters
2569        ----------
2570        lats
2571            Two item list or tuple of latitudes.  Default is None which will
2572            return a subset unbounded by latitude.  The first term defines the
2573            southern boundary and the second term defines the northern
2574            boundary.
2575        lons
2576            Two item list or tuple of longitudes.  Default is None which will
2577            return a subset unbounded by longitude.  The first term defines the
2578            western boundary and the second term defines the eastern
2579            boundary.  Can follow either: 0 to 360 postive eastward, or 0 to
2580            -180 westward / 0 to 180 eastward conventions.  The longitude
2581            boundaries cannot cross 0.
2582
2583        Returns
2584        -------
2585        subset
2586            DataArray subset to the bounding box created by input 'lats'/'lons'.
2587            All gridpoints with lat/lon matching contraints are included within
2588            subset.
2589        """
2590
2591        def slice_from_contiguous_mask(mask):
2592            indices = np.where(mask)[0]
2593
2594            if indices.size > 0:
2595                # slice(start, stop) - stop is exclusive, so we add 1
2596                my_slice = slice(indices[0], indices[-1] + 1)
2597            else:
2598                my_slice = slice(0, 0)
2599            return my_slice
2600
2601        da = self._obj.copy()
2602
2603        if lats is None:
2604            lats = (np.min(da.latitude), np.max(da.latitude))
2605        else:
2606            lats = (min(lats), max(lats))
2607
2608        if lons is None:
2609            lons = (np.min(da.longitude), np.max(da.longitude))
2610        else:
2611            lons = (min(lons), max(lons))
2612
2613        # Internally work in common lon data representation (0->360 positive eastward from 0)
2614        lons = np.mod(np.array(lons) + 360, 360)
2615        lon_da = np.mod(da.longitude + 360, 360)
2616
2617        snap_first_point = snap_to_nearest_cell_center(da.latitude, lon_da, lats[0], lons[0])
2618        snap_last_point = snap_to_nearest_cell_center(da.latitude, lon_da, lats[1], lons[1])
2619        lats = (snap_first_point[0], snap_last_point[0])
2620        lons = (snap_first_point[1], snap_last_point[1])
2621
2622        x = ((lon_da >= lons[0]) & (lon_da <= lons[1])).any("y")
2623        if x.chunks:
2624            x = x.compute()
2625
2626        y = ((da.latitude >= lats[0]) & (da.latitude <= lats[1])).any("x")
2627        if y.chunks:
2628            y = y.compute()
2629
2630        y_slice = slice_from_contiguous_mask(y)
2631        x_slice = slice_from_contiguous_mask(x)
2632
2633        da = da.isel(y=y_slice, x=x_slice)
2634        if da.size < 1:
2635            raise ValueError("None of grid data is within given lat/lon bounds.")
2636
2637        da = da.grib2io.update_section3()
2638
2639        return da
2640
2641    def compute(self, **kwargs):
2642        """
2643        Compute the Dask-backed DataArray with retries for transient errors.
2644
2645        Wraps :func:`grib2io.utils.compute_with_retries`.
2646
2647        Parameters
2648        ----------
2649        **kwargs
2650            Arguments passed to :func:`grib2io.utils.compute_with_retries`,
2651            e.g., `max_attempts` or `base_sleep`.
2652
2653        Returns
2654        -------
2655        xarray.DataArray
2656            The computed DataArray with NumPy-backed data.
2657        """
2658        from .utils import compute_with_retries
2659
2660        return compute_with_retries(self._obj, **kwargs)
2661
2662
2663def open_mfdataset(
2664    filenames: typing.Union[str, typing.Sequence[str]],
2665    *,
2666    drop_variables: typing.Optional[typing.List[str]] = None,
2667    save_index: bool = True,
2668    filters: typing.Mapping[str, typing.Any] = dict(),
2669    data_model: typing.Optional[str] = None,
2670    parallel: bool = False,
2671    preprocess: typing.Optional[typing.Callable] = None,
2672    chunks: typing.Optional[typing.Union[int, typing.Dict[typing.Any, typing.Any], typing.Literal["auto"]]] = None,
2673    **kwargs,
2674) -> xr.Dataset:
2675    """
2676    Open multiple GRIB2 files as a single xarray Dataset.
2677
2678    This function is optimized for GRIB2 files by combining their indices
2679    and creating a single Dataset, which is often much faster than
2680    using ``xarray.open_mfdataset``. It supports parallel index reading
2681    and dataset opening when ``parallel=True`` and ``dask`` is installed.
2682
2683    Parameters
2684    ----------
2685    filenames : str or sequence of str
2686        GRIB2 files to be opened. Can be a glob pattern.
2687    drop_variables : list of str, optional
2688        List of variables to exclude from the dataset.
2689    save_index : bool, optional
2690        Whether to save the GRIB2 index to a file (default is True).
2691    filters : dict, optional
2692        Filter GRIB2 messages to a single hypercube. Dictionary keys can be
2693        any GRIB2 metadata attribute name.
2694    data_model : str, optional
2695        Parse GRIB metadata following a defined data model convention
2696        (e.g., "nws-viz").
2697    parallel : bool, optional
2698        If True, use ``dask`` to read indices and open datasets in parallel.
2699        Requires the ``dask`` package.
2700    preprocess : callable, optional
2701        A function to apply to each file's dataset before combining.
2702    chunks : int, dict or 'auto', optional
2703        If chunks is provided, it is used to load the dataset into a
2704        dask-backed dataset.
2705    **kwargs : optional
2706        Additional arguments passed to the combination logic.
2707        If ``combine='nested'``, passed to ``xarray.combine_nested``.
2708        If ``combine='by_coords'``, passed to ``xarray.combine_by_coords``.
2709        If ``combine='merge'``, passed to ``xarray.merge``.
2710        If no ``combine`` argument is provided, the function attempts
2711        ``xarray.combine_by_coords`` followed by ``xarray.merge``.
2712
2713    Returns
2714    -------
2715    xarray.Dataset
2716        Xarray dataset of grib2 messages.
2717
2718    Notes
2719    -----
2720    - This function uses a "fast path" when ``preprocess=None`` and no
2721      combination ``**kwargs`` are provided, which concatenates all indices
2722      into a single global index before building the Dataset.
2723    - All files must share the same horizontal grid (ny, nx).
2724    """
2725    if isinstance(filenames, str):
2726        import glob
2727
2728        filenames = sorted(glob.glob(filenames))
2729
2730    storage_options = kwargs.pop("storage_options", None)
2731
2732    def _get_index(fname_and_index: typing.Tuple[str, int]) -> pd.DataFrame:
2733        """
2734        Internal utility to read GRIB2 index from a file.
2735
2736        Parameters
2737        ----------
2738        fname_and_index : tuple of (str, int)
2739            Tuple containing the filename and its position in the file list.
2740
2741        Returns
2742        -------
2743        pandas.DataFrame
2744            The GRIB2 index for the specified file.
2745        """
2746        fname, i = fname_and_index
2747        with grib2io.open(
2748            fname,
2749            save_index=save_index,
2750            _xarray_backend=True,
2751            **(storage_options or {}),
2752        ) as f:
2753            idx = pd.DataFrame(f._index)
2754            idx = idx.assign(msg=list(f))
2755            idx["file_index"] = i
2756            return idx
2757
2758    if parallel:
2759        try:
2760            import dask
2761            from dask.bag import from_sequence
2762
2763            indices = from_sequence(zip(filenames, range(len(filenames)))).map(_get_index).compute()
2764        except ImportError:
2765            warnings.warn("dask not installed, falling back to sequential index reading.")
2766            parallel = False
2767            indices = [_get_index((fname, i)) for i, fname in enumerate(filenames)]
2768    else:
2769        indices = [_get_index((fname, i)) for i, fname in enumerate(filenames)]
2770
2771    if not indices:
2772        return xr.Dataset()
2773
2774    # Validate grid consistency across files using only the first message of each file
2775    grid_cols = ["ny", "nx"]
2776    first_msgs = pd.concat([idx.head(1) for idx in indices], ignore_index=True)
2777    unique_grids = first_msgs[grid_cols].drop_duplicates()
2778    if len(unique_grids) > 1:
2779        grid_list = unique_grids.to_dict("records")
2780        raise ValueError(f"Multiple grids detected in open_mfdataset. All files must have the same grid. Found grids: {grid_list}")
2781
2782    # Determine if we can use the fast path (single index concatenation)
2783    # The fast path is only available if no preprocess is provided and no combination kwargs are used
2784    # that would require individual datasets (like concat_dim for nested combination)
2785    use_fast_path = preprocess is None and not kwargs
2786
2787    if not use_fast_path:
2788        if parallel:
2789            import dask
2790
2791            @dask.delayed
2792            def _open_delayed(idx, fname):
2793                return _open_dataset_from_index(
2794                    idx,
2795                    fname,
2796                    filters,
2797                    data_model,
2798                    drop_variables=drop_variables,
2799                    chunks=chunks,
2800                )
2801
2802            datasets = dask.compute(*[_open_delayed(idx, fname) for idx, fname in zip(indices, filenames)])
2803        else:
2804            datasets = [
2805                _open_dataset_from_index(
2806                    idx,
2807                    fname,
2808                    filters,
2809                    data_model,
2810                    drop_variables=drop_variables,
2811                    chunks=chunks,
2812                )
2813                for idx, fname in zip(indices, filenames)
2814            ]
2815
2816        if preprocess is not None:
2817            datasets = [preprocess(ds) for ds in datasets]
2818
2819        combine_opt = kwargs.pop("combine", None)
2820        if combine_opt == "nested":
2821            ds = xr.combine_nested(datasets, **kwargs)
2822        elif combine_opt == "by_coords":
2823            ds = xr.combine_by_coords(datasets, **kwargs)
2824        elif combine_opt == "merge":
2825            ds = xr.merge(datasets, **kwargs)
2826        else:
2827            # Default behavior: try by_coords, then merge
2828            try:
2829                ds = xr.combine_by_coords(datasets, **kwargs)
2830            except Exception:
2831                ds = xr.merge(datasets, **kwargs)
2832    else:
2833        file_index = pd.concat(indices, ignore_index=True)
2834        ds = _open_dataset_from_index(
2835            file_index,
2836            list(filenames),
2837            filters,
2838            data_model,
2839            drop_variables=drop_variables,
2840            chunks=chunks,
2841        )
2842
2843    # Update history for provenance
2844    history = ds.attrs.get("history", "")
2845    now = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
2846    ds.attrs["history"] = f"{now}: Initialized via grib2io.open_mfdataset from {len(filenames)} files\n{history}"
2847
2848    return ds
2849
2850
2851def _open_dataset_from_index(
2852    file_index: pd.DataFrame,
2853    filenames: typing.Union[str, typing.List[str]],
2854    filters: typing.Mapping[str, typing.Any] = dict(),
2855    data_model: typing.Optional[str] = None,
2856    drop_variables: typing.Optional[typing.List[str]] = None,
2857    chunks: typing.Optional[typing.Union[int, typing.Dict[typing.Any, typing.Any], typing.Literal["auto"]]] = None,
2858) -> xr.Dataset:
2859    """
2860    Create an xarray Dataset from a GRIB2 index DataFrame.
2861
2862    This is an internal utility used by ``open_dataset`` and ``open_mfdataset``
2863    to build a Dataset structure from a pre-computed index of GRIB2 messages.
2864
2865    Parameters
2866    ----------
2867    file_index : pandas.DataFrame
2868        GRIB2 index DataFrame, expected to contain GRIB2 metadata and
2869        message pointers.
2870    filenames : str or list of str
2871        Path(s) to the GRIB2 file(s) referenced by the index.
2872    filters : dict, optional
2873        Filter GRIB2 messages to a single hypercube.
2874    data_model : str, optional
2875        Target data model for metadata normalization (e.g., "nws-viz").
2876    drop_variables : list of str, optional
2877        List of shortnames to exclude from the resulting Dataset.
2878    chunks : int, dict or 'auto', optional
2879        If chunks is provided, it is used to load the dataset into a
2880        dask-backed dataset.
2881
2882    Returns
2883    -------
2884    xarray.Dataset
2885        Dataset representing the GRIB2 messages.
2886    """
2887    # parse grib2io _index to dataframe and acquire non-geo possible dims
2888    # (scalar coord when not dim due to squeeze) parse_grib_index applies
2889    # filters to index and expands metadata based on product definition
2890    # template number
2891    file_index, dim_coords, attrs, coord_attrs = parse_grib_index(file_index, filters)
2892
2893    if drop_variables:
2894        file_index = file_index[~file_index["shortName"].isin(drop_variables)]
2895
2896    # Divide up records by variable
2897    frames, cubes, extra_geo = make_variables(file_index, filenames, dim_coords)  # have this return var_attrs
2898
2899    # return empty dataset if no data
2900    if frames is None:
2901        return xr.Dataset()
2902
2903    # create dataframe and add datarrays without any coords
2904    ds = xr.Dataset()
2905    for var_df, var_cube in zip(frames, cubes):
2906        da = build_da_without_coords(var_df, var_cube, filenames, attrs)
2907
2908        # Assign variable-specific coords from its cube
2909        coords = coords_from_cube(var_cube)
2910        da = da.assign_coords(coords)
2911
2912        # Assign extra index associated coords for this variable
2913        for dim_name, coord_names in dim_coords.items():
2914            retain_index_coord = False
2915            for name in coord_names:
2916                if name == dim_name:
2917                    retain_index_coord = True
2918                else:
2919                    if dim_name not in da.dims:
2920                        # for assigning scalar coords
2921                        coord_data = var_df[name].unique().item()
2922                        da = da.assign_coords({name: coord_data})
2923                    else:
2924                        # "ValueError: can only convert an array of size 1 to a Python scalar" indicates the coord is not compatible with the index
2925                        coord_data = [
2926                            var_df[var_df.index.get_level_values(f"{dim_name}_ix") == val][name].unique().item() for val in range(da[dim_name].size)
2927                        ]
2928                        coord = pd.Index(coord_data, name=dim_name)
2929                        da = da.assign_coords({name: (dim_name, coord)})
2930            if not retain_index_coord and dim_name in da.coords:
2931                da = da.drop_vars(dim_name)
2932
2933        ds[da.name] = da
2934
2935    # add coords and dataset meta
2936    # Pass first cube for common geo coords assignment
2937    ds = assign_xr_meta(ds, frames, cubes[0], dim_coords, extra_geo, coord_attrs)
2938
2939    if data_model is not None:
2940        ds = parse_data_model(ds, data_model)
2941
2942    # assign attributes
2943    ds.attrs["engine"] = "grib2io"
2944
2945    if chunks is not None:
2946        ds = ds.chunk(chunks)
2947
2948    return ds
2949
2950
2951def build_datatree_from_grib(
2952    filename: str,
2953    file_index: pd.DataFrame,
2954    filters: typing.Optional[typing.Mapping[str, typing.Any]] = None,
2955    stack_vertical: bool = False,
2956    drop_variables: typing.Optional[typing.List[str]] = None,
2957    chunks: typing.Optional[typing.Union[int, typing.Dict[typing.Any, typing.Any], typing.Literal["auto"]]] = None,
2958) -> typing.Any:
2959    """
2960    Build a DataTree from GRIB2 messages.
2961
2962    This internal function organizes GRIB2 messages into a hierarchical
2963    tree structure based on level types, PDTNs, and other metadata.
2964
2965    Parameters
2966    ----------
2967    filename : str
2968        Path to the source GRIB2 file.
2969    file_index : pandas.DataFrame
2970        Index of GRIB2 messages.
2971    filters : dict, optional
2972        Filter criteria for selecting messages.
2973    stack_vertical : bool, optional
2974        If True, vertical levels will be stacked in a single dataset
2975        within each node, rather than creating separate nodes per level value.
2976    drop_variables : list of str, optional
2977        List of variable shortnames to exclude.
2978    chunks : int, dict or 'auto', optional
2979        If chunks is provided, it is used to load the dataset into a
2980        dask-backed dataset.
2981
2982    Returns
2983    -------
2984    xarray.DataTree
2985        A hierarchical tree representation of the GRIB2 data.
2986    """
2987    if filters is None:
2988        filters = {}
2989
2990    # Apply any filters from user
2991    for k, v in filters.items():
2992        if k not in file_index.columns:
2993            file_index = file_index.copy()
2994            file_index[k] = file_index.msg.apply(lambda msg: getattr(msg, k, None))
2995        file_index = filter_index(file_index, k, v)
2996
2997    # Make a copy to avoid the SettingWithCopyWarning
2998    file_index = file_index.copy()
2999
3000    # Extract metadata needed for tree organization
3001    # Use a safer approach to handle missing attributes
3002    def safe_getattr(obj, name):
3003        try:
3004            attr = getattr(obj, name)
3005            # Need to test if the attribute is Grib2Metadata. If so,
3006            # then get the value attribute.
3007            if isinstance(attr, grib2io.templates.Grib2Metadata):
3008                attr = attr.value
3009            return attr
3010        except (AttributeError, KeyError):
3011            return None
3012
3013    for attr in _TREE_HIERARCHY_LEVELS:
3014        if (attr not in file_index.columns) and (attr != "valueOfFirstFixedSurface"):
3015            file_index[attr] = file_index.msg.apply(lambda msg: safe_getattr(msg, attr))
3016
3017    # Also extract shortName for variable naming
3018    if "shortName" not in file_index.columns:
3019        file_index = file_index.assign(shortName=file_index.msg.apply(lambda msg: getattr(msg, "shortName", None)))
3020
3021    if drop_variables:
3022        file_index = file_index[~file_index["shortName"].isin(drop_variables)]
3023
3024    file_index = file_index.assign(nx=file_index.msg.apply(lambda msg: getattr(msg, "nx", None)))
3025    file_index = file_index.assign(ny=file_index.msg.apply(lambda msg: getattr(msg, "ny", None)))
3026
3027    # Create root DataTree
3028    root = xr.DataTree()
3029
3030    # Adjust hierarchy levels if we're stacking vertical levels
3031    hierarchy_levels = list(_TREE_HIERARCHY_LEVELS)  # This makes a copy
3032    if stack_vertical and "valueOfFirstFixedSurface" in hierarchy_levels:
3033        hierarchy_levels.remove("valueOfFirstFixedSurface")
3034
3035    # First group by level type
3036    level_groups = {}
3037
3038    # Create a dictionary to group data by level type
3039    for level_type in file_index["typeOfFirstFixedSurface"].unique():
3040        if pd.notna(level_type):  # Skip None/NaN values
3041            level_info = _LEVEL_NAME_MAPPING.get(level_type, f"level_{level_type}")
3042            level_name = level_info[0]
3043            # Get all rows for this level type
3044            level_data = file_index[file_index["typeOfFirstFixedSurface"] == level_type]
3045            level_groups[level_type] = {"name": level_name, "data": level_data}
3046
3047    # Process each level group
3048    for level_type, group_info in level_groups.items():
3049        level_name = group_info["name"]
3050        level_df = group_info["data"]
3051
3052        # Create a branch for this level type
3053        level_tree = xr.DataTree()
3054
3055        # Process this branch based on PDTN, perturbation number, etc.
3056        process_level_branch(level_tree, level_df, filename, chunks=chunks)
3057
3058        # Add this branch to the main tree
3059        root[level_name] = level_tree
3060
3061    return root
3062
3063
3064def process_level_branch(
3065    level_tree: typing.Any,
3066    df: pd.DataFrame,
3067    filename: str,
3068    chunks: typing.Optional[typing.Union[int, typing.Dict[typing.Any, typing.Any], typing.Literal["auto"]]] = None,
3069):
3070    """
3071    Process a level type branch of the data tree.
3072
3073    Organizes the tree by PDTN and other attributes.
3074
3075    Parameters
3076    ----------
3077    level_tree : xarray.DataTree
3078        The DataTree node for this level type.
3079    df : pandas.DataFrame
3080        DataFrame of messages for this level type.
3081    filename : str
3082        Path to the GRIB2 file.
3083    chunks : int, dict or 'auto', optional
3084        If chunks is provided, it is used to load the dataset into a
3085        dask-backed dataset.
3086    """
3087    # Group by PDTN
3088    pdtn_groups = {}
3089
3090    # Group data by PDTN first
3091    for pdtn_value in df["productDefinitionTemplateNumber"].unique():
3092        if pd.notna(pdtn_value):
3093            pdtn_df = df[df["productDefinitionTemplateNumber"] == pdtn_value]
3094            pdtn_groups[pdtn_value] = pdtn_df
3095
3096    # If there's only one PDTN value, skip creating PDTN branch level
3097    if len(pdtn_groups) == 1:
3098        pdtn, pdtn_df = next(iter(pdtn_groups.items()))
3099
3100        pdtn_name = f"pdtn_{int(pdtn)}"
3101
3102        # Check if we need to further subdivide by perturbation number
3103        has_perturbations = "perturbationNumber" in pdtn_df.columns and len(pdtn_df["perturbationNumber"].dropna().unique()) > 1
3104
3105        # Check if we need to further subdivide by probabilities unique for each variable.
3106        has_probabilities = "typeOfProbability" in pdtn_df.columns and len(pdtn_df["typeOfProbability"].dropna().unique()) > 1
3107
3108        if has_perturbations:
3109            # Process perturbations directly on the level tree
3110            process_perturbation_groups(level_tree, pdtn_df, filename, chunks=chunks)
3111        elif has_probabilities:
3112            # Process probability groups
3113            process_probability_groups(level_tree, pdtn_df, filename, chunks=chunks)
3114        else:
3115            # Try to create dataset directly on level
3116            try:
3117                dss = create_datasets_from_df(pdtn_df, filename, chunks=chunks)
3118                if dss is not None:
3119                    dt = xr.DataTree()
3120                    if len(dss) == 1:
3121                        dt.ds = dss[0]
3122                    else:
3123                        for ds in dss:
3124                            varname = list(ds.data_vars)[0]
3125                            dt[f"var_{varname}"] = ds
3126                    level_tree[pdtn_name] = dt
3127                else:
3128                    # Try to separate by variable name as a fallback
3129                    try_process_by_variables(level_tree, pdtn_df, filename, chunks=chunks)
3130            except Exception as e:
3131                print(f"Error creating dataset for level with pdtn {int(pdtn)}: {e}")
3132
3133                # Try to separate by variable name as a fallback
3134                try_process_by_variables(level_tree, pdtn_df, filename, chunks=chunks)
3135    else:
3136        # Multiple PDTN values, process each group with PDTN branch nodes
3137        for pdtn, pdtn_df in pdtn_groups.items():
3138            # Use a simple node name that's easy to use in code
3139            pdtn_name = f"pdtn_{int(pdtn)}"
3140
3141            # Check if we need to further subdivide by perturbation number
3142            has_perturbations = "perturbationNumber" in pdtn_df.columns and len(pdtn_df["perturbationNumber"].dropna().unique()) > 1
3143
3144            # Check if we need to further subdivide by probabilities unique for each variable.
3145            has_probabilities = "typeOfProbability" in pdtn_df.columns and len(pdtn_df["typeOfProbability"].dropna().unique()) > 1
3146
3147            if has_perturbations:
3148                # Create a branch for this PDTN
3149                pdtn_tree = xr.DataTree()
3150
3151                # Process perturbation groups
3152                process_perturbation_groups(pdtn_tree, pdtn_df, filename, chunks=chunks)
3153
3154                # Only add the PDTN branch if it has children
3155                if len(pdtn_tree.children) > 0 or pdtn_tree.ds is not None:
3156                    level_tree[pdtn_name] = pdtn_tree
3157            elif has_probabilities:
3158                # Create a branch for this PDTN
3159                pdtn_tree = xr.DataTree()
3160
3161                # Process probability groups
3162                process_probability_groups(pdtn_tree, pdtn_df, filename, chunks=chunks)
3163
3164                # Only add the PDTN branch if it has children
3165                if len(pdtn_tree.children) > 0 or pdtn_tree.ds is not None:
3166                    level_tree[pdtn_name] = pdtn_tree
3167            else:
3168                # Create a subtree for this PDTN
3169                pdtn_tree = xr.DataTree()
3170
3171                # Try to create dataset directly on level
3172                try:
3173                    dss = create_datasets_from_df(pdtn_df, filename, chunks=chunks)
3174                    if dss is not None:
3175                        if len(dss) == 1:
3176                            pdtn_tree.ds = dss[0]
3177                        else:
3178                            for ds in dss:
3179                                varname = list(ds.data_vars)[0]
3180                                pdtn_tree[f"var_{varname}"] = ds
3181                        level_tree[pdtn_name] = pdtn_tree
3182                    else:
3183                        # Try to separate by variable name as a fallback
3184                        try_process_by_variables(pdtn_tree, pdtn_df, filename, chunks=chunks)
3185                        level_tree[pdtn_name] = pdtn_tree
3186                except Exception as e:
3187                    print(f"Error creating dataset for level with pdtn {int(pdtn)}: {e}")
3188
3189                    # Try to separate by variable name as a fallback
3190                    try_process_by_variables(pdtn_tree, pdtn_df, filename, chunks=chunks)
3191                    level_tree[pdtn_name] = pdtn_tree
3192
3193
3194def process_probability_groups(
3195    target_tree: typing.Any,
3196    pdtn_df: pd.DataFrame,
3197    filename: str,
3198    chunks: typing.Optional[typing.Union[int, typing.Dict[typing.Any, typing.Any], typing.Literal["auto"]]] = None,
3199) -> bool:
3200    """
3201    Process probability groups and add them to the target tree.
3202
3203    Parameters
3204    ----------
3205    target_tree : xarray.DataTree
3206        The tree node to add probability groups to.
3207    pdtn_df : pandas.DataFrame
3208        DataFrame of messages for a specific PDTN.
3209    filename : str
3210        Path to the GRIB2 file.
3211    chunks : int, dict or 'auto', optional
3212        If chunks is provided, it is used to load the dataset into a
3213        dask-backed dataset.
3214
3215    Returns
3216    -------
3217    bool
3218        True if successful.
3219    """
3220    success = False
3221    # Group by type of probability
3222    prob_groups = {}
3223    for prob_value in pdtn_df["typeOfProbability"].unique():
3224        if pd.notna(prob_value):
3225            prob_df = pdtn_df[pdtn_df["typeOfProbability"] == prob_value]
3226            prob_groups[prob_value] = prob_df
3227
3228    # Process each probability group
3229    for prob_num, prob_df in prob_groups.items():
3230        prob_name = f"prob_{int(prob_num)}"
3231
3232        # Try to create dataset for this probability group
3233        try:
3234            dss = create_datasets_from_df(prob_df, filename, chunks=chunks)
3235            dt = xr.DataTree()
3236            if len(dss) == 1:
3237                dt.ds = dss[0]
3238                target_tree[prob_name] = dt
3239            elif len(dss) > 1:
3240                for ds in dss:
3241                    dt[f"var_{ds.data_vars[0]}"] = ds
3242            target_tree[prob_name] = dt
3243        except Exception as e:
3244            # Log error but continue processing other groups
3245            print(f"Error creating dataset for type of probability {prob_name}: {e}")
3246
3247    return success
3248
3249
3250def process_perturbation_groups(
3251    target_tree: typing.Any,
3252    pdtn_df: pd.DataFrame,
3253    filename: str,
3254    chunks: typing.Optional[typing.Union[int, typing.Dict[typing.Any, typing.Any], typing.Literal["auto"]]] = None,
3255) -> bool:
3256    """
3257    Process perturbation groups and add them to the target tree.
3258
3259    Parameters
3260    ----------
3261    target_tree : xarray.DataTree
3262        The tree node to add perturbation groups to.
3263    pdtn_df : pandas.DataFrame
3264        DataFrame of messages for a specific PDTN.
3265    filename : str
3266        Path to the GRIB2 file.
3267    chunks : int, dict or 'auto', optional
3268        If chunks is provided, it is used to load the dataset into a
3269        dask-backed dataset.
3270
3271    Returns
3272    -------
3273    bool
3274        True if at least one perturbation was successfully processed.
3275    """
3276    success = False
3277    # Group by perturbation number
3278    pert_groups = {}
3279    for pert_value in pdtn_df["perturbationNumber"].unique():
3280        if pd.notna(pert_value):
3281            pert_df = pdtn_df[pdtn_df["perturbationNumber"] == pert_value]
3282            pert_groups[pert_value] = pert_df
3283
3284    # Process each perturbation group
3285    for pert_num, pert_df in pert_groups.items():
3286        pert_name = f"pert_{int(pert_num)}"
3287
3288        ## Try to create dataset for this perturbation group
3289        # try:
3290        #    dss = create_datasets_from_df(pert_df, filename)
3291        #    if dss is not None:
3292        #        if len(dss) == 1:
3293        #            target_tree.ds = dss[0]
3294        #        else:
3295        #            dss_dict = {f"ds_{i}": ds for i, ds in enumerate(dss)}
3296        #            atree = xr.DataTree(dss_dict)
3297        #            target_tree[prob_name] = atree
3298        #        success = True
3299        # except Exception as e:
3300        #    # Log error but continue processing other groups
3301        #    print(f"Error creating dataset for perturbation {pert_name}: {e}")
3302
3303        # Try to create dataset for this perturbation group
3304        try:
3305            dss = create_datasets_from_df(pert_df, filename, chunks=chunks)
3306            dt = xr.DataTree()
3307            if len(dss) == 1:
3308                dt.ds = dss[0]
3309                target_tree[pert_name] = dt
3310            elif len(dss) > 1:
3311                for ds in dss:
3312                    dt[f"pert{ds.data_vars[0]}"] = ds
3313            target_tree[pert_name] = dt
3314        except Exception as e:
3315            # Log error but continue processing other groups
3316            print(f"Error creating dataset for perturbation {pert_name}: {e}")
3317
3318    return success
3319
3320
3321def try_process_by_variables(
3322    target_tree: typing.Any,
3323    df: pd.DataFrame,
3324    filename: str,
3325    chunks: typing.Optional[typing.Union[int, typing.Dict[typing.Any, typing.Any], typing.Literal["auto"]]] = None,
3326) -> bool:
3327    """
3328    Try to separate data by variable names and create datasets.
3329
3330    Parameters
3331    ----------
3332    target_tree : xarray.DataTree
3333        The tree node to add variable datasets to.
3334    df : pandas.DataFrame
3335        DataFrame of messages.
3336    filename : str
3337        Path to the GRIB2 file.
3338    chunks : int, dict or 'auto', optional
3339        If chunks is provided, it is used to load the dataset into a
3340        dask-backed dataset.
3341
3342    Returns
3343    -------
3344    bool
3345        True if at least one variable was successfully processed.
3346    """
3347    success = False
3348
3349    try:
3350        for var_name in df["shortName"].unique():
3351            if pd.notna(var_name):
3352                var_df = df[df["shortName"] == var_name]
3353                try:
3354                    var_ds = create_datasets_from_df(var_df, filename, chunks=chunks)
3355                    if var_ds is not None:
3356                        target_tree[f"var_{var_name}"] = var_ds[0]
3357                        success = True
3358                except Exception as var_e:
3359                    print(f"Error creating dataset for variable {var_name}: {var_e}")
3360    except Exception as nested_e:
3361        print(f"Failed to process variables: {nested_e}")
3362
3363    return success
3364
3365
3366def create_datasets_from_df(
3367    df: pd.DataFrame,
3368    filename: str,
3369    verbose: bool = False,
3370    chunks: typing.Optional[typing.Union[int, typing.Dict[typing.Any, typing.Any], typing.Literal["auto"]]] = None,
3371) -> typing.Optional[typing.List[xr.Dataset]]:
3372    """
3373    Create a list of xarray Datasets from a DataFrame of messages.
3374
3375    Parameters
3376    ----------
3377    df : pandas.DataFrame
3378        DataFrame of GRIB messages.
3379    filename : str
3380        Path to the GRIB2 file.
3381    verbose : bool, optional
3382        If True, prints detailed debugging information.
3383    chunks : int, dict or 'auto', optional
3384        If chunks is provided, it is used to load the dataset into a
3385        dask-backed dataset.
3386
3387    Returns
3388    -------
3389    list of xarray.Dataset, optional
3390        List of Datasets, or None if creation failed.
3391    """
3392    try:
3393        # Use parse_grib_index to get dimensions and attributes
3394        file_index, dim_coords, attrs, coord_attrs = parse_grib_index(df, {})
3395
3396        # Divide up records by variable
3397        frames, cubes, extra_geo = make_variables(file_index, filename, dim_coords, allow_uneven_dims=True)
3398
3399        if frames is None:
3400            return None
3401
3402        ds_list = []
3403        for var_df, var_cube in zip(frames, cubes):
3404            da = build_da_without_coords(var_df, var_cube, filename, attrs)
3405
3406            # Assign variable-specific coords from its cube
3407            coords = coords_from_cube(var_cube)
3408            da = da.assign_coords(coords)
3409
3410            # Assign extra index associated coords for this variable
3411            for dim_name, coord_names in dim_coords.items():
3412                retain_index_coord = False
3413                for name in coord_names:
3414                    if name == dim_name:
3415                        retain_index_coord = True
3416                    else:
3417                        if dim_name not in da.dims:
3418                            # for assigning scalar coords
3419                            coord_data = var_df[name].unique().item()
3420                            da = da.assign_coords({name: coord_data})
3421                        else:
3422                            # Handle non-scalar coords
3423                            coord_data = [
3424                                var_df[var_df.index.get_level_values(f"{dim_name}_ix") == val][name].unique().item()
3425                                for val in range(da[dim_name].size)
3426                            ]
3427                            coord = pd.Index(coord_data, name=dim_name)
3428                            da = da.assign_coords({name: (dim_name, coord)})
3429                if not retain_index_coord and dim_name in da.coords:
3430                    da = da.drop_vars(dim_name)
3431
3432            # Create a dataset for this variable
3433            var_ds = xr.Dataset({da.name: da})
3434
3435            # Assign metadata and common coords
3436            var_ds = assign_xr_meta(var_ds, [var_df], var_cube, dim_coords, extra_geo, coord_attrs)
3437
3438            if chunks is not None:
3439                var_ds = var_ds.chunk(chunks)
3440
3441            ds_list.append(var_ds)
3442
3443        return ds_list
3444    except Exception as e:
3445        if verbose:
3446            print(f"Error in create_datasets_from_df: {e}")
3447        return None
3448
3449
3450if _HAS_DATATREE:
3451
3452    @xr.register_datatree_accessor("grib2io")
3453    class Grib2ioDataTree:
3454        """
3455        DataTree accessor for GRIB2 files.
3456
3457        This accessor provides methods for working with GRIB2 data organized
3458        in a hierarchical tree structure.
3459        """
3460
3461        def __init__(self, datatree_obj):
3462            self._obj = datatree_obj
3463
3464        def to_grib2(self, filename, mode: typing.Literal["x", "w", "a"] = "x"):
3465            """
3466            Write all datasets in the DataTree to a GRIB2 file.
3467
3468            Parameters
3469            ----------
3470            filename : str
3471                Name of the GRIB2 file to write to.
3472            mode : {"x", "w", "a"}, optional
3473                Persistence mode, default is "x" (create, fail if exists)
3474            """
3475            # Start with the specified mode
3476            current_mode = mode
3477
3478            # Function to recursively process the tree
3479            def process_tree(node):
3480                nonlocal current_mode
3481
3482                # If this is a Dataset node with data variables
3483                if node.ds is not None and node.ds.data_vars:
3484                    # Write dataset to GRIB2 file
3485                    node.ds.grib2io.to_grib2(filename, mode=current_mode)
3486                    # Switch to append mode after first write
3487                    current_mode = "a"
3488
3489                # Process children
3490                for child_name, child_node in node.children.items():
3491                    process_tree(child_node)
3492
3493            # Start processing from the root
3494            process_tree(self._obj)
3495
3496        def griddef(self):
3497            """
3498            Get the grid definition from the first dataset in the tree that has one.
3499
3500            Returns
3501            -------
3502            grib2io.Grib2GridDef
3503                Grid definition object
3504            """
3505
3506            # Function to find first dataset with GRIB2IO_section3
3507            def find_griddef(node):
3508                if node.ds is not None and node.ds.data_vars:
3509                    for var_name in node.ds.data_vars:
3510                        if "GRIB2IO_section3" in node.ds[var_name].attrs:
3511                            return Grib2GridDef.from_section3(node.ds[var_name].attrs["GRIB2IO_section3"])
3512
3513                # Check children
3514                for child_name, child_node in node.children.items():
3515                    griddef = find_griddef(child_node)
3516                    if griddef is not None:
3517                        return griddef
3518
3519                return None
3520
3521            return find_griddef(self._obj)
3522
3523        def interp(
3524            self,
3525            method: typing.Union[str, int],
3526            grid_def_out: grib2io.Grib2GridDef,
3527            method_options: typing.Optional[typing.List[int]] = None,
3528            num_threads: int = 1,
3529        ) -> typing.Any:
3530            """
3531            Interpolate all datasets in the tree to a new grid.
3532
3533            Parameters
3534            ----------
3535            method : str or int
3536                Interpolation method to use.
3537            grid_def_out : grib2io.Grib2GridDef
3538                Target grid definition.
3539            method_options : list of int, optional
3540                Options for interpolation method.
3541            num_threads : int, optional
3542                Number of threads to use for interpolation.
3543
3544            Returns
3545            -------
3546            xarray.DataTree
3547                New DataTree with interpolated data.
3548            """
3549            new_tree = xr.DataTree()
3550
3551            # Function to recursively process the tree
3552            def process_tree(node, new_parent):
3553                # If this is a Dataset node with data variables
3554                if node.ds is not None and node.ds.data_vars:
3555                    # Interpolate dataset
3556                    interp_ds = node.ds.grib2io.interp(
3557                        method,
3558                        grid_def_out,
3559                        method_options=method_options,
3560                        num_threads=num_threads,
3561                    )
3562
3563                    # Add to new tree at the same path
3564                    if node == self._obj:  # Root node
3565                        new_parent.ds = interp_ds
3566                    else:
3567                        new_parent.ds = interp_ds
3568
3569                # Process children
3570                for child_name, child_node in node.children.items():
3571                    # Create same child in new tree
3572                    new_child = xr.DataTree()
3573                    new_parent[child_name] = new_child
3574                    process_tree(child_node, new_child)
3575
3576            # Start processing from the root
3577            process_tree(self._obj, new_tree)
3578
3579            return new_tree
3580
3581        def subset(self, lats: typing.Sequence[float], lons: typing.Sequence[float]) -> typing.Any:
3582            """
3583            Subset all datasets in the tree to a region.
3584
3585            Parameters
3586            ----------
3587            lats : sequence of float
3588                Latitude bounds [min_lat, max_lat].
3589            lons : sequence of float
3590                Longitude bounds [min_lon, max_lon].
3591
3592            Returns
3593            -------
3594            xarray.DataTree
3595                New DataTree with subset data.
3596            """
3597            new_tree = xr.DataTree()
3598
3599            # Function to recursively process the tree
3600            def process_tree(node, new_parent):
3601                # If this is a Dataset node with data variables
3602                if node.ds is not None and node.ds.data_vars:
3603                    # Subset dataset
3604                    subset_ds = node.ds.grib2io.subset(lats, lons)
3605
3606                    # Add to new tree at the same path
3607                    if node == self._obj:  # Root node
3608                        new_parent.ds = subset_ds
3609                    else:
3610                        new_parent.ds = subset_ds
3611
3612                # Process children
3613                for child_name, child_node in node.children.items():
3614                    # Create same child in new tree
3615                    new_child = xr.DataTree()
3616                    new_parent[child_name] = new_child
3617                    process_tree(child_node, new_child)
3618
3619            # Start processing from the root
3620            process_tree(self._obj, new_tree)
3621
3622            return new_tree
AVAILABLE_NON_GEO_COORDS = ['duration', 'leadTime', 'percentileValue', 'perturbationNumber', 'refDate', 'thresholdLowerLimit', 'thresholdUpperLimit', 'valueOfFirstFixedSurface', 'valueOfSecondFixedSurface', 'typeOfAerosol', 'constituentType', 'sourceSinkIndicator', 'firstWavelength', 'secondWavelength', 'firstSizeOfAerosol', 'secondSizeOfAerosol', 'scaledValueOfFirstWavelength', 'scaledValueOfSecondWavelength', 'scaledValueOfCentralWaveNumber', 'scaledValueOfFirstSize', 'scaledValueOfSecondSize']

Available non-geographic coordinate names.

AVAILABLE_NON_GEO_DIMS = ['duration', 'leadTime', 'percentileValue', 'perturbationNumber', 'refDate', 'threshold', 'level', 'typeOfAerosol', 'constituentType', 'sourceSinkIndicator', 'firstWavelength', 'secondWavelength', 'firstSizeOfAerosol', 'secondSizeOfAerosol']

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: xarray.core.dataset.Dataset, data_model: str) -> xarray.core.dataset.Dataset:
242def parse_data_model(ds: xr.Dataset, data_model: str) -> xr.Dataset:
243    """
244    Normalize a GRIB2-derived Dataset to a target data model (currently ``"nws-viz"``).
245
246    When ``data_model == "nws-viz"``, this function converts coordinate and
247    variable names to snake_case, derives CF-like metadata, promotes select
248    GRIB-derived quantities to coordinates, optionally swaps dimensions, and
249    standardizes units/attributes. If ``data_model`` is anything else, the
250    input dataset is returned unchanged.
251
252    Parameters
253    ----------
254    ds : xarray.Dataset
255        GRIB2-derived dataset whose variables and attributes follow the
256        conventions emitted by ``grib2io``. Expected to contain GRIB-related
257        attributes such as ``typeOfFirstFixedSurface``,
258        ``typeOfSecondFixedSurface``, and (for probabilistic variables)
259        ``typeOfProbability``.
260    data_model : str
261        Target data model name. Only the value ``"nws-viz"`` triggers
262        transformations.
263
264    Returns
265    -------
266    xarray.Dataset
267        A new dataset with:
268        * Selected coordinates renamed:
269          ``refDate -> forecast_reference_time``,
270          ``leadTime -> lead_time``,
271          ``validDate -> time``,
272          ``percentileValue -> percentile``,
273          ``thresholdLowerLimit -> threshold_lower_limit``,
274          ``thresholdUpperLimit -> threshold_upper_limit``.
275        * Vertical coordinates derived from
276          ``valueOfFirstFixedSurface`` / ``valueOfSecondFixedSurface`` and their
277          corresponding ``typeOf*FixedSurface`` definitions. New coordinate
278          names are generated from the surface definition (lowercased, spaces
279          to underscores, punctuation removed). If the name already exists, a
280          ``"_2"`` suffix is appended.
281        * Possible dimension swaps:
282          ``level -> <derived_vertical_coord>`` when present; and for
283          probabilistic variables, ``threshold -> threshold_lower_limit`` or
284          ``threshold -> threshold_upper_limit`` when
285          ``typeOfProbability`` indicates the appropriate semantics.
286        * Variable names lowercased; dataset- and variable-level attributes
287          converted to snake_case (except GRIB section attributes which are
288          normalized to ``grib...``).
289        * CF-adjacent metadata populated: ``standard_name`` and
290          ``cell_methods`` are set via the shortname→CF lookup table.
291        * Percent units normalized from ``"%"`` to ``"percent"`` on coordinates.
292        * For precipitation type (``PTYPE``) thresholds, numeric codes are
293          decoded to strings (GRIB2 Table 4.201) in relevant attrs/coords.
294
295    Notes
296    -----
297    - Precipitation type decoding uses GRIB2 Table 4.201 via
298      ``tables.get_value_from_table(code, "4.201")`` and returns a NumPy
299      array with ``np.dtypes.StringDType``.
300    - CF-related lookups are performed using
301      ``tables.get_table("shortname_to_cf")``.
302    - Vertical coordinate surface names are validated against
303      ``VERTICAL_COORDINATE_SURFACES`` before promotion to coordinates.
304
305    Warnings
306    --------
307    This function assumes the presence of certain GRIB-derived attributes on the
308    first data variable (e.g., ``typeOfFirstFixedSurface``,
309    ``typeOfSecondFixedSurface``, and possibly ``typeOfProbability``).
310    If these are absent or malformed, errors (e.g., ``KeyError``) may occur.
311
312    Examples
313    --------
314    >>> ds2 = parse_data_model(ds, "nws-viz")
315    >>> list(ds2.coords)
316    ['forecast_reference_time', 'lead_time', 'time', 'percentile', ...]
317    """
318    # convert coordinates and attributes to CF if requested
319    if data_model == "nws-viz":
320        # define regex to convert to snake case
321        pattern = re.compile(r"(?<!^)(?=[A-Z])")
322
323        # check for coordinates and rename
324        for coord in ds.coords:
325            if coord == "refDate":
326                ds = ds.rename({"refDate": "forecast_reference_time"})
327
328            elif coord == "leadTime":
329                ds = ds.rename({"leadTime": "lead_time"})
330
331            elif coord == "validDate":
332                ds = ds.rename({"validDate": "time"})
333
334            elif coord == "percentileValue":
335                ds = ds.rename({"percentileValue": "percentile"})
336
337            elif coord == "perturbationNumber":
338                ds = ds.rename({"perturbationNumber": "perturbation"})
339                ds["perturbation"].attrs["long_name"] = "Ensemble Perturbation Number"
340
341            elif coord == "thresholdLowerLimit":
342                ds = ds.rename({"thresholdLowerLimit": "threshold_lower_limit"})
343                ds["threshold_lower_limit"].attrs["long_name"] = "Threshold Lower Limit"
344                ds["threshold_lower_limit"].attrs["units"] = ds[list(ds.data_vars.keys())[0]].attrs["units"]
345
346                if "PTYPE" in ds.data_vars:
347                    ds["threshold_lower_limit"] = xr.apply_ufunc(
348                        _decode_ptype,
349                        ds["threshold_lower_limit"],
350                        dask="parallelized",
351                        output_dtypes=[np.dtypes.StringDType] if _HAS_STRINGDTYPE else [object],
352                    )
353
354                # check if thresholdLowerLimit should be a dimension coordinate
355                if "threshold" in ds.dims:
356                    var_key = list(ds.data_vars.keys())[0]
357                    prob_types = [
358                        "Probability of event below lower limit",
359                        "Probability of event above lower limit",
360                        "Probability of event equal to lower limit",
361                        "Probability of event between upper and lower limits (the range includes lower limit but not the upper limit)",
362                    ]
363                    if ds[var_key].attrs["typeOfProbability"] in prob_types:
364                        ds = ds.swap_dims({"threshold": "threshold_lower_limit"})
365
366            elif coord == "thresholdUpperLimit":
367                ds = ds.rename({"thresholdUpperLimit": "threshold_upper_limit"})
368                ds["threshold_upper_limit"].attrs["long_name"] = "Threshold Upper Limit"
369                ds["threshold_upper_limit"].attrs["units"] = ds[list(ds.data_vars.keys())[0]].attrs["units"]
370
371                if "PTYPE" in ds.data_vars:
372                    ds["threshold_upper_limit"] = xr.apply_ufunc(
373                        _decode_ptype,
374                        ds["threshold_upper_limit"],
375                        dask="parallelized",
376                        output_dtypes=[np.dtypes.StringDType] if _HAS_STRINGDTYPE else [object],
377                    )
378
379                if "threshold" in ds.dims:
380                    var_key = list(ds.data_vars.keys())[0]
381                    prob_types = [
382                        "Probability of event below upper limit",
383                        "Probability of event above upper limit",
384                    ]
385                    if ds[var_key].attrs["typeOfProbability"] in prob_types:
386                        ds = ds.swap_dims({"threshold": "threshold_upper_limit"})
387
388            elif coord == "typeOfAerosol":
389                ds = ds.rename({"typeOfAerosol": "aerosol_type"})
390                ds["aerosol_type"].attrs["long_name"] = "Aerosol Type"
391                ds["aerosol_type"] = xr.apply_ufunc(
392                    _decode_code,
393                    ds["aerosol_type"],
394                    "4.233",
395                    dask="parallelized",
396                    output_dtypes=[np.dtypes.StringDType] if _HAS_STRINGDTYPE else [object],
397                )
398
399            elif coord == "constituentType":
400                ds = ds.rename({"constituentType": "constituent_type"})
401                ds["constituent_type"].attrs["long_name"] = "Chemical Constituent Type"
402                ds["constituent_type"] = xr.apply_ufunc(
403                    _decode_code,
404                    ds["constituent_type"],
405                    "4.230",
406                    dask="parallelized",
407                    output_dtypes=[np.dtypes.StringDType] if _HAS_STRINGDTYPE else [object],
408                )
409
410            elif coord == "sourceSinkIndicator":
411                ds = ds.rename({"sourceSinkIndicator": "source_sink_indicator"})
412                ds["source_sink_indicator"].attrs["long_name"] = "Source/Sink Indicator"
413                ds["source_sink_indicator"] = xr.apply_ufunc(
414                    _decode_code,
415                    ds["source_sink_indicator"],
416                    "4.238",
417                    dask="parallelized",
418                    output_dtypes=[np.dtypes.StringDType] if _HAS_STRINGDTYPE else [object],
419                )
420
421            elif coord == "firstWavelength":
422                ds = ds.rename({"firstWavelength": "first_wavelength"})
423                ds["first_wavelength"].attrs["long_name"] = "First Wavelength"
424                ds["first_wavelength"].attrs["units"] = "m"
425
426            elif coord == "secondWavelength":
427                ds = ds.rename({"secondWavelength": "second_wavelength"})
428                ds["second_wavelength"].attrs["long_name"] = "Second Wavelength"
429                ds["second_wavelength"].attrs["units"] = "m"
430
431            elif coord == "firstSizeOfAerosol":
432                ds = ds.rename({"firstSizeOfAerosol": "first_size_of_aerosol"})
433                ds["first_size_of_aerosol"].attrs["long_name"] = "First Size of Aerosol"
434                ds["first_size_of_aerosol"].attrs["units"] = "m"
435
436            elif coord == "secondSizeOfAerosol":
437                ds = ds.rename({"secondSizeOfAerosol": "second_size_of_aerosol"})
438                ds["second_size_of_aerosol"].attrs["long_name"] = "Second Size of Aerosol"
439                ds["second_size_of_aerosol"].attrs["units"] = "m"
440
441            elif coord == "scaledValueOfFirstWavelength":
442                ds = ds.rename({"scaledValueOfFirstWavelength": "scaled_first_wavelength"})
443                ds["scaled_first_wavelength"].attrs["long_name"] = "Scaled Value of First Wavelength"
444
445            elif coord == "scaledValueOfSecondWavelength":
446                ds = ds.rename({"scaledValueOfSecondWavelength": "scaled_second_wavelength"})
447                ds["scaled_second_wavelength"].attrs["long_name"] = "Scaled Value of Second Wavelength"
448
449            elif coord == "scaledValueOfCentralWaveNumber":
450                ds = ds.rename({"scaledValueOfCentralWaveNumber": "scaled_central_wave_number"})
451                ds["scaled_central_wave_number"].attrs["long_name"] = "Scaled Value of Central Wave Number"
452
453            elif coord == "scaledValueOfFirstSize":
454                ds = ds.rename({"scaledValueOfFirstSize": "scaled_first_size"})
455                ds["scaled_first_size"].attrs["long_name"] = "Scaled Value of First Size"
456
457            elif coord == "scaledValueOfSecondSize":
458                ds = ds.rename({"scaledValueOfSecondSize": "scaled_second_size"})
459                ds["scaled_second_size"].attrs["long_name"] = "Scaled Value of Second Size"
460
461            # If the dataset has valueOfFirstFixedSurface as a coordinate
462            elif coord == "valueOfFirstFixedSurface":
463                # Get the valueOfFirstFixedSurface coordinate
464                da = ds.valueOfFirstFixedSurface
465
466                # Get the definition and units from typeOfFirstFixedSurface
467                var_key = list(ds.data_vars.keys())[0]
468                definition, units = ds[var_key].attrs["typeOfFirstFixedSurface"]
469
470                if definition in VERTICAL_COORDINATE_SURFACES:
471                    # Convert definition to lowercase and replace spaces with underscores
472                    key = definition.lower().replace(" ", "_")
473
474                    # remove special characters
475                    key = re.sub(r"[^a-z0-9_]", "", key)
476
477                    # Add units and grib_name attributes
478                    da.attrs["units"] = units
479                    da.attrs["grib_name"] = [
480                        "valueOfFirstFixedSurface",
481                        "typeOfFirstFixedSurface",
482                    ]
483
484                    # Assign the coordinate with the new key name
485                    ds = ds.assign_coords({key: da})
486
487                    # If valueOfFirstFixedSurface is a dimension, swap it with the new key
488                    if "level" in ds.dims:
489                        ds = ds.swap_dims({"level": key})
490
491                # Remove the original coordinates
492                del ds["valueOfFirstFixedSurface"]
493
494            # If the dataset has valueOfSecondFixedSurface as a coordinate
495            elif coord == "valueOfSecondFixedSurface":
496                # Get the valueOfSecondFixedSurface coordinate
497                da = ds.valueOfSecondFixedSurface
498
499                # Get the definition and units from typeOfSecondFixedSurface
500                var_key = list(ds.data_vars.keys())[0]
501                definition, units = ds[var_key].attrs["typeOfSecondFixedSurface"]
502
503                if definition in VERTICAL_COORDINATE_SURFACES:
504                    # Convert definition to lowercase and replace spaces with underscores
505                    key = definition.lower().replace(" ", "_")
506
507                    # remove special characters
508                    key = re.sub(r"[^a-z0-9_]", "", key)
509
510                    # check if key is already in coords
511                    if key in ds.coords:
512                        key = key + "_2"
513
514                    # Add units and grib_name attributes
515                    da.attrs["units"] = units
516                    da.attrs["grib_name"] = [
517                        "valueOfSecondFixedSurface",
518                        "typeOfSecondFixedSurface",
519                    ]
520
521                    # Assign the coordinate with the new key name
522                    ds = ds.assign_coords({key: da})
523
524                # Remove the original coordinates
525                del ds["valueOfSecondFixedSurface"]
526            else:
527                # change coord name to snake case
528                new_coord_name = pattern.sub("_", coord).lower()
529                ds = ds.rename({coord: new_coord_name})
530
531        # convert all attributes and variable names to snake case
532        for var in ds.data_vars:
533            da = ds[var]
534            record = tables.get_table("shortname_to_cf").get(da.name)
535            da.attrs["standard_name"] = "unknown" if record is None else record["cf_standard_name"]
536            da.attrs["cell_methods"] = "unknown" if record is None else record["cf_cell_methods"]
537
538            ds[var] = da
539
540            # rename variable
541            new_var_name = var.lower()
542            ds = ds.rename({var: new_var_name})
543
544            # remove attr for typeOfFirstFixedSurface (applied as coordinate above)
545            if "typeOfFirstFixedSurface" in ds[new_var_name].attrs:
546                definition, units = ds[new_var_name].attrs["typeOfFirstFixedSurface"]
547                ds[new_var_name].attrs["typeOfFirstFixedSurface"] = f"{definition} ({units})"
548
549            if "typeOfSecondFixedSurface" in ds[new_var_name].attrs:
550                definition, units = ds[new_var_name].attrs["typeOfSecondFixedSurface"]
551                ds[new_var_name].attrs["typeOfSecondFixedSurface"] = f"{definition} ({units})"
552
553            ds[new_var_name].attrs.pop("percentileValue", None)
554
555            if "threshold_lower_limit" in ds.coords:
556                ds[new_var_name].attrs.pop("thresholdLowerLimit", None)
557
558            if "threshold_upper_limit" in ds.coords:
559                ds[new_var_name].attrs.pop("thresholdUpperLimit", None)
560
561            for attr in list(ds[new_var_name].attrs.keys()):
562                # skip grib section attrs
563                if "GRIB2IO_section" in attr:
564                    # replace GRIB2IO with grib in attr
565                    new_attr_name = attr.replace("GRIB2IO", "grib")
566                else:
567                    # change attr name to snake case
568                    new_attr_name = pattern.sub("_", attr).lower()
569
570                # update new attr name for specific CF names
571                if new_attr_name == "full_name":
572                    new_attr_name = "long_name"
573
574                # change % to percent
575                if attr == "units" and ds[new_var_name].attrs[attr] == "%":
576                    ds[new_var_name].attrs[attr] = "percent"
577
578                if new_var_name == "ptype" and "threshold" in new_attr_name:
579                    value = ds[new_var_name].attrs.pop(attr)
580                    ds[new_var_name].attrs[attr] = _decode_ptype(value)
581                else:
582                    # change attr name in attrs
583                    ds[new_var_name].attrs[new_attr_name] = ds[new_var_name].attrs.pop(attr)
584
585            try:
586                new_cell_methods = section4_to_cell_methods(ds[new_var_name].attrs["grib_section4"])
587            except KeyError:
588                pass
589            else:
590                if new_cell_methods is not None:
591                    if ds[new_var_name].attrs["cell_methods"] is None:
592                        ds[new_var_name].attrs["cell_methods"] = new_cell_methods
593                    else:
594                        ds[new_var_name].attrs["cell_methods"] = " ".join(ds[new_var_name].attrs["cell_methods"], new_cell_methods)
595
596        # change dataset attrs to snake case
597        for attr in list(ds.attrs.keys()):
598            # change attr name to snake case
599            new_attr_name = pattern.sub("_", attr).lower()
600
601            # change attr name in attrs
602            ds.attrs[new_attr_name] = ds.attrs.pop(attr)
603
604        # change % to percent
605        for coord in ds.coords:
606            if "units" in ds[coord].attrs and ds[coord].attrs["units"] == "%":
607                ds[coord].attrs["units"] = "percent"
608
609        # Update history for provenance
610        history = ds.attrs.get("history", "")
611        now = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
612        ds.attrs["history"] = f"{now}: Parsed to data model {data_model}\n{history}"
613
614    # Update history for provenance
615    history = ds.attrs.get("history", "")
616    now = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
617    ds.attrs["history"] = f"{now}: Normalized to {data_model} data model\n{history}"
618
619    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', ...]
def section4_to_cell_methods(section4_array: numpy.ndarray) -> Optional[str]:
622def section4_to_cell_methods(section4_array: np.ndarray) -> typing.Optional[str]:
623    cell_methods = None
624    if section4_array[1] == 0:
625        cell_methods = f"{cell_methods:s} lead_time: point"
626    elif section4_array[1] == 8:
627        to_join = []
628        # interval_end = datetime.datetime(*section4_array[17:23]
629        time_unit_table = tables.get_table("4.4")
630        for i in reversed(range(section4_array[23])):
631            offset = 6 * i
632            method = tables.get_table("4.10")[str(section4_array[25 + offset])]
633            method = method.replace("Average", "mean").lower()
634            if section4_array[26 + offset] == 1:
635                dim = "forecast_reference_time"
636            elif section4_array[26 + offset] == 2:
637                dim = "lead_time"
638            # duration_unit = time_unit_table[str(section4_array[27 + offset])].lower()
639            # duration_value = section4_array[28 + offset]
640            input_interval_units = time_unit_table[str(section4_array[29 + offset])].lower()
641            input_interval_value = section4_array[30 + offset]
642            to_join.append(f"{dim:s}: {method:s} (interval: {input_interval_value:d} {input_interval_units:s})")
643            # comment: duration {duration_value:d} {duration_unit:s} ending {interval_end:%Y-%m-%dT%H:%M:%s}
644        cell_methods = " ".join(to_join)
645    return cell_methods
class GribBackendEntrypoint(xarray.backends.common.BackendEntrypoint):
653class GribBackendEntrypoint(BackendEntrypoint):
654    """
655    xarray backend engine entrypoint for opening and decoding grib2 files.
656
657    .. warning::
658
659       This backend is experimental and the API/behavior may change without
660       backward compatibility.
661    """
662
663    def open_dataset(
664        self,
665        filename_or_obj,
666        drop_variables=None,
667        save_index=True,
668        filters=None,
669        data_model=None,
670        chunks=None,
671        storage_options=None,
672    ) -> xr.Dataset:
673        """
674        Read and parse metadata from a GRIB2 file.
675
676        Parameters
677        ----------
678        filename_or_obj : str or file-like
679            GRIB2 file to be opened. Can be a local path or a remote URI.
680        drop_variables : list of str, optional
681            List of variables to exclude from the dataset.
682        save_index : bool, optional
683            Whether to save the GRIB2 index to a file (default is True).
684        filters : dict, optional
685            Filter GRIB2 messages to a single hypercube. Dictionary keys can
686            be any GRIB2 metadata attribute name.
687        data_model : str, optional
688            Parse GRIB metadata following a defined data model convention
689            (e.g., "nws-viz").
690        chunks : int, dict or 'auto', optional
691            If chunks is provided, it is used to load the dataset into a
692            dask-backed dataset.
693        storage_options : dict, optional
694            Extra options passed to the storage backend.
695
696        Returns
697        -------
698        xarray.Dataset
699            Xarray dataset of GRIB2 messages.
700        """
701        if filters is None:
702            filters = {}
703
704        with grib2io.open(
705            filename_or_obj,
706            save_index=save_index,
707            _xarray_backend=True,
708            **(storage_options or {}),
709        ) as f:
710            file_index = pd.DataFrame(f._index)
711            file_index = file_index.assign(msg=list(f))
712
713        ds = _open_dataset_from_index(
714            file_index,
715            filename_or_obj,
716            filters,
717            data_model,
718            drop_variables=drop_variables,
719            chunks=chunks,
720        )
721
722        # Update history for provenance
723        history = ds.attrs.get("history", "")
724        now = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
725        ds.attrs["history"] = f"{now}: Initialized via grib2io.open_dataset from {filename_or_obj}\n{history}"
726
727        return ds
728
729    def open_datatree(
730        self,
731        filename_or_obj,
732        drop_variables=None,
733        save_index=True,
734        filters=None,
735        stack_vertical=False,
736        chunks=None,
737    ) -> typing.Any:
738        """
739        Open a GRIB2 file as an xarray DataTree.
740
741        Parameters
742        ----------
743        filename : str
744            Path to the GRIB2 file.
745        drop_variables : list, optional
746            List of variables to exclude.
747        filters : dict, optional
748            Filter criteria for GRIB2 messages.
749        stack_vertical : bool, optional
750            If True, organize the tree with vertical layers stacked in a single dataset.
751        chunks : int, dict or 'auto', optional
752            If chunks is provided, it is used to load the dataset into a
753            dask-backed dataset.
754
755        Returns
756        -------
757        xarray.DataTree
758            A hierarchical DataTree representation of the GRIB2 data.
759        """
760        if not _HAS_DATATREE:
761            raise ImportError("xarray version does not support DataTree functionality.")
762
763        if filters is None:
764            filters = {}
765
766        # Open the file without any filters first to get all messages
767        with grib2io.open(filename_or_obj, save_index=save_index, _xarray_backend=True) as f:
768            file_index = pd.DataFrame(f._index)
769            file_index = file_index.assign(msg=list(f))
770
771        # Build tree structure from GRIB messages with specified options
772        tree = build_datatree_from_grib(
773            filename_or_obj,
774            file_index,
775            filters,
776            stack_vertical=stack_vertical,
777            drop_variables=drop_variables,
778            chunks=chunks,
779        )
780
781        # Update history for provenance
782        now = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
783        history = f"{now}: Initialized via grib2io.open_datatree\n"
784
785        def _add_history(node):
786            if node.ds is not None:
787                node.ds.attrs["history"] = history + node.ds.attrs.get("history", "")
788            for child in node.children.values():
789                _add_history(child)
790
791        _add_history(tree)
792
793        # Put warning here so it is the last message from likely other Xarray warnings.
794        warnings.warn(
795            "grib2io’s xarray backend DataTree support is experimental. The DataTree structure or attributes may change in future releases.",
796            UserWarning,
797            stacklevel=2,
798        )
799
800        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_or_obj, drop_variables=None, save_index=True, filters=None, data_model=None, chunks=None, storage_options=None) -> xarray.core.dataset.Dataset:
663    def open_dataset(
664        self,
665        filename_or_obj,
666        drop_variables=None,
667        save_index=True,
668        filters=None,
669        data_model=None,
670        chunks=None,
671        storage_options=None,
672    ) -> xr.Dataset:
673        """
674        Read and parse metadata from a GRIB2 file.
675
676        Parameters
677        ----------
678        filename_or_obj : str or file-like
679            GRIB2 file to be opened. Can be a local path or a remote URI.
680        drop_variables : list of str, optional
681            List of variables to exclude from the dataset.
682        save_index : bool, optional
683            Whether to save the GRIB2 index to a file (default is True).
684        filters : dict, optional
685            Filter GRIB2 messages to a single hypercube. Dictionary keys can
686            be any GRIB2 metadata attribute name.
687        data_model : str, optional
688            Parse GRIB metadata following a defined data model convention
689            (e.g., "nws-viz").
690        chunks : int, dict or 'auto', optional
691            If chunks is provided, it is used to load the dataset into a
692            dask-backed dataset.
693        storage_options : dict, optional
694            Extra options passed to the storage backend.
695
696        Returns
697        -------
698        xarray.Dataset
699            Xarray dataset of GRIB2 messages.
700        """
701        if filters is None:
702            filters = {}
703
704        with grib2io.open(
705            filename_or_obj,
706            save_index=save_index,
707            _xarray_backend=True,
708            **(storage_options or {}),
709        ) as f:
710            file_index = pd.DataFrame(f._index)
711            file_index = file_index.assign(msg=list(f))
712
713        ds = _open_dataset_from_index(
714            file_index,
715            filename_or_obj,
716            filters,
717            data_model,
718            drop_variables=drop_variables,
719            chunks=chunks,
720        )
721
722        # Update history for provenance
723        history = ds.attrs.get("history", "")
724        now = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
725        ds.attrs["history"] = f"{now}: Initialized via grib2io.open_dataset from {filename_or_obj}\n{history}"
726
727        return ds

Read and parse metadata from a GRIB2 file.

Parameters
  • filename_or_obj (str or file-like): GRIB2 file to be opened. Can be a local path or a remote URI.
  • drop_variables (list of str, optional): List of variables to exclude from the dataset.
  • save_index (bool, optional): Whether to save the GRIB2 index to a file (default is True).
  • filters (dict, optional): Filter GRIB2 messages to a single hypercube. Dictionary keys can be any GRIB2 metadata attribute name.
  • data_model (str, optional): Parse GRIB metadata following a defined data model convention (e.g., "nws-viz").
  • chunks (int, dict or 'auto', optional): If chunks is provided, it is used to load the dataset into a dask-backed dataset.
  • storage_options (dict, optional): Extra options passed to the storage backend.
Returns
  • xarray.Dataset: Xarray dataset of GRIB2 messages.
def open_datatree( self, filename_or_obj, drop_variables=None, save_index=True, filters=None, stack_vertical=False, chunks=None) -> Any:
729    def open_datatree(
730        self,
731        filename_or_obj,
732        drop_variables=None,
733        save_index=True,
734        filters=None,
735        stack_vertical=False,
736        chunks=None,
737    ) -> typing.Any:
738        """
739        Open a GRIB2 file as an xarray DataTree.
740
741        Parameters
742        ----------
743        filename : str
744            Path to the GRIB2 file.
745        drop_variables : list, optional
746            List of variables to exclude.
747        filters : dict, optional
748            Filter criteria for GRIB2 messages.
749        stack_vertical : bool, optional
750            If True, organize the tree with vertical layers stacked in a single dataset.
751        chunks : int, dict or 'auto', optional
752            If chunks is provided, it is used to load the dataset into a
753            dask-backed dataset.
754
755        Returns
756        -------
757        xarray.DataTree
758            A hierarchical DataTree representation of the GRIB2 data.
759        """
760        if not _HAS_DATATREE:
761            raise ImportError("xarray version does not support DataTree functionality.")
762
763        if filters is None:
764            filters = {}
765
766        # Open the file without any filters first to get all messages
767        with grib2io.open(filename_or_obj, save_index=save_index, _xarray_backend=True) as f:
768            file_index = pd.DataFrame(f._index)
769            file_index = file_index.assign(msg=list(f))
770
771        # Build tree structure from GRIB messages with specified options
772        tree = build_datatree_from_grib(
773            filename_or_obj,
774            file_index,
775            filters,
776            stack_vertical=stack_vertical,
777            drop_variables=drop_variables,
778            chunks=chunks,
779        )
780
781        # Update history for provenance
782        now = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
783        history = f"{now}: Initialized via grib2io.open_datatree\n"
784
785        def _add_history(node):
786            if node.ds is not None:
787                node.ds.attrs["history"] = history + node.ds.attrs.get("history", "")
788            for child in node.children.values():
789                _add_history(child)
790
791        _add_history(tree)
792
793        # Put warning here so it is the last message from likely other Xarray warnings.
794        warnings.warn(
795            "grib2io’s xarray backend DataTree support is experimental. The DataTree structure or attributes may change in future releases.",
796            UserWarning,
797            stacklevel=2,
798        )
799
800        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.
  • chunks (int, dict or 'auto', optional): If chunks is provided, it is used to load the dataset into a dask-backed dataset.
Returns
  • xarray.DataTree: A hierarchical DataTree representation of the GRIB2 data.
class GribBackendArray(xarray.backends.common.BackendArray):
803class GribBackendArray(BackendArray):
804    """
805    BackendArray implementation for GRIB2 data.
806    """
807
808    def __init__(self, array: "OnDiskArray", lock: SerializableLock):
809        """
810        Initialize the GribBackendArray.
811
812        Parameters
813        ----------
814        array : OnDiskArray
815            The on-disk array object.
816        lock : SerializableLock
817            The lock to use for thread-safe access.
818        """
819        self.array = array
820        self.shape = array.shape
821        self.dtype = np.dtype(array.dtype)
822        self.lock = lock
823
824    def __getitem__(self, key: xr.core.indexing.ExplicitIndexer) -> np.typing.ArrayLike:
825        return xr.core.indexing.explicit_indexing_adapter(
826            key,
827            self.shape,
828            indexing.IndexingSupport.BASIC,
829            self._raw_getitem,
830        )
831
832    def _raw_getitem(self, key: tuple) -> np.ndarray:
833        """
834        Implement thread-safe access to data on disk.
835
836        Parameters
837        ----------
838        key : tuple
839            The indexing key.
840
841        Returns
842        -------
843        np.ndarray
844            The indexed array.
845        """
846        with self.lock:
847            return self.array[key]

BackendArray implementation for GRIB2 data.

GribBackendArray( array: OnDiskArray, lock: xarray.backends.locks.SerializableLock)
808    def __init__(self, array: "OnDiskArray", lock: SerializableLock):
809        """
810        Initialize the GribBackendArray.
811
812        Parameters
813        ----------
814        array : OnDiskArray
815            The on-disk array object.
816        lock : SerializableLock
817            The lock to use for thread-safe access.
818        """
819        self.array = array
820        self.shape = array.shape
821        self.dtype = np.dtype(array.dtype)
822        self.lock = lock

Initialize the GribBackendArray.

Parameters
  • array (OnDiskArray): The on-disk array object.
  • lock (SerializableLock): The lock to use for thread-safe access.
array
shape
dtype
lock
class Grid:
850class Grid:
851    def __new__(cls, section3):
852        gdtn = section3[4]
853        Gdt = templates.gdt_class_by_gdtn(gdtn)
854
855        @dataclass
856        class _Grid(Gdt):
857            section3: NDArray = field(init=True, repr=True)
858            # Section 3 looked up common attributes.  Other looked up attributes are available according
859            # to the Grid Definition Template.
860            gridDefinitionSection: NDArray = field(init=False, repr=False, default=templates.GridDefinitionSection())
861            sourceOfGridDefinition: int = field(init=False, repr=False, default=templates.SourceOfGridDefinition())
862            numberOfDataPoints: int = field(init=False, repr=False, default=templates.NumberOfDataPoints())
863            interpretationOfListOfNumbers: templates.Grib2Metadata = field(
864                init=False,
865                repr=False,
866                default=templates.InterpretationOfListOfNumbers(),
867            )
868            gridDefinitionTemplateNumber: templates.Grib2Metadata = field(init=False, repr=False, default=templates.GridDefinitionTemplateNumber())
869            gridDefinitionTemplate: list = field(init=False, repr=False, default=templates.GridDefinitionTemplate())
870            _earthparams: dict = field(init=False, repr=False, default=templates.EarthParams())
871            _dxsign: float = field(init=False, repr=False, default=templates.DxSign())
872            _dysign: float = field(init=False, repr=False, default=templates.DySign())
873            _llscalefactor: float = field(init=False, repr=False, default=templates.LLScaleFactor())
874            _lldivisor: float = field(init=False, repr=False, default=templates.LLDivisor())
875            _xydivisor: float = field(init=False, repr=False, default=templates.XYDivisor())
876            shapeOfEarth: templates.Grib2Metadata = field(init=False, repr=False, default=templates.ShapeOfEarth())
877            earthShape: str = field(init=False, repr=False, default=templates.EarthShape())
878            earthRadius: float = field(init=False, repr=False, default=templates.EarthRadius())
879            earthMajorAxis: float = field(init=False, repr=False, default=templates.EarthMajorAxis())
880            earthMinorAxis: float = field(init=False, repr=False, default=templates.EarthMinorAxis())
881            resolutionAndComponentFlags: list = field(init=False, repr=False, default=templates.ResolutionAndComponentFlags())
882            ny: int = field(init=False, repr=False, default=templates.Ny())
883            nx: int = field(init=False, repr=False, default=templates.Nx())
884            scanModeFlags: list = field(init=False, repr=False, default=templates.ScanModeFlags())
885            projParameters: dict = field(init=False, repr=False, default=templates.ProjParameters())
886
887            def __post_init__(self):
888                self.gdtn = self.section3[4]
889
890        grid = _Grid(section3)
891        return grid
def exclusive_slice_to_inclusive(item: slice):
894def exclusive_slice_to_inclusive(item: slice):
895    """
896    Convert a slice with exclusive stop to an inclusive slice.
897
898    If the slice has a step, the stop is reduced by the step, so that both
899    interpretations would yield the same result.
900
901    The means that [start, stop) is converted to [start, stop - step].
902
903    Parameters
904    ----------
905    item
906        The slice to convert.
907
908    Returns
909    -------
910    slice
911        The converted slice.
912    """
913    # return the None slice
914    if item.start is None and item.stop is None and item.step is None:
915        return item
916    if not isinstance(item, slice):
917        raise ValueError(f"item must be a slice; it was of type {type(item)}")
918    # if step is None, it's one
919    step = 1 if item.step is None else item.step
920    if item.stop < item.start or step < 1:
921        raise ValueError(f"slice {item} not accounted for")
922    # handle case where slice has one item
923    if abs(item.stop - item.start) == step:
924        return [item.start]
925    # other cases require reducing the stop by the step
926    s = slice(item.start, item.stop - step, step)
927    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:
930class Validator:
931    def __set_name__(self, owner, name):
932        self.private_name = f"_{name}"
933        self.name = name
934
935    def __get__(self, obj, objtype=None):
936        try:
937            value = getattr(obj, self.private_name)
938        except AttributeError:
939            value = None
940        return value
class PdIndex(Validator):
943class PdIndex(Validator):
944    def __set__(self, obj, value):
945        try:
946            value = pd.Index(value)
947        except TypeError:
948            value = pd.Index([value])
949        setattr(obj, self.private_name, value)
def array_safe_eq(a: Any, b: Any) -> bool:
 980def array_safe_eq(a: typing.Any, b: typing.Any) -> bool:
 981    """
 982    Check if a and b are equal, even if they are numpy arrays.
 983
 984    Parameters
 985    ----------
 986    a : any
 987        First object to compare.
 988    b : any
 989        Second object to compare.
 990
 991    Returns
 992    -------
 993    bool
 994        True if equal, False otherwise.
 995    """
 996    if a is b:
 997        return True
 998    if hasattr(a, "equals"):
 999        return a.equals(b)
1000    if hasattr(a, "all") and hasattr(b, "all"):
1001        return a.shape == b.shape and (a == b).all()
1002    if hasattr(a, "all") or hasattr(b, "all"):
1003        return False
1004    try:
1005        return a == b
1006    except TypeError:
1007        return NotImplementedError

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

Parameters
  • a (any): First object to compare.
  • b (any): Second object to compare.
Returns
  • bool: True if equal, False otherwise.
def dc_eq(dc1: Any, dc2: Any) -> bool:
1010def dc_eq(dc1: typing.Any, dc2: typing.Any) -> bool:
1011    """
1012    Check if two dataclasses which hold numpy arrays are equal.
1013
1014    Parameters
1015    ----------
1016    dc1 : any
1017        First dataclass to compare.
1018    dc2 : any
1019        Second dataclass to compare.
1020
1021    Returns
1022    -------
1023    bool
1024        True if equal, False otherwise.
1025    """
1026    if dc1 is dc2:
1027        return True
1028    if dc1.__class__ is not dc2.__class__:
1029        return NotImplementedError
1030    t1 = astuple(dc1)
1031    t2 = astuple(dc2)
1032    return all(array_safe_eq(a1, a2) for a1, a2 in zip(t1, t2))

Check if two dataclasses which hold numpy arrays are equal.

Parameters
  • dc1 (any): First dataclass to compare.
  • dc2 (any): Second dataclass to compare.
Returns
  • bool: True if equal, False otherwise.
def coords_from_cube(cube: dict) -> Dict[str, xarray.core.variable.Variable]:
1035def coords_from_cube(cube: dict) -> typing.Dict[str, xr.Variable]:
1036    """
1037    Create a dictionary of xarray Variables from a cube definition.
1038
1039    Parameters
1040    ----------
1041    cube : dict
1042        Dimension cube definition.
1043
1044    Returns
1045    -------
1046    dict of str to xarray.Variable
1047        Coordinates for the Dataset/DataArray.
1048    """
1049    keys = list(cube.keys())
1050    keys.remove("x")
1051    keys.remove("y")
1052    coords = dict()
1053    for k in keys:
1054        if k is not None:
1055            if len(cube[k]) > 1:
1056                coords[k] = xr.Variable(dims=k, data=cube[k], attrs=dict(grib_name=k))
1057            elif len(cube[k]) == 1:
1058                coords[k] = xr.Variable(dims=tuple(), data=cube[k][0], attrs=dict(grib_name=k))
1059    return coords

Create a dictionary of xarray Variables from a cube definition.

Parameters
  • cube (dict): Dimension cube definition.
Returns
  • dict of str to xarray.Variable: Coordinates for the Dataset/DataArray.
@dataclass
class OnDiskArray:
1062@dataclass
1063class OnDiskArray:
1064    """
1065    On-disk array representation for GRIB2 messages.
1066    """
1067
1068    file_name: typing.Union[str, typing.List[str]]
1069    index: pd.DataFrame = field(repr=False)
1070    cube: dict = field(repr=False)
1071    shape: typing.Tuple[int, ...] = field(init=False)
1072    ndim: int = field(init=False)
1073    geo_ndim: int = field(init=False)
1074    dtype: str = "float32"
1075
1076    def __post_init__(self):
1077        # multiple grids not allowed so can just use first
1078        geo_shape = (self.index.iloc[0].ny, self.index.iloc[0].nx)
1079
1080        self.geo_shape = geo_shape
1081        self.geo_ndim = len(geo_shape)
1082
1083        if len(self.index) == 1:
1084            self.shape = geo_shape
1085        else:
1086            if self.index.index.nlevels == 1:
1087                self.shape = tuple([len(self.index.index)]) + geo_shape
1088            else:
1089                self.shape = tuple([len(i) for i in self.index.index.levels]) + geo_shape
1090        self.ndim = len(self.shape)
1091
1092        cols = ["msg", "sectionOffset"]
1093        if "file_index" in self.index.columns:
1094            cols.append("file_index")
1095        self.index = self.index[cols]
1096
1097    def __getitem__(self, item: tuple) -> np.ndarray:
1098        """
1099        Retrieve data from disk for the specified slices.
1100
1101        Parameters
1102        ----------
1103        item : tuple
1104            The slicing tuple.
1105
1106        Returns
1107        -------
1108        np.ndarray
1109            The retrieved data array.
1110        """
1111        # dimensions not in index are internal to tdlpack records; 2 dims for
1112        # grids; 1 dim for stations
1113
1114        index_slicer = item[: -self.geo_ndim]
1115        # maintain all multindex levels
1116        index_slicer = tuple([[i] if isinstance(i, int) else i for i in index_slicer])
1117
1118        # pandas loc slicing is inclusive, therefore convert slices into
1119        # explicit lists
1120        index_slicer_inclusive = tuple([exclusive_slice_to_inclusive(i) if isinstance(i, slice) else i for i in index_slicer])
1121
1122        # get records selected by item in new index dataframe
1123        if len(index_slicer_inclusive) == 1:
1124            index = self.index.loc[index_slicer_inclusive]
1125        elif len(index_slicer_inclusive) > 1:
1126            index = self.index.loc[index_slicer_inclusive, :]
1127        else:
1128            index = self.index
1129        index = index.set_index(index.index)
1130
1131        # set miloc to new relative locations in sub array
1132        index["miloc"] = list(zip(*[index.index.unique(level=dim).get_indexer(index.index.get_level_values(dim)) for dim in index.index.names]))
1133
1134        if len(index_slicer_inclusive) == 1:
1135            array_field_shape = tuple([len(index.index)]) + self.geo_shape
1136        elif len(index_slicer_inclusive) > 1:
1137            array_field_shape = index.index.levshape + self.geo_shape
1138        else:
1139            array_field_shape = self.geo_shape
1140
1141        array_field = np.full(array_field_shape, fill_value=np.nan, dtype="float32")
1142
1143        if "file_index" in index.columns:
1144            for file_idx, group in index.groupby("file_index"):
1145                filename = self.file_name[file_idx] if isinstance(self.file_name, list) else self.file_name
1146                with open(filename, mode="rb") as filehandle:
1147                    for key, row in group.iterrows():
1148                        bitmap_offset = None if pd.isna(row["sectionOffset"][6]) else int(row["sectionOffset"][6])
1149                        values = _data(filehandle, row.msg, bitmap_offset, row["sectionOffset"][7])
1150
1151                        if len(index_slicer_inclusive) >= 1:
1152                            array_field[row.miloc] = values
1153                        else:
1154                            array_field = values
1155        else:
1156            with open(self.file_name, mode="rb") as filehandle:
1157                for key, row in index.iterrows():
1158                    bitmap_offset = None if pd.isna(row["sectionOffset"][6]) else int(row["sectionOffset"][6])
1159                    values = _data(filehandle, row.msg, bitmap_offset, row["sectionOffset"][7])
1160
1161                    if len(index_slicer_inclusive) >= 1:
1162                        array_field[row.miloc] = values
1163                    else:
1164                        array_field = values
1165
1166        # handle geo dim slicing
1167        array_field = array_field[(Ellipsis,) + item[-self.geo_ndim :]]
1168
1169        # squeeze array dimensions expressed as integer
1170        for i, it in reversed(list(enumerate(item[: -self.geo_ndim]))):
1171            if isinstance(it, int):
1172                array_field = array_field[(slice(None, None, None),) * i + (0,)]
1173
1174        return array_field

On-disk array representation for GRIB2 messages.

OnDiskArray( file_name: Union[str, List[str]], index: pandas.core.frame.DataFrame, cube: dict, dtype: str = 'float32')
file_name: Union[str, List[str]]
index: pandas.core.frame.DataFrame
cube: dict
shape: Tuple[int, ...]
ndim: int
geo_ndim: int
dtype: str = 'float32'
def dims_to_shape(d: dict) -> tuple:
1177def dims_to_shape(d: dict) -> tuple:
1178    """
1179    Convert dimension metadata to a shape tuple.
1180
1181    Parameters
1182    ----------
1183    d : dict
1184        Dimension metadata dictionary.
1185
1186    Returns
1187    -------
1188    tuple
1189        Shape tuple.
1190    """
1191    if "nx" in d:
1192        t = (d["ny"], d["nx"])
1193    else:
1194        t = (d["nsta"],)
1195    return t

Convert dimension metadata to a shape tuple.

Parameters
  • d (dict): Dimension metadata dictionary.
Returns
  • tuple: Shape tuple.
def filter_index( index: pandas.core.frame.DataFrame, k: str, v: Any) -> pandas.core.frame.DataFrame:
1198def filter_index(index: pd.DataFrame, k: str, v: typing.Any) -> pd.DataFrame:
1199    """
1200    Filter a GRIB2 index DataFrame by a key-value pair.
1201
1202    Supports slice and vectorized-indexing similar to xarray's ``sel``.
1203
1204    Parameters
1205    ----------
1206    index : pandas.DataFrame
1207        The GRIB2 index DataFrame to filter.
1208    k : str
1209        Column name to filter by.
1210    v : any
1211        Value(s) or slice to filter for.
1212
1213    Returns
1214    -------
1215    pandas.DataFrame
1216        Filtered index.
1217    """
1218    if isinstance(v, slice):
1219        index = index.set_index(k)
1220        index = index.loc[v]
1221        index = index.reset_index()
1222    else:
1223        label = (
1224            v
1225            if getattr(v, "ndim", 1) > 1  # vectorized-indexing
1226            else _asarray_tuplesafe(v)
1227        )
1228        if label.ndim == 0:
1229            # see https://github.com/pydata/xarray/pull/4292 for details
1230            label_value = label[()] if label.dtype.kind in "mM" else label.item()
1231            try:
1232                indexer = pd.Index(index[k]).get_loc(label_value)
1233                if isinstance(indexer, int):
1234                    index = index.iloc[[indexer]]
1235                else:
1236                    index = index.iloc[indexer]
1237            except KeyError:
1238                index = index.iloc[[]]
1239        else:
1240            indexer = pd.Index(index[k]).get_indexer_for(np.ravel(v))
1241            index = index.iloc[indexer[indexer >= 0]]
1242
1243    return index

Filter a GRIB2 index DataFrame by a key-value pair.

Supports slice and vectorized-indexing similar to xarray's sel.

Parameters
  • index (pandas.DataFrame): The GRIB2 index DataFrame to filter.
  • k (str): Column name to filter by.
  • v (any): Value(s) or slice to filter for.
Returns
  • pandas.DataFrame: Filtered index.
def parse_grib_index( index: pandas.core.frame.DataFrame, filters: Mapping[str, Any] = {}) -> Tuple[pandas.core.frame.DataFrame, Dict[str, List[str]], dict, Dict[str, dict]]:
1246def parse_grib_index(
1247    index: pd.DataFrame,
1248    filters: typing.Mapping[str, typing.Any] = dict(),
1249) -> typing.Tuple[pd.DataFrame, typing.Dict[str, typing.List[str]], dict, typing.Dict[str, dict]]:
1250    """
1251    Apply filters.
1252
1253    Evaluate remaining dimensions based on pdtn and parse each out.
1254
1255    Parameters
1256    ----------
1257    index
1258        Pandas DataFrame containing the GRIB2 message index.
1259    filters
1260        Filter GRIB2 messages to single hypercube. Dict keys can be any
1261        GRIB2 metadata attribute name.
1262
1263    Returns
1264    -------
1265    index
1266        Modified Pandas DataFrame with added GRIB2 metadata columns.
1267    dim_coords
1268        List of GRIB2 attributes that will be used for coordinates and/or dimensions.
1269    attrs
1270        Dict of metadata attributes (non-coordinates, non-geo)
1271    """
1272
1273    # make a copy of filters, remove filters as they are applied
1274    filters = copy(filters)
1275
1276    for k, v in filters.items():
1277        if k not in index.columns:
1278            kwarg = {k: index.msg.apply(lambda msg: getattr(msg, k))}
1279            index = index.assign(**kwarg)
1280        # adopt parts of xarray's sel logic  so that filters behave similarly
1281        # allowed to filter to nothing to make empty dataset
1282        index = filter_index(index, k, v)
1283
1284    if len(index) == 0:
1285        return index, list(), dict(), dict()
1286
1287    dim_coords = dict()  # key=name of dim, value=list of coord names
1288    attrs = dict()
1289    coord_attrs = dict()
1290
1291    # expand index
1292    index = index.assign(shortName=index.msg.apply(lambda msg: msg.shortName))
1293    index = index.assign(nx=index.msg.apply(lambda msg: msg.nx))
1294    index = index.assign(ny=index.msg.apply(lambda msg: msg.ny))
1295    index = index.astype({"ny": "int", "nx": "int"})
1296
1297    # apply common filters(to all definition templates) to reduce dataset to
1298    # single cube
1299    # ensure only one of each of the below exists after filters applied
1300    required_uniques = [
1301        "productDefinitionTemplateNumber",
1302        "typeOfGeneratingProcess",
1303        "typeOfFirstFixedSurface",
1304        "typeOfSecondFixedSurface",
1305    ]
1306
1307    def meta_check(index, attrs, meta):
1308        """
1309        add meta to the datframe index
1310        check that there is a single type
1311        add the type to attrs
1312
1313        returns index, attrs
1314        """
1315        index = index.assign(**{meta: index.msg.apply(lambda msg: getattr(msg, meta))})
1316
1317        unique = index[meta].unique()
1318        if len(index[meta].unique()) > 1:
1319            raise ValueError(f"filter to a single {meta}; found: {[str(i) for i in unique]}")
1320        value = unique.item()
1321        if isinstance(value, grib2io.templates.Grib2Metadata):
1322            value = value.definition
1323
1324        # None is returned if no value found,
1325        # check and change to string None
1326        if value is None:
1327            value = "None"
1328
1329        attrs[meta] = value
1330        return index, attrs
1331
1332    for meta in required_uniques:
1333        index, attrs = meta_check(index, attrs, meta)
1334
1335    pdtn = index.productDefinitionTemplateNumber.iloc[0].value
1336
1337    # determine which non geo dimensions can be created from data by this point
1338    # the index is filtered down to a single type for all required_uniques
1339
1340    # Dim Name     # matching dim_name for using this data as index coordinate
1341    dim_coords["refDate"] = ["refDate"]
1342    coord_attrs["refDate"] = dict(standard_name="forecast_reference_time")
1343    #   dim_coords["refDate"] = ["refDate", "hour"] # non dim name matching items in list are used as non-index coordinates
1344
1345    dim_coords["leadTime"] = ["leadTime"]
1346    coord_attrs["leadTime"] = dict(standard_name="forecast_period")
1347
1348    if "valueOfFirstFixedSurface" not in index.columns:
1349        index = index.assign(valueOfFirstFixedSurface=index.msg.apply(lambda msg: msg.valueOfFirstFixedSurface))
1350    if "valueOfsecondFixedSurface" not in index.columns:
1351        index = index.assign(valueOfSecondFixedSurface=index.msg.apply(lambda msg: msg.valueOfSecondFixedSurface))
1352
1353    # dim name api change, user could run ds = ds.swap_dims(fixedSurface="valueOfFirstFixedSurface")
1354    index = index.assign(level=list(zip(index["valueOfFirstFixedSurface"], index["valueOfSecondFixedSurface"])))
1355    #   index = index.assign(level=index.msg.apply(lambda msg: msg.level))
1356    # lack of "level" indeicates don't create extra index coordinate "level"
1357    dim_coords["level"] = ["valueOfFirstFixedSurface", "valueOfSecondFixedSurface"]
1358
1359    # logic for parsing possible dims from specific product definition section
1360
1361    if pdtn in {5, 9}:
1362        # Probability forecasts at a horizontal level or in a horizontal layer
1363        # in a continuous or non-continuous time interval.  (see Template
1364        # 4.9)
1365        #       AVAILABLE_THRESHOLD = {
1366        #           0: {'has_lower': True, 'has_upper': False},
1367        #           1: {'has_lower': False, 'has_upper': True},
1368        #           2: {'has_lower': True, 'has_upper': True},
1369        #           3: {'has_lower': True, 'has_upper': False},
1370        #           4: {'has_lower': False, 'has_upper': True},
1371        #           5: {'has_lower': True, 'has_upper': False},
1372        #       }
1373
1374        index, attrs = meta_check(index, attrs, "typeOfProbability")
1375        if "thresholdLowerLimit" not in index.columns:
1376            index = index.assign(thresholdLowerLimit=index.msg.apply(lambda msg: msg.thresholdLowerLimit))
1377        if "thresholdUpperLimit" not in index.columns:
1378            index = index.assign(thresholdUpperLimit=index.msg.apply(lambda msg: msg.thresholdUpperLimit))
1379        if "threshold" not in index.columns:
1380            # 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
1381            index = index.assign(threshold=list(zip(index["thresholdLowerLimit"], index["thresholdUpperLimit"])))
1382        #           index = index.assign(threshold = index.msg.apply(lambda msg: msg.threshold))
1383
1384        # ommiting threshold results in no index being assigned for this possible dim
1385        dim_coords["threshold"] = ["thresholdLowerLimit", "thresholdUpperLimit"]
1386
1387    if pdtn in {6, 10}:
1388        # Percentile forecasts at a horizontal level or in a horizontal layer
1389        # in a continuous or non-continuous time interval.  (see Template
1390        # 4.10)
1391        dim_coords["percentileValue"] = ["percentileValue"]
1392        coord_attrs["percentileValue"] = dict(long_name="percentile", units="percent")
1393
1394    if pdtn in {
1395        8,
1396        9,
1397        10,
1398        11,
1399        12,
1400        13,
1401        14,
1402        42,
1403        43,
1404        45,
1405        46,
1406        47,
1407        61,
1408        62,
1409        63,
1410        67,
1411        68,
1412        72,
1413        73,
1414        78,
1415        79,
1416        82,
1417        83,
1418        84,
1419        85,
1420        87,
1421        91,
1422    }:
1423        dim_coords["duration"] = ["duration"]
1424
1425    if pdtn in {
1426        1,
1427        11,
1428        33,
1429        34,
1430        41,
1431        43,
1432        45,
1433        47,
1434        49,
1435        54,
1436        56,
1437        58,
1438        59,
1439        63,
1440        68,
1441        77,
1442        79,
1443        81,
1444        83,
1445        84,
1446        85,
1447        92,
1448    }:
1449        dim_coords["perturbationNumber"] = ["perturbationNumber"]
1450
1451    if pdtn in {2, 3, 4, 12, 13, 14}:
1452        index, attrs = meta_check(index, attrs, "typeOfDerivedForecast")
1453
1454    if pdtn in {5, 9}:
1455        dim_coords["typeOfProbability"] = ["typeOfProbability"]
1456
1457    if pdtn in {6, 10}:
1458        dim_coords["percentileValue"] = ["percentileValue"]
1459
1460    if pdtn in {8, 15, 42, 46, 62, 67, 72, 78, 82, 1001, 1002, 1100, 1101}:
1461        index, attrs = meta_check(index, attrs, "statisticalProcess")
1462
1463    # Logic for Trace Gas and Aerosol dimensions
1464    if pdtn in {40, 41, 42, 43, 76, 77, 78, 79}:
1465        dim_coords["constituentType"] = ["constituentType"]
1466
1467    if pdtn in {76, 77, 78, 79}:
1468        dim_coords["sourceSinkIndicator"] = ["sourceSinkIndicator"]
1469
1470    if pdtn in {44, 45, 46, 47, 48, 49, 50, 80, 81, 82, 83, 84, 85}:
1471        dim_coords["typeOfAerosol"] = ["typeOfAerosol"]
1472
1473    if pdtn in {80, 81, 82, 83, 84}:
1474        dim_coords["sourceSinkIndicator"] = ["sourceSinkIndicator"]
1475
1476    if pdtn in {48, 49, 80, 81}:
1477        dim_coords["firstWavelength"] = ["firstWavelength"]
1478        dim_coords["secondWavelength"] = ["secondWavelength"]
1479        dim_coords["firstSizeOfAerosol"] = ["firstSizeOfAerosol"]
1480        dim_coords["secondSizeOfAerosol"] = ["secondSizeOfAerosol"]
1481
1482    # Finish logic by pdtn
1483
1484    for k, v in dim_coords.items():
1485        for meta in v:
1486            if meta not in index.columns:
1487                index = index.assign(**{meta: index.msg.apply(lambda msg: getattr(msg, meta))})
1488
1489    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: str, *, drop_variables: Optional[List[str]] = None, filters: Optional[Mapping[str, Any]] = None, engine: str = 'grib2io', chunks: Union[int, Dict[Any, Any], Literal['auto'], NoneType] = None, **kwargs) -> Any:
1493def open_datatree(
1494    filename: str,
1495    *,
1496    drop_variables: typing.Optional[typing.List[str]] = None,
1497    filters: typing.Optional[typing.Mapping[str, typing.Any]] = None,
1498    engine: str = "grib2io",
1499    chunks: typing.Optional[typing.Union[int, typing.Dict[typing.Any, typing.Any], typing.Literal["auto"]]] = None,
1500    **kwargs,
1501) -> typing.Any:
1502    """
1503    Open a GRIB2 file as an xarray DataTree.
1504
1505    Parameters
1506    ----------
1507    filename : str
1508        Path to the GRIB2 file.
1509    drop_variables : list, optional
1510        List of variables to exclude.
1511    filters : dict, optional
1512        Filter criteria for GRIB2 messages.
1513    engine : str, optional
1514        Engine to use for opening the file, defaults to "grib2io".
1515    chunks : int, dict or 'auto', optional
1516        If chunks is provided, it is used to load the dataset into a
1517        dask-backed dataset.
1518    **kwargs : optional
1519        Additional keyword arguments passed to the xarray backend.
1520
1521    Returns
1522    -------
1523    xarray.DataTree
1524        A hierarchical DataTree representation of the GRIB2 data.
1525    """
1526    if not _HAS_DATATREE:
1527        raise ImportError("xarray version does not support DataTree functionality.")
1528
1529    if filters is None:
1530        filters = {}
1531
1532    # Open the file without any filters first to get all messages
1533    with grib2io.open(filename, _xarray_backend=True) as f:
1534        file_index = pd.DataFrame(f._index)
1535        file_index = file_index.assign(msg=msgs_from_index(f._index))
1536
1537    # Build tree structure from GRIB messages
1538    root = build_datatree_from_grib(
1539        filename,
1540        file_index,
1541        filters,
1542        drop_variables=drop_variables,
1543        chunks=chunks,
1544    )
1545
1546    # Update history for provenance
1547    now = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
1548    existing_history = root.attrs.get("history", "") if hasattr(root, "attrs") else ""
1549    history = f"{now}: Initialized via grib2io.open_datatree from {filename}\n{existing_history}"
1550    if hasattr(root, "attrs"):
1551        root.attrs["history"] = history
1552    # Also add to all datasets in the tree
1553    for node in root.subtree:
1554        if node.ds is not None:
1555            node.ds.attrs["history"] = history + node.ds.attrs.get("history", "")
1556
1557    return root
1558
1559    return root

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.
  • engine (str, optional): Engine to use for opening the file, defaults to "grib2io".
  • chunks (int, dict or 'auto', optional): If chunks is provided, it is used to load the dataset into a dask-backed dataset.
  • **kwargs (optional): Additional keyword arguments passed to the xarray backend.
Returns
  • xarray.DataTree: A hierarchical DataTree representation of the GRIB2 data.
def build_da_without_coords( index: pandas.core.frame.DataFrame, cube: dict, filename: str, attrs: dict) -> xarray.core.dataarray.DataArray:
1562def build_da_without_coords(index: pd.DataFrame, cube: dict, filename: str, attrs: dict) -> xr.DataArray:
1563    """
1564    Build a DataArray without coordinates from a cube of grib2 messages.
1565
1566    Parameters
1567    ----------
1568    index : pd.DataFrame
1569        Index of cube.
1570    cube : dict
1571        Cube of grib2 messages.
1572    filename : str
1573        Filename of grib2 file.
1574    attrs : dict
1575        Attributes for the DataArray.
1576
1577    Returns
1578    -------
1579    xr.DataArray
1580        DataArray without coordinates.
1581    """
1582
1583    dim_names = [k for k in cube.keys() if cube[k] is not None and len(cube[k]) > 1]
1584    constant_meta_names = [k for k in cube.keys() if cube[k] is None]
1585    dims = {k: len(cube[k]) for k in dim_names}
1586
1587    # guard against bad datarrays being formed
1588    dims_total = 1
1589    dims_to_filter = []
1590    for (
1591        dim_name,
1592        dim_len,
1593    ) in dims.items():
1594        if dim_name not in {"x", "y", "station"}:
1595            dims_total *= dim_len
1596            dims_to_filter.append(dim_name)
1597
1598    # Check number of GRIB2 message indexed compared to non-X/Y
1599    # dimensions.
1600    if dims_total != len(index):
1601        raise ValueError(
1602            f"DataArray dimensions are not compatible with number of GRIB2 messages; DataArray has {dims_total} "
1603            f"and GRIB2 index has {len(index)}. Consider applying a filter for dimensions: {dims_to_filter}"
1604        )
1605
1606    data = OnDiskArray(filename, index, cube)
1607    lock = _LOCK
1608    data = GribBackendArray(data, lock)
1609    data = indexing.LazilyIndexedArray(data)
1610    if len(dim_names) != len(data.shape):
1611        raise ValueError(
1612            "different number of dimensions on data "
1613            f"and dims: {len(data.shape)} vs {len(dim_names)}\n"
1614            "Grib2 messages could not be formed into a data cube; "
1615            "It's possible extra messages exist along a non-accounted for dimension based on PDTN\n"
1616            "It might be possible to get around this by applying a filter on the non-accounted for dimension"
1617        )
1618    da = xr.DataArray(data, dims=dim_names)
1619
1620    da.encoding["original_shape"] = data.shape
1621
1622    da.encoding["preferred_chunks"] = {"y": -1, "x": -1}
1623    msg1 = index.msg.iloc[0]
1624
1625    # plain language metadata is minimized
1626    # add grib section metadata
1627    da.attrs["GRIB2IO_section0"] = msg1.section0
1628    da.attrs["GRIB2IO_section1"] = msg1.section1
1629    da.attrs["GRIB2IO_section2"] = msg1.section2 if msg1.section2 else []
1630    da.attrs["GRIB2IO_section3"] = msg1.section3
1631    da.attrs["GRIB2IO_section4"] = msg1.section4
1632    da.attrs["GRIB2IO_section5"] = msg1.section5
1633    da.attrs["fullName"] = str(msg1.fullName)
1634    da.attrs["shortName"] = str(msg1.shortName)
1635    da.attrs["units"] = str(msg1.units)
1636    da.attrs["originatingCenter"] = str(msg1.originatingCenter.definition)
1637    da.attrs["originatingSubCenter"] = str(msg1.originatingSubCenter.definition)
1638
1639    # add master table
1640    da.attrs["masterTableInfo"] = str(msg1.masterTableInfo.definition)
1641
1642    da.name = index.shortName.iloc[0]
1643    for meta_name in constant_meta_names:
1644        if meta_name in index.columns:
1645            da.attrs[meta_name] = index[meta_name].iloc[0]
1646
1647    da.attrs.update(attrs)
1648
1649    return da

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

Parameters
  • index (pd.DataFrame): Index of cube.
  • cube (dict): Cube of grib2 messages.
  • filename (str): Filename of grib2 file.
  • attrs (dict): Attributes for the DataArray.
Returns
  • xr.DataArray: DataArray without coordinates.
def assign_xr_meta( ds: xarray.core.dataset.Dataset, frames: List[pandas.core.frame.DataFrame], cube: dict, non_geo_dims: Dict[str, List[str]], extra_geo: dict, coord_attrs: Dict[str, dict]) -> xarray.core.dataset.Dataset:
1652def assign_xr_meta(
1653    ds: xr.Dataset,
1654    frames: typing.List[pd.DataFrame],
1655    cube: dict,
1656    non_geo_dims: typing.Dict[str, typing.List[str]],
1657    extra_geo: dict,
1658    coord_attrs: typing.Dict[str, dict],
1659) -> xr.Dataset:
1660    """
1661    Assign coordinates and attributes to the dataset.
1662
1663    Parameters
1664    ----------
1665    ds : xr.Dataset
1666        The dataset to update.
1667    frames : list of pd.DataFrame
1668        The dataframes for each variable.
1669    cube : dict
1670        The dimensions cube.
1671    non_geo_dims : dict
1672        The non-geographic dimensions.
1673    extra_geo : dict
1674        Extra geographic coordinates.
1675    coord_attrs : dict
1676        Attributes for coordinates.
1677
1678    Returns
1679    -------
1680    xr.Dataset
1681        The updated dataset.
1682    """
1683    df = frames[0]
1684
1685    # assign extra geo coords
1686    ds = ds.assign_coords(extra_geo)
1687    # add crs data from first grib message to each data variable and the dataset
1688    geo_attrs = {
1689        "crs_wkt": CRS.from_dict(df.msg.iloc[0].projParameters).to_wkt(),
1690        "gridlengthXDirection": df.msg.iloc[0].gridlengthXDirection,
1691        "gridlengthYDirection": df.msg.iloc[0].gridlengthYDirection,
1692        "latitudeFirstGridpoint": df.msg.iloc[0].latitudeFirstGridpoint,
1693        "longitudeFirstGridpoint": df.msg.iloc[0].longitudeFirstGridpoint,
1694    }
1695    for data_var in ds.data_vars:
1696        ds[data_var].attrs.update(geo_attrs)
1697    ds.attrs.update(geo_attrs)
1698
1699    # add coordinate specific attributes
1700    for coord, attrs in coord_attrs.items():
1701        ds[coord].attrs.update(attrs)
1702
1703    # assign valid date coords
1704    try:
1705        ds = ds.assign_coords(dict(validDate=ds.coords["refDate"] + ds.coords["leadTime"]))
1706        ds.validDate.attrs["standard_name"] = "time"
1707        ds.validDate.attrs["long_name"] = "time"
1708    except Exception as e:
1709        warnings.warn(f"could not parse validTime: {e}")
1710
1711    # assign attributes
1712    ds.attrs["engine"] = "grib2io"
1713
1714    return ds

Assign coordinates and attributes to the dataset.

Parameters
  • ds (xr.Dataset): The dataset to update.
  • frames (list of pd.DataFrame): The dataframes for each variable.
  • cube (dict): The dimensions cube.
  • non_geo_dims (dict): The non-geographic dimensions.
  • extra_geo (dict): Extra geographic coordinates.
  • coord_attrs (dict): Attributes for coordinates.
Returns
  • xr.Dataset: The updated dataset.
def make_variables( index: pandas.core.frame.DataFrame, f: str, non_geo_dims: Dict[str, List[str]], allow_uneven_dims: bool = False) -> Tuple[Optional[List[pandas.core.frame.DataFrame]], Optional[List[dict]], Optional[dict]]:
1717def make_variables(
1718    index: pd.DataFrame,
1719    f: str,
1720    non_geo_dims: typing.Dict[str, typing.List[str]],
1721    allow_uneven_dims: bool = False,
1722) -> typing.Tuple[
1723    typing.Optional[typing.List[pd.DataFrame]],
1724    typing.Optional[typing.List[dict]],
1725    typing.Optional[dict],
1726]:
1727    """
1728    Create an individual dataframe index and cube for each variable.
1729
1730    Parameters
1731    ----------
1732    index : pd.DataFrame
1733        Index of messages.
1734    f : str
1735        Filename.
1736    non_geo_dims : dict
1737        Dimensions not associated with the x,y grid.
1738    allow_uneven_dims : bool, optional
1739        If True, allows uneven dimensions (used for DataTree creation).
1740
1741    Returns
1742    -------
1743    ordered_frames : list of pd.DataFrame, optional
1744        List of dataframes, one for each variable.
1745    cubes : list of dict, optional
1746        List of cubes, one for each variable.
1747    extra_geo : dict, optional
1748        Extra geographic coordinates.
1749    """
1750    # let shortName determine the variables
1751
1752    # set the index to the name
1753    index = index.set_index("shortName").sort_index()
1754    # return nothing if no data
1755    if index.empty:
1756        return None, None, None
1757
1758    # define the DimCube
1759    dims = copy(non_geo_dims)
1760
1761    ordered_meta = list(non_geo_dims.keys())
1762    cubes = list()
1763    ordered_frames = list()
1764    for key in index.index.unique():
1765        frame = index.loc[[key]]
1766        frame = frame.reset_index()
1767        # frame is a dataframe with all records for one variable
1768        c = dict()
1769        # for colname in frame.columns:
1770        for colname in ordered_meta:
1771            uniques = pd.Index(frame[colname]).unique()
1772            if len(uniques) > 1:
1773                c[colname] = uniques.sort_values()
1774            else:
1775                c[colname] = [uniques[0]]
1776
1777        dims = [k for k in ordered_meta if len(c[k]) > 1]
1778
1779        for dim in dims:
1780            if frame[dim].value_counts().nunique() > 1 and not allow_uneven_dims:
1781                raise ValueError(f"uneven number of grib msgs associated with dimension: {dim}\n unique values for {dim}: {frame[dim].unique()} ")
1782
1783        if len(dims) >= 1:  # dims may be empty if no extra dims on top of x,y
1784            frame = frame.sort_values(dims)
1785            frame = frame.set_index(dims)
1786
1787        cubes.append(c)
1788
1789        # miloc is multi-index integer location of msg in nd DataArray
1790        miloc = list(zip(*[frame.index.unique(level=dim).get_indexer(frame.index.get_level_values(dim)) for dim in dims]))
1791
1792        # set frame multi index
1793        if len(miloc) >= 1:  # miloc will be empty when no extra dims, thus no multiindex
1794            dim_ix = tuple([n + "_ix" for n in dims])
1795            frame = frame.set_index(pd.MultiIndex.from_tuples(miloc, names=dim_ix))
1796
1797        ordered_frames.append(frame)
1798
1799    # no variables
1800    if not cubes:
1801        cubes = [dict()]
1802
1803    # check geography of data and assign to cube
1804    if len(index.ny.unique()) > 1 or len(index.nx.unique()) > 1:
1805        raise ValueError("multiple grids not accommodated")
1806    for cube in cubes:
1807        cube["y"] = range(int(index.ny.iloc[0]))
1808        cube["x"] = range(int(index.nx.iloc[0]))
1809
1810    extra_geo = None
1811    msg = index.msg.iloc[0]
1812
1813    # we want the lat lons; make them via accessing a record; we are assuming
1814    # all records are the same grid because they have the same shape;
1815    # may want a unique grid identifier from grib2io to avoid assuming this
1816    latitude, longitude = msg.latlons()
1817    latitude = xr.DataArray(latitude, dims=["y", "x"])
1818    latitude.attrs["standard_name"] = "latitude"
1819    latitude.attrs["units"] = "degrees_north"
1820    longitude = xr.DataArray(longitude, dims=["y", "x"])
1821    longitude.attrs["standard_name"] = "longitude"
1822    longitude.attrs["units"] = "degrees_east"
1823    extra_geo = dict(latitude=latitude, longitude=longitude)
1824
1825    return ordered_frames, cubes, extra_geo

Create an individual dataframe index and cube for each variable.

Parameters
  • index (pd.DataFrame): Index of messages.
  • f (str): Filename.
  • non_geo_dims (dict): Dimensions not associated with the x,y grid.
  • allow_uneven_dims (bool, optional): If True, allows uneven dimensions (used for DataTree creation).
Returns
  • ordered_frames (list of pd.DataFrame, optional): List of dataframes, one for each variable.
  • cubes (list of dict, optional): List of cubes, one for each variable.
  • extra_geo (dict, optional): Extra geographic coordinates.
def interp_nd( a: numpy.ndarray, *, method: Union[str, int], grid_def_in: grib2io.Grib2GridDef, grid_def_out: grib2io.Grib2GridDef, method_options: Optional[List[int]] = None, num_threads: int = 1) -> numpy.ndarray:
1828def interp_nd(
1829    a: np.ndarray,
1830    *,
1831    method: typing.Union[str, int],
1832    grid_def_in: grib2io.Grib2GridDef,
1833    grid_def_out: grib2io.Grib2GridDef,
1834    method_options: typing.Optional[typing.List[int]] = None,
1835    num_threads: int = 1,
1836) -> np.ndarray:
1837    """
1838    Perform multi-dimensional interpolation on a horizontal grid.
1839
1840    This function reshapes the input array to (N, ny, nx) before performing
1841    interpolation and then reshapes it back to its original dimensions plus
1842    the new grid dimensions.
1843
1844    Parameters
1845    ----------
1846    a : np.ndarray
1847        Input array with horizontal dimensions (..., ny, nx).
1848    method : str or int
1849        Interpolation method.
1850    grid_def_in : grib2io.Grib2GridDef
1851        Input grid definition.
1852    grid_def_out : grib2io.Grib2GridDef
1853        Output grid definition.
1854    method_options : list of int, optional
1855        Interpolation options.
1856    num_threads : int, optional
1857        Number of threads for parallel interpolation.
1858
1859    Returns
1860    -------
1861    np.ndarray
1862        Interpolated array with horizontal dimensions of the output grid.
1863    """
1864    front_shape = a.shape[:-2]
1865    a = a.reshape(-1, a.shape[-2], a.shape[-1])
1866    a = grib2io.interpolate(
1867        a,
1868        method,
1869        grid_def_in,
1870        grid_def_out,
1871        method_options=method_options,
1872        num_threads=num_threads,
1873    )
1874    a = a.reshape(front_shape + (a.shape[-2], a.shape[-1]))
1875    return a

Perform multi-dimensional interpolation on a horizontal grid.

This function reshapes the input array to (N, ny, nx) before performing interpolation and then reshapes it back to its original dimensions plus the new grid dimensions.

Parameters
  • a (np.ndarray): Input array with horizontal dimensions (..., ny, nx).
  • method (str or int): Interpolation method.
  • grid_def_in (grib2io.Grib2GridDef): Input grid definition.
  • grid_def_out (grib2io.Grib2GridDef): Output grid definition.
  • method_options (list of int, optional): Interpolation options.
  • num_threads (int, optional): Number of threads for parallel interpolation.
Returns
  • np.ndarray: Interpolated array with horizontal dimensions of the output grid.
def interp_nd_stations( a: numpy.ndarray, *, method: Union[str, int], grid_def_in: grib2io.Grib2GridDef, lats: Sequence[float], lons: Sequence[float], method_options: Optional[List[int]] = None, num_threads: int = 1) -> numpy.ndarray:
1878def interp_nd_stations(
1879    a: np.ndarray,
1880    *,
1881    method: typing.Union[str, int],
1882    grid_def_in: grib2io.Grib2GridDef,
1883    lats: typing.Sequence[float],
1884    lons: typing.Sequence[float],
1885    method_options: typing.Optional[typing.List[int]] = None,
1886    num_threads: int = 1,
1887) -> np.ndarray:
1888    """
1889    Perform multi-dimensional interpolation to station points.
1890
1891    This function reshapes the input array to (N, ny, nx) before performing
1892    interpolation and then reshapes it back to its original dimensions plus
1893    the station dimension.
1894
1895    Parameters
1896    ----------
1897    a : np.ndarray
1898        Input array with horizontal dimensions (..., ny, nx).
1899    method : str or int
1900        Interpolation method.
1901    grid_def_in : grib2io.Grib2GridDef
1902        Input grid definition.
1903    lats : sequence of float
1904        Station latitudes.
1905    lons : sequence of float
1906        Station longitudes.
1907    method_options : list of int, optional
1908        Interpolation options.
1909    num_threads : int, optional
1910        Number of threads for parallel interpolation.
1911
1912    Returns
1913    -------
1914    np.ndarray
1915        Interpolated array with the last dimension representing stations.
1916    """
1917    front_shape = a.shape[:-2]
1918    a = a.reshape(-1, a.shape[-2], a.shape[-1])
1919    a = grib2io.interpolate_to_stations(
1920        a,
1921        method,
1922        grid_def_in,
1923        lats,
1924        lons,
1925        method_options=method_options,
1926        num_threads=num_threads,
1927    )
1928    a = a.reshape(front_shape + (len(lats),))
1929    return a

Perform multi-dimensional interpolation to station points.

This function reshapes the input array to (N, ny, nx) before performing interpolation and then reshapes it back to its original dimensions plus the station dimension.

Parameters
  • a (np.ndarray): Input array with horizontal dimensions (..., ny, nx).
  • method (str or int): Interpolation method.
  • grid_def_in (grib2io.Grib2GridDef): Input grid definition.
  • lats (sequence of float): Station latitudes.
  • lons (sequence of float): Station longitudes.
  • method_options (list of int, optional): Interpolation options.
  • num_threads (int, optional): Number of threads for parallel interpolation.
Returns
  • np.ndarray: Interpolated array with the last dimension representing stations.
@xr.register_dataset_accessor('grib2io')
class Grib2ioDataSet:
1932@xr.register_dataset_accessor("grib2io")
1933class Grib2ioDataSet:
1934    def __init__(self, xarray_obj):
1935        self._obj = xarray_obj
1936
1937    def griddef(self):
1938        return Grib2GridDef.from_section3(self._obj[list(self._obj.data_vars)[0]].attrs["GRIB2IO_section3"])
1939
1940    def interp(
1941        self,
1942        method: typing.Union[str, int],
1943        grid_def_out: grib2io.Grib2GridDef,
1944        method_options: typing.Optional[typing.List[int]] = None,
1945        num_threads: int = 1,
1946    ) -> xr.Dataset:
1947        """
1948        Perform grid spatial interpolation on all variables in the Dataset.
1949
1950        Parameters
1951        ----------
1952        method : str or int
1953            Interpolation method.
1954        grid_def_out : grib2io.Grib2GridDef
1955            Output grid definition.
1956        method_options : list of int, optional
1957            Interpolation options.
1958        num_threads : int, optional
1959            Number of threads.
1960
1961        Returns
1962        -------
1963        xarray.Dataset
1964            Interpolated dataset.
1965        """
1966        da = self._obj.to_array()
1967        da.attrs["GRIB2IO_section3"] = self._obj[list(self._obj.data_vars)[0]].attrs["GRIB2IO_section3"]
1968        da = da.grib2io.interp(method, grid_def_out, method_options=method_options, num_threads=num_threads)
1969        ds = da.to_dataset(dim="variable")
1970
1971        # Update history for provenance
1972        history = ds.attrs.get("history", "")
1973        now = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
1974        ds.attrs["history"] = f"{now}: Interpolated via {method} to {grid_def_out}\n{history}"
1975
1976        return ds
1977
1978    def interp_to_stations(
1979        self,
1980        method: typing.Union[str, int],
1981        calls: typing.Sequence[str],
1982        lats: typing.Sequence[float],
1983        lons: typing.Sequence[float],
1984        method_options: typing.Optional[typing.List[int]] = None,
1985        num_threads: int = 1,
1986    ) -> xr.Dataset:
1987        """
1988        Perform spatial interpolation to station points on all variables.
1989
1990        Parameters
1991        ----------
1992        method : str or int
1993            Interpolation method.
1994        calls : sequence of str
1995            Station call signs.
1996        lats : sequence of float
1997            Station latitudes.
1998        lons : sequence of float
1999            Station longitudes.
2000        method_options : list of int, optional
2001            Interpolation options.
2002        num_threads : int, optional
2003            Number of threads.
2004
2005        Returns
2006        -------
2007        xarray.Dataset
2008            Dataset interpolated to stations.
2009        """
2010        da = self._obj.to_array()
2011        da.attrs["GRIB2IO_section3"] = self._obj[list(self._obj.data_vars)[0]].attrs["GRIB2IO_section3"]
2012        da = da.grib2io.interp_to_stations(
2013            method,
2014            calls,
2015            lats,
2016            lons,
2017            method_options=method_options,
2018            num_threads=num_threads,
2019        )
2020        ds = da.to_dataset(dim="variable")
2021
2022        # Update history for provenance
2023        history = ds.attrs.get("history", "")
2024        now = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
2025        ds.attrs["history"] = f"{now}: Interpolated to {len(calls)} stations via {method}\n{history}"
2026
2027        return ds
2028
2029    def to_grib2(self, filename, mode: typing.Literal["x", "w", "a"] = "x"):
2030        """
2031        Write a DataSet to a grib2 file.
2032
2033        Parameters
2034        ----------
2035        filename
2036            Name of the grib2 file to write to.
2037        mode: {"x", "w", "a"}, optional, default="x"
2038            Persistence mode
2039
2040            | mode | Description                       |
2041            | :---:| :---:                             |
2042            | 'x'  | create (fail if exists)           |
2043            | 'w'  | create (overwrite if exists)      |
2044            | 'a'  | append (create if does not exist) |
2045
2046        """
2047        ds = self._obj
2048
2049        for shortName in sorted(ds):
2050            # make a DataArray from the "Data Variables" in the DataSet
2051            da = ds[shortName]
2052
2053            da.grib2io.to_grib2(filename, mode=mode)
2054            mode = "a"
2055
2056    def update_attrs(self, **kwargs):
2057        """
2058        Raises an error because Datasets don't have a .attrs attribute.
2059
2060        Parameters
2061        ----------
2062        attrs
2063            Attributes to update.
2064        """
2065        raise ValueError(f"Datasets do not have a .attrs attribute; use .grib2io.update_attrs({kwargs}) on a DataArray instead.")
2066
2067    def subset(self, *, lats=None, lons=None) -> xr.Dataset:
2068        """
2069        Subset the Dataset to a box defined by latitudes and/or longitudes.
2070
2071        Parameters
2072        ----------
2073        lats
2074            Two item list or tuple of latitudes.  Default is None which will
2075            return a subset unbounded by latitude.  The first term defines the
2076            southern boundary and the second term defines the northern
2077            boundary.
2078        lons
2079            Two item list or tuple of longitudes.  Default is None which will
2080            return a subset unbounded by longitude.  The first term defines the
2081            western boundary and the second term defines the eastern
2082            boundary.  Can follow either: 0 to 360 postive eastward, or 0 to
2083            -180 westward / 0 to 180 eastward conventions.  The longitude
2084            boundaries cannot cross 0.
2085
2086        Returns
2087        -------
2088        subset
2089            Dataset subset to the bounding box created by input 'lats'/'lons'.
2090            All gridpoints with lat/lon matching contraints are included within
2091            subset.
2092        """
2093        ds = self._obj
2094
2095        newds = xr.Dataset()
2096        for shortName in ds:
2097            newds[shortName] = ds[shortName].grib2io.subset(lats=lats, lons=lons).copy()
2098
2099        # Update history for provenance
2100        history = newds.attrs.get("history", "")
2101        now = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
2102        newds.attrs["history"] = f"{now}: Subsetted to lats={lats}, lons={lons}\n{history}"
2103
2104        return newds
2105
2106    def compute(self, **kwargs):
2107        """
2108        Compute the Dask-backed Dataset with retries for transient errors.
2109
2110        Wraps :func:`grib2io.utils.compute_with_retries`.
2111
2112        Parameters
2113        ----------
2114        **kwargs
2115            Arguments passed to :func:`grib2io.utils.compute_with_retries`,
2116            e.g., `max_attempts` or `base_sleep`.
2117
2118        Returns
2119        -------
2120        xarray.Dataset
2121            The computed Dataset with NumPy-backed data.
2122        """
2123        from .utils import compute_with_retries
2124
2125        return compute_with_retries(self._obj, **kwargs)
Grib2ioDataSet(xarray_obj)
1934    def __init__(self, xarray_obj):
1935        self._obj = xarray_obj
def griddef(self):
1937    def griddef(self):
1938        return Grib2GridDef.from_section3(self._obj[list(self._obj.data_vars)[0]].attrs["GRIB2IO_section3"])
def interp( self, method: Union[str, int], grid_def_out: grib2io.Grib2GridDef, method_options: Optional[List[int]] = None, num_threads: int = 1) -> xarray.core.dataset.Dataset:
1940    def interp(
1941        self,
1942        method: typing.Union[str, int],
1943        grid_def_out: grib2io.Grib2GridDef,
1944        method_options: typing.Optional[typing.List[int]] = None,
1945        num_threads: int = 1,
1946    ) -> xr.Dataset:
1947        """
1948        Perform grid spatial interpolation on all variables in the Dataset.
1949
1950        Parameters
1951        ----------
1952        method : str or int
1953            Interpolation method.
1954        grid_def_out : grib2io.Grib2GridDef
1955            Output grid definition.
1956        method_options : list of int, optional
1957            Interpolation options.
1958        num_threads : int, optional
1959            Number of threads.
1960
1961        Returns
1962        -------
1963        xarray.Dataset
1964            Interpolated dataset.
1965        """
1966        da = self._obj.to_array()
1967        da.attrs["GRIB2IO_section3"] = self._obj[list(self._obj.data_vars)[0]].attrs["GRIB2IO_section3"]
1968        da = da.grib2io.interp(method, grid_def_out, method_options=method_options, num_threads=num_threads)
1969        ds = da.to_dataset(dim="variable")
1970
1971        # Update history for provenance
1972        history = ds.attrs.get("history", "")
1973        now = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
1974        ds.attrs["history"] = f"{now}: Interpolated via {method} to {grid_def_out}\n{history}"
1975
1976        return ds

Perform grid spatial interpolation on all variables in the Dataset.

Parameters
  • method (str or int): Interpolation method.
  • grid_def_out (grib2io.Grib2GridDef): Output grid definition.
  • method_options (list of int, optional): Interpolation options.
  • num_threads (int, optional): Number of threads.
Returns
  • xarray.Dataset: Interpolated dataset.
def interp_to_stations( self, method: Union[str, int], calls: Sequence[str], lats: Sequence[float], lons: Sequence[float], method_options: Optional[List[int]] = None, num_threads: int = 1) -> xarray.core.dataset.Dataset:
1978    def interp_to_stations(
1979        self,
1980        method: typing.Union[str, int],
1981        calls: typing.Sequence[str],
1982        lats: typing.Sequence[float],
1983        lons: typing.Sequence[float],
1984        method_options: typing.Optional[typing.List[int]] = None,
1985        num_threads: int = 1,
1986    ) -> xr.Dataset:
1987        """
1988        Perform spatial interpolation to station points on all variables.
1989
1990        Parameters
1991        ----------
1992        method : str or int
1993            Interpolation method.
1994        calls : sequence of str
1995            Station call signs.
1996        lats : sequence of float
1997            Station latitudes.
1998        lons : sequence of float
1999            Station longitudes.
2000        method_options : list of int, optional
2001            Interpolation options.
2002        num_threads : int, optional
2003            Number of threads.
2004
2005        Returns
2006        -------
2007        xarray.Dataset
2008            Dataset interpolated to stations.
2009        """
2010        da = self._obj.to_array()
2011        da.attrs["GRIB2IO_section3"] = self._obj[list(self._obj.data_vars)[0]].attrs["GRIB2IO_section3"]
2012        da = da.grib2io.interp_to_stations(
2013            method,
2014            calls,
2015            lats,
2016            lons,
2017            method_options=method_options,
2018            num_threads=num_threads,
2019        )
2020        ds = da.to_dataset(dim="variable")
2021
2022        # Update history for provenance
2023        history = ds.attrs.get("history", "")
2024        now = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
2025        ds.attrs["history"] = f"{now}: Interpolated to {len(calls)} stations via {method}\n{history}"
2026
2027        return ds

Perform spatial interpolation to station points on all variables.

Parameters
  • method (str or int): Interpolation method.
  • calls (sequence of str): Station call signs.
  • lats (sequence of float): Station latitudes.
  • lons (sequence of float): Station longitudes.
  • method_options (list of int, optional): Interpolation options.
  • num_threads (int, optional): Number of threads.
Returns
  • xarray.Dataset: Dataset interpolated to stations.
def to_grib2(self, filename, mode: Literal['x', 'w', 'a'] = 'x'):
2029    def to_grib2(self, filename, mode: typing.Literal["x", "w", "a"] = "x"):
2030        """
2031        Write a DataSet to a grib2 file.
2032
2033        Parameters
2034        ----------
2035        filename
2036            Name of the grib2 file to write to.
2037        mode: {"x", "w", "a"}, optional, default="x"
2038            Persistence mode
2039
2040            | mode | Description                       |
2041            | :---:| :---:                             |
2042            | 'x'  | create (fail if exists)           |
2043            | 'w'  | create (overwrite if exists)      |
2044            | 'a'  | append (create if does not exist) |
2045
2046        """
2047        ds = self._obj
2048
2049        for shortName in sorted(ds):
2050            # make a DataArray from the "Data Variables" in the DataSet
2051            da = ds[shortName]
2052
2053            da.grib2io.to_grib2(filename, mode=mode)
2054            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):
2056    def update_attrs(self, **kwargs):
2057        """
2058        Raises an error because Datasets don't have a .attrs attribute.
2059
2060        Parameters
2061        ----------
2062        attrs
2063            Attributes to update.
2064        """
2065        raise ValueError(f"Datasets do not have a .attrs attribute; use .grib2io.update_attrs({kwargs}) on a DataArray instead.")

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

Parameters
  • attrs: Attributes to update.
def subset(self, *, lats=None, lons=None) -> xarray.core.dataset.Dataset:
2067    def subset(self, *, lats=None, lons=None) -> xr.Dataset:
2068        """
2069        Subset the Dataset to a box defined by latitudes and/or longitudes.
2070
2071        Parameters
2072        ----------
2073        lats
2074            Two item list or tuple of latitudes.  Default is None which will
2075            return a subset unbounded by latitude.  The first term defines the
2076            southern boundary and the second term defines the northern
2077            boundary.
2078        lons
2079            Two item list or tuple of longitudes.  Default is None which will
2080            return a subset unbounded by longitude.  The first term defines the
2081            western boundary and the second term defines the eastern
2082            boundary.  Can follow either: 0 to 360 postive eastward, or 0 to
2083            -180 westward / 0 to 180 eastward conventions.  The longitude
2084            boundaries cannot cross 0.
2085
2086        Returns
2087        -------
2088        subset
2089            Dataset subset to the bounding box created by input 'lats'/'lons'.
2090            All gridpoints with lat/lon matching contraints are included within
2091            subset.
2092        """
2093        ds = self._obj
2094
2095        newds = xr.Dataset()
2096        for shortName in ds:
2097            newds[shortName] = ds[shortName].grib2io.subset(lats=lats, lons=lons).copy()
2098
2099        # Update history for provenance
2100        history = newds.attrs.get("history", "")
2101        now = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
2102        newds.attrs["history"] = f"{now}: Subsetted to lats={lats}, lons={lons}\n{history}"
2103
2104        return newds

Subset the Dataset to a box defined by latitudes and/or longitudes.

Parameters
  • lats: Two item list or tuple of latitudes. Default is None which will return a subset unbounded by latitude. The first term defines the southern boundary and the second term defines the northern boundary.
  • lons: Two item list or tuple of longitudes. Default is None which will return a subset unbounded by longitude. The first term defines the western boundary and the second term defines the eastern boundary. Can follow either: 0 to 360 postive eastward, or 0 to -180 westward / 0 to 180 eastward conventions. The longitude boundaries cannot cross 0.
Returns
  • subset: Dataset subset to the bounding box created by input 'lats'/'lons'. All gridpoints with lat/lon matching contraints are included within subset.
def compute(self, **kwargs):
2106    def compute(self, **kwargs):
2107        """
2108        Compute the Dask-backed Dataset with retries for transient errors.
2109
2110        Wraps :func:`grib2io.utils.compute_with_retries`.
2111
2112        Parameters
2113        ----------
2114        **kwargs
2115            Arguments passed to :func:`grib2io.utils.compute_with_retries`,
2116            e.g., `max_attempts` or `base_sleep`.
2117
2118        Returns
2119        -------
2120        xarray.Dataset
2121            The computed Dataset with NumPy-backed data.
2122        """
2123        from .utils import compute_with_retries
2124
2125        return compute_with_retries(self._obj, **kwargs)

Compute the Dask-backed Dataset with retries for transient errors.

Wraps grib2io.utils.compute_with_retries().

Parameters
Returns
  • xarray.Dataset: The computed Dataset with NumPy-backed data.
@xr.register_dataarray_accessor('grib2io')
class Grib2ioDataArray:
2128@xr.register_dataarray_accessor("grib2io")
2129class Grib2ioDataArray:
2130    def __init__(self, xarray_obj):
2131        self._obj = xarray_obj
2132
2133    def griddef(self):
2134        return Grib2GridDef.from_section3(self._obj.attrs["GRIB2IO_section3"])
2135
2136    def interp(self, method, grid_def_out, method_options=None, num_threads=1) -> xr.DataArray:
2137        """
2138        Perform grid spatial interpolation.
2139
2140        Uses the [NCEPLIBS-ip library](https://github.com/NOAA-EMC/NCEPLIBS-ip).
2141
2142        Parameters
2143        ----------
2144        method
2145            Interpolate method to use. This can either be an integer or string
2146            using the following mapping:
2147
2148            | Interpolate Scheme | Integer Value |
2149            | :---:              | :---:         |
2150            | 'bilinear'         | 0             |
2151            | 'bicubic'          | 1             |
2152            | 'neighbor'         | 2             |
2153            | 'budget'           | 3             |
2154            | 'spectral'         | 4             |
2155            | 'neighbor-budget'  | 6             |
2156        grid_def_out
2157            Grib2GridDef object of the output grid.
2158        method_options : list of ints, optional
2159            Interpolation options. See the NCEPLIBS-ip documentation for
2160            more information on how these are used.
2161        num_threads : int, optional
2162            Number of OpenMP threads to use for interpolation. The default
2163            value is 1. If grib2io_interp was not built with OpenMP, then
2164            this keyword argument and value will have no impact.
2165
2166        Returns
2167        -------
2168        interp
2169            DataSet interpolated to new grid definition.  The attribute
2170            GRIB2IO_section3 is replaced with the section3 array from the new
2171            grid definition.
2172        """
2173        da = self._obj
2174        # ensure that y, x are rightmost dims; they should be if opening with
2175        # grib2io engine
2176
2177        # gdtn and gdt is not the entirety of the new s3
2178        npoints = grid_def_out.npoints
2179        s3_new = np.array([0, npoints, 0, 0, grid_def_out.gdtn] + list(grid_def_out.gdt))
2180
2181        # make new lat lons
2182        lats, lons = Grib2Message(section3=s3_new, pdtn=0, drtn=0).grid()
2183        latitude = xr.DataArray(lats, dims=["y", "x"])
2184        longitude = xr.DataArray(lons, dims=["y", "x"])
2185
2186        # create new coords
2187        new_coords = dict(da.coords)
2188        del new_coords["latitude"]
2189        del new_coords["longitude"]
2190        new_coords["longitude"] = longitude
2191        new_coords["latitude"] = latitude
2192
2193        # make grid def in from section3 on da.attrs
2194        grid_def_in = self.griddef()
2195
2196        if da.chunks is None:
2197            data = interp_nd(
2198                da.data,
2199                method=method,
2200                grid_def_in=grid_def_in,
2201                grid_def_out=grid_def_out,
2202                method_options=method_options,
2203                num_threads=num_threads,
2204            )
2205        else:
2206            data = da.data.map_blocks(
2207                interp_nd,
2208                method=method,
2209                grid_def_in=grid_def_in,
2210                grid_def_out=grid_def_out,
2211                method_options=method_options,
2212                chunks=da.chunks[:-2] + latitude.shape,
2213                dtype=da.dtype,
2214            )
2215
2216        new_da = xr.DataArray(data, dims=da.dims, coords=new_coords, attrs=da.attrs)
2217
2218        new_da.attrs["GRIB2IO_section3"] = s3_new
2219        new_da.name = da.name
2220
2221        # Update history for provenance
2222        history = new_da.attrs.get("history", "")
2223        now = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
2224        new_da.attrs["history"] = f"{now}: Interpolated via {method} to {grid_def_out}\n{history}"
2225
2226        return new_da
2227
2228    def interp_to_stations(
2229        self,
2230        method: typing.Union[str, int],
2231        calls: typing.Sequence[str],
2232        lats: typing.Sequence[float],
2233        lons: typing.Sequence[float],
2234        method_options: typing.Optional[typing.List[int]] = None,
2235        num_threads: int = 1,
2236    ) -> xr.DataArray:
2237        """
2238        Perform spatial interpolation to station points.
2239
2240        Parameters
2241        ----------
2242        method : str or int
2243            Interpolate method to use. This can either be an integer or string
2244            using the following mapping:
2245
2246            | Interpolate Scheme | Integer Value |
2247            | :---:              | :---:         |
2248            | 'bilinear'         | 0             |
2249            | 'bicubic'          | 1             |
2250            | 'neighbor'         | 2             |
2251            | 'budget'           | 3             |
2252            | 'spectral'         | 4             |
2253            | 'neighbor-budget'  | 6             |
2254
2255        calls : sequence of str
2256            Station calls used for labeling new station index coordinate
2257        lats : sequence of float
2258            Latitudes of the station points.
2259        lons : sequence of float
2260            Longitudes of the station points.
2261        method_options : list of int, optional
2262            Interpolation options.
2263        num_threads : int, optional
2264            Number of threads.
2265
2266        Returns
2267        -------
2268        xarray.DataArray
2269            DataArray interpolated to lat and lon locations and labeled with
2270            dimension and coordinate 'station'. (..., y, x) -> (..., station)
2271        """
2272        da = self._obj
2273        # TODO ensure that y, x are rightmost dims; they should be if opening
2274        # with grib2io engine
2275
2276        calls = np.asarray(calls)
2277        lats = np.asarray(lats)
2278        lons = np.asarray(lons)
2279        latitude = xr.DataArray(lats, dims=["station"])
2280        longitude = xr.DataArray(lons, dims=["station"])
2281
2282        # create new coords
2283        new_coords = dict(da.coords)
2284        del new_coords["latitude"]
2285        del new_coords["longitude"]
2286        new_coords["longitude"] = longitude
2287        new_coords["latitude"] = latitude
2288        new_coords["station"] = calls
2289
2290        new_dims = da.dims[:-2] + ("station",)
2291
2292        # make grid def in from section3 on da attrs
2293        grid_def_in = self.griddef()
2294
2295        if da.chunks is None:
2296            data = interp_nd_stations(
2297                da.data,
2298                method=method,
2299                grid_def_in=grid_def_in,
2300                lats=lats,
2301                lons=lons,
2302                method_options=method_options,
2303                num_threads=num_threads,
2304            )
2305        else:
2306            data = da.data.map_blocks(
2307                interp_nd_stations,
2308                method=method,
2309                grid_def_in=grid_def_in,
2310                lats=lats,
2311                lons=lons,
2312                method_options=method_options,
2313                drop_axis=-1,
2314                chunks=da.chunks[:-2] + latitude.shape,
2315                dtype=da.dtype,
2316            )
2317
2318        new_da = xr.DataArray(data, dims=new_dims, coords=new_coords, attrs=da.attrs)
2319
2320        new_da.name = da.name
2321
2322        # Update history for provenance
2323        history = new_da.attrs.get("history", "")
2324        now = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
2325        new_da.attrs["history"] = f"{now}: Interpolated to {len(calls)} stations via {method}\n{history}"
2326
2327        return new_da
2328
2329    def to_grib2(self, filename, mode: typing.Literal["x", "w", "a"] = "x"):
2330        """
2331        Write a DataArray to a grib2 file.
2332
2333        Parameters
2334        ----------
2335        filename
2336            Name of the grib2 file to write to.
2337        mode: {"x", "w", "a"}, optional, default="x"
2338            Persistence mode
2339
2340            +------+-----------------------------------+
2341            | mode | Description                       |
2342            +======+===================================+
2343            | x    | create (fail if exists)           |
2344            +------+-----------------------------------+
2345            | w    | create (overwrite if exists)      |
2346            +------+-----------------------------------+
2347            | a    | append (create if does not exist) |
2348            +------+-----------------------------------+
2349
2350        """
2351        da = self._obj.copy(deep=True)
2352
2353        coords_keys = sorted(da.coords.keys())
2354        coords_keys = [k for k in coords_keys if k in AVAILABLE_NON_GEO_COORDS]
2355
2356        # If there are dimension coordinates, the DataArray is a hypercube of
2357        # grib2 messages.
2358
2359        # Create `indexes` which is a list of lists of dictionaries for all
2360        # dimension coordinates. Each dictionary key is the dimension
2361        # coordinate name and the value is a list of the dimension coordinate
2362        # values.  This allows for easy iteration over all possible grib2
2363        # messages in the DataArray by using itertools.product.
2364        #
2365        # For example:
2366        # indexes = [
2367        #     [
2368        #         {"leadTime": 9},
2369        #         {"leadTime": 12},
2370        #     ],
2371        #     [
2372        #         {"valueOfFirstFixedSurface": 900},
2373        #         {"valueOfFirstFixedSurface": 925},
2374        #         {"valueOfFirstFixedSurface": 950},
2375        #     ],
2376        # ]
2377
2378        # assign loc indexes to dimensions without indexes for uniform selection by name
2379        loc_indexes = list()
2380        for dim in da.dims:
2381            if dim not in da.indexes:
2382                da = da.assign_coords({dim: range(da[dim].size)})
2383                loc_indexes.append(dim)
2384
2385        indexes = []
2386        for index in [i for i in AVAILABLE_NON_GEO_DIMS if i in da.dims]:
2387            values = da.coords[index].values
2388            if len(values) != len(set(values)):
2389                raise ValueError(
2390                    f"Dimension coordinate '{index}' has duplicate values, but to_grib2 requires unique values to find each GRIB2 message in the DataArray."
2391                )
2392            listeach = [{index: value} for value in sorted(values)]
2393            indexes.append(listeach)
2394
2395        # If `dim_coords` is [], then the DataArray is a single grib2 message and
2396        # itertools.product(*dim_coords) will run once with `selectors = ()`.
2397        for selectors in itertools.product(*indexes):
2398            # Need to find the correct data in the DataArray based on the
2399            # dimension coordinates.
2400            filters = {k: v for d in selectors for k, v in d.items()}
2401
2402            # If `filters` is {}, then the DataArray is a single grib2 message
2403            # and da.sel(indexers={}) returns the DataArray.
2404            selected = da.sel(indexers=filters)
2405
2406            newmsg = Grib2Message(
2407                selected.attrs["GRIB2IO_section0"],
2408                selected.attrs["GRIB2IO_section1"],
2409                selected.attrs["GRIB2IO_section2"],
2410                selected.attrs["GRIB2IO_section3"],
2411                selected.attrs["GRIB2IO_section4"],
2412                selected.attrs["GRIB2IO_section5"],
2413            )
2414            newmsg.data = np.array(selected.data)
2415
2416            # For dimension coordinates, set the grib2 message metadata to the
2417            # dimension coordinate value.
2418            for index, value in filters.items():
2419                if index not in loc_indexes:
2420                    setattr(newmsg, index, value)
2421
2422            # For non-dimension coordinates, set the grib2 message metadata to
2423            # the DataArray coordinate value.
2424            for index in [i for i in coords_keys if i not in da.dims]:
2425                setattr(newmsg, index, selected.coords[index].values)
2426
2427            # Set section 5 attributes to the da.encoding dictionary.
2428            for key, value in selected.encoding.items():
2429                if key in ["dtype", "chunks", "original_shape"]:
2430                    continue
2431                setattr(newmsg, key, value)
2432
2433            # write the message to file
2434            with grib2io.open(filename, mode=mode) as f:
2435                f.write(newmsg)
2436            mode = "a"
2437
2438    def update_attrs(self, **kwargs):
2439        """
2440        Update many of the attributes of the DataArray.
2441
2442        Parameters
2443        ----------
2444        **kwargs
2445            Attributes to update.  This can include many of the GRIB2IO message
2446            attributes that you can find when you print a GRIB2IO message. For
2447            conflicting updates, the last keyword will be used.
2448
2449            +-----------------------+------------------------------------------+
2450            | kwargs                | Description                              |
2451            +=======================+==========================================+
2452            | shortName="VTMP"      | Set shortName to "VTMP", along with      |
2453            |                       | appropriate discipline,                  |
2454            |                       | parameterCategory, parameterNumber,      |
2455            |                       | fullName and units.                      |
2456            +-----------------------+------------------------------------------+
2457            | discipline=0,         | Set shortName, discipline,               |
2458            | parameterCategory=0,  | parameterCategory, parameterNumber,      |
2459            | parameterNumber=1     | fullName and units appropriate for       |
2460            |                       | "Virtual Temperature".                   |
2461            +-----------------------+------------------------------------------+
2462            | discipline=0,         | Conflicting keywords but                 |
2463            | parameterCategory=0,  | 'shortName="TMP"' wins.  Set shortName,  |
2464            | parameterNumber=1,    | discipline, parameterCategory,           |
2465            | shortName="TMP"       | parameterNumber, fullName and units      |
2466            |                       | appropriate for "Temperature".           |
2467            +-----------------------+------------------------------------------+
2468
2469        Returns
2470        -------
2471        DataArray
2472            DataArray with updated attributes.
2473        """
2474        da = self._obj.copy(deep=True)
2475
2476        newmsg = Grib2Message(
2477            da.attrs["GRIB2IO_section0"],
2478            da.attrs["GRIB2IO_section1"],
2479            da.attrs["GRIB2IO_section2"],
2480            da.attrs["GRIB2IO_section3"],
2481            da.attrs["GRIB2IO_section4"],
2482            da.attrs["GRIB2IO_section5"],
2483        )
2484
2485        coords_keys = [k for k in da.coords.keys() if k in AVAILABLE_NON_GEO_COORDS]
2486
2487        for grib2_name, value in kwargs.items():
2488            if grib2_name == "gridDefinitionTemplateNumber":
2489                raise ValueError(
2490                    "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."
2491                )
2492            if grib2_name == "productDefinitionTemplateNumber":
2493                raise ValueError("The productDefinitionTemplateNumber attribute cannot be updated.")
2494            if grib2_name == "dataRepresentationTemplateNumber":
2495                raise ValueError("The dataRepresentationTemplateNumber attribute cannot be updated.")
2496            if grib2_name in coords_keys:
2497                warnings.warn(f"Skipping attribute '{grib2_name}' because it is a coordinate. Use da.assign_coords() to change coordinate values.")
2498                continue
2499            if hasattr(newmsg, grib2_name):
2500                setattr(newmsg, grib2_name, value)
2501            else:
2502                warnings.warn(f"Skipping attribute '{grib2_name}' because it is not a valid GRIB2 attribute for this message and cannot be updated.")
2503                continue
2504
2505        da.attrs["GRIB2IO_section0"] = newmsg.section0
2506        da.attrs["GRIB2IO_section1"] = newmsg.section1
2507        da.attrs["GRIB2IO_section2"] = newmsg.section2 or []
2508        da.attrs["GRIB2IO_section3"] = newmsg.section3
2509        da.attrs["GRIB2IO_section4"] = newmsg.section4
2510        da.attrs["GRIB2IO_section5"] = newmsg.section5
2511        da.attrs["fullName"] = newmsg.fullName
2512        da.attrs["shortName"] = newmsg.shortName
2513        da.attrs["units"] = newmsg.units
2514
2515        return da
2516
2517    def update_section3(self) -> xr.DataArray:
2518        """
2519        Update section3 attributes based on the latitude and longitude corners.
2520
2521        This makes the GRIB2IO_section3 attribute consistent with the grid's
2522        new corners after a change in the spatial extent.
2523        """
2524        da = self._obj
2525        if "GRIB2IO_section3" not in da.attrs:
2526            raise ValueError(
2527                "DataArray has no attr 'GRIB2IO_section3'.  This function only works with Datasets/DataArrrays opened with the 'grib2io' backend."
2528            )
2529        if "latitude" not in da.coords:
2530            raise ValueError("DataArray has no coord 'latitude'")
2531        if "longitude" not in da.coords:
2532            raise ValueError("DataArray has no coord 'longitude'")
2533
2534        grid = Grid(da.attrs["GRIB2IO_section3"])
2535
2536        if grid.gdtn not in [0, 1, 10, 20, 30, 31, 40, 110]:
2537            raise ValueError(
2538                textwrap.dedent("""\
2539                    update_section3 only works for:
2540
2541                    Latitude/Longitude, Equidistant Cylindrical, or Plate Carree (gdtn=0)
2542                    Rotated Latitude/Longitude (gdtn=1)
2543                    Mercator (gdtn=10)
2544                    Polar Stereographic (gdtn=20)
2545                    Lambert Conformal (gdtn=30)
2546                    Albers Equal-Area (gdtn=31)
2547                    Gaussian Latitude/Longitude (gdtn=40)
2548                    Equatorial Azimuthal Equidistant Projection (gdtn=110)
2549                    """)
2550            )
2551
2552        grid.latitudeFirstGridpoint = da.latitude.isel(y=0, x=0)
2553        grid.longitudeFirstGridpoint = da.longitude.isel(y=0, x=0)
2554        grid.nx = len(da.x)
2555        grid.ny = len(da.y)
2556
2557        # last gridpoint does not affect section3 for some gdt but set anyway
2558        grid.latitudeLastGridpoint = da.latitude.isel(y=-1, x=-1)
2559        grid.longitudeLastGridpoint = da.longitude.isel(y=-1, x=-1)
2560
2561        da.attrs["GRIB2IO_section3"] = grid.section3
2562
2563        return da
2564
2565    def subset(self, *, lats=None, lons=None) -> xr.DataArray:
2566        """
2567        Subset the DataArray to a box defined by latitudes and/or longitudes.
2568
2569        Parameters
2570        ----------
2571        lats
2572            Two item list or tuple of latitudes.  Default is None which will
2573            return a subset unbounded by latitude.  The first term defines the
2574            southern boundary and the second term defines the northern
2575            boundary.
2576        lons
2577            Two item list or tuple of longitudes.  Default is None which will
2578            return a subset unbounded by longitude.  The first term defines the
2579            western boundary and the second term defines the eastern
2580            boundary.  Can follow either: 0 to 360 postive eastward, or 0 to
2581            -180 westward / 0 to 180 eastward conventions.  The longitude
2582            boundaries cannot cross 0.
2583
2584        Returns
2585        -------
2586        subset
2587            DataArray subset to the bounding box created by input 'lats'/'lons'.
2588            All gridpoints with lat/lon matching contraints are included within
2589            subset.
2590        """
2591
2592        def slice_from_contiguous_mask(mask):
2593            indices = np.where(mask)[0]
2594
2595            if indices.size > 0:
2596                # slice(start, stop) - stop is exclusive, so we add 1
2597                my_slice = slice(indices[0], indices[-1] + 1)
2598            else:
2599                my_slice = slice(0, 0)
2600            return my_slice
2601
2602        da = self._obj.copy()
2603
2604        if lats is None:
2605            lats = (np.min(da.latitude), np.max(da.latitude))
2606        else:
2607            lats = (min(lats), max(lats))
2608
2609        if lons is None:
2610            lons = (np.min(da.longitude), np.max(da.longitude))
2611        else:
2612            lons = (min(lons), max(lons))
2613
2614        # Internally work in common lon data representation (0->360 positive eastward from 0)
2615        lons = np.mod(np.array(lons) + 360, 360)
2616        lon_da = np.mod(da.longitude + 360, 360)
2617
2618        snap_first_point = snap_to_nearest_cell_center(da.latitude, lon_da, lats[0], lons[0])
2619        snap_last_point = snap_to_nearest_cell_center(da.latitude, lon_da, lats[1], lons[1])
2620        lats = (snap_first_point[0], snap_last_point[0])
2621        lons = (snap_first_point[1], snap_last_point[1])
2622
2623        x = ((lon_da >= lons[0]) & (lon_da <= lons[1])).any("y")
2624        if x.chunks:
2625            x = x.compute()
2626
2627        y = ((da.latitude >= lats[0]) & (da.latitude <= lats[1])).any("x")
2628        if y.chunks:
2629            y = y.compute()
2630
2631        y_slice = slice_from_contiguous_mask(y)
2632        x_slice = slice_from_contiguous_mask(x)
2633
2634        da = da.isel(y=y_slice, x=x_slice)
2635        if da.size < 1:
2636            raise ValueError("None of grid data is within given lat/lon bounds.")
2637
2638        da = da.grib2io.update_section3()
2639
2640        return da
2641
2642    def compute(self, **kwargs):
2643        """
2644        Compute the Dask-backed DataArray with retries for transient errors.
2645
2646        Wraps :func:`grib2io.utils.compute_with_retries`.
2647
2648        Parameters
2649        ----------
2650        **kwargs
2651            Arguments passed to :func:`grib2io.utils.compute_with_retries`,
2652            e.g., `max_attempts` or `base_sleep`.
2653
2654        Returns
2655        -------
2656        xarray.DataArray
2657            The computed DataArray with NumPy-backed data.
2658        """
2659        from .utils import compute_with_retries
2660
2661        return compute_with_retries(self._obj, **kwargs)
Grib2ioDataArray(xarray_obj)
2130    def __init__(self, xarray_obj):
2131        self._obj = xarray_obj
def griddef(self):
2133    def griddef(self):
2134        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:
2136    def interp(self, method, grid_def_out, method_options=None, num_threads=1) -> xr.DataArray:
2137        """
2138        Perform grid spatial interpolation.
2139
2140        Uses the [NCEPLIBS-ip library](https://github.com/NOAA-EMC/NCEPLIBS-ip).
2141
2142        Parameters
2143        ----------
2144        method
2145            Interpolate method to use. This can either be an integer or string
2146            using the following mapping:
2147
2148            | Interpolate Scheme | Integer Value |
2149            | :---:              | :---:         |
2150            | 'bilinear'         | 0             |
2151            | 'bicubic'          | 1             |
2152            | 'neighbor'         | 2             |
2153            | 'budget'           | 3             |
2154            | 'spectral'         | 4             |
2155            | 'neighbor-budget'  | 6             |
2156        grid_def_out
2157            Grib2GridDef object of the output grid.
2158        method_options : list of ints, optional
2159            Interpolation options. See the NCEPLIBS-ip documentation for
2160            more information on how these are used.
2161        num_threads : int, optional
2162            Number of OpenMP threads to use for interpolation. The default
2163            value is 1. If grib2io_interp was not built with OpenMP, then
2164            this keyword argument and value will have no impact.
2165
2166        Returns
2167        -------
2168        interp
2169            DataSet interpolated to new grid definition.  The attribute
2170            GRIB2IO_section3 is replaced with the section3 array from the new
2171            grid definition.
2172        """
2173        da = self._obj
2174        # ensure that y, x are rightmost dims; they should be if opening with
2175        # grib2io engine
2176
2177        # gdtn and gdt is not the entirety of the new s3
2178        npoints = grid_def_out.npoints
2179        s3_new = np.array([0, npoints, 0, 0, grid_def_out.gdtn] + list(grid_def_out.gdt))
2180
2181        # make new lat lons
2182        lats, lons = Grib2Message(section3=s3_new, pdtn=0, drtn=0).grid()
2183        latitude = xr.DataArray(lats, dims=["y", "x"])
2184        longitude = xr.DataArray(lons, dims=["y", "x"])
2185
2186        # create new coords
2187        new_coords = dict(da.coords)
2188        del new_coords["latitude"]
2189        del new_coords["longitude"]
2190        new_coords["longitude"] = longitude
2191        new_coords["latitude"] = latitude
2192
2193        # make grid def in from section3 on da.attrs
2194        grid_def_in = self.griddef()
2195
2196        if da.chunks is None:
2197            data = interp_nd(
2198                da.data,
2199                method=method,
2200                grid_def_in=grid_def_in,
2201                grid_def_out=grid_def_out,
2202                method_options=method_options,
2203                num_threads=num_threads,
2204            )
2205        else:
2206            data = da.data.map_blocks(
2207                interp_nd,
2208                method=method,
2209                grid_def_in=grid_def_in,
2210                grid_def_out=grid_def_out,
2211                method_options=method_options,
2212                chunks=da.chunks[:-2] + latitude.shape,
2213                dtype=da.dtype,
2214            )
2215
2216        new_da = xr.DataArray(data, dims=da.dims, coords=new_coords, attrs=da.attrs)
2217
2218        new_da.attrs["GRIB2IO_section3"] = s3_new
2219        new_da.name = da.name
2220
2221        # Update history for provenance
2222        history = new_da.attrs.get("history", "")
2223        now = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
2224        new_da.attrs["history"] = f"{now}: Interpolated via {method} to {grid_def_out}\n{history}"
2225
2226        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: Union[str, int], calls: Sequence[str], lats: Sequence[float], lons: Sequence[float], method_options: Optional[List[int]] = None, num_threads: int = 1) -> xarray.core.dataarray.DataArray:
2228    def interp_to_stations(
2229        self,
2230        method: typing.Union[str, int],
2231        calls: typing.Sequence[str],
2232        lats: typing.Sequence[float],
2233        lons: typing.Sequence[float],
2234        method_options: typing.Optional[typing.List[int]] = None,
2235        num_threads: int = 1,
2236    ) -> xr.DataArray:
2237        """
2238        Perform spatial interpolation to station points.
2239
2240        Parameters
2241        ----------
2242        method : str or int
2243            Interpolate method to use. This can either be an integer or string
2244            using the following mapping:
2245
2246            | Interpolate Scheme | Integer Value |
2247            | :---:              | :---:         |
2248            | 'bilinear'         | 0             |
2249            | 'bicubic'          | 1             |
2250            | 'neighbor'         | 2             |
2251            | 'budget'           | 3             |
2252            | 'spectral'         | 4             |
2253            | 'neighbor-budget'  | 6             |
2254
2255        calls : sequence of str
2256            Station calls used for labeling new station index coordinate
2257        lats : sequence of float
2258            Latitudes of the station points.
2259        lons : sequence of float
2260            Longitudes of the station points.
2261        method_options : list of int, optional
2262            Interpolation options.
2263        num_threads : int, optional
2264            Number of threads.
2265
2266        Returns
2267        -------
2268        xarray.DataArray
2269            DataArray interpolated to lat and lon locations and labeled with
2270            dimension and coordinate 'station'. (..., y, x) -> (..., station)
2271        """
2272        da = self._obj
2273        # TODO ensure that y, x are rightmost dims; they should be if opening
2274        # with grib2io engine
2275
2276        calls = np.asarray(calls)
2277        lats = np.asarray(lats)
2278        lons = np.asarray(lons)
2279        latitude = xr.DataArray(lats, dims=["station"])
2280        longitude = xr.DataArray(lons, dims=["station"])
2281
2282        # create new coords
2283        new_coords = dict(da.coords)
2284        del new_coords["latitude"]
2285        del new_coords["longitude"]
2286        new_coords["longitude"] = longitude
2287        new_coords["latitude"] = latitude
2288        new_coords["station"] = calls
2289
2290        new_dims = da.dims[:-2] + ("station",)
2291
2292        # make grid def in from section3 on da attrs
2293        grid_def_in = self.griddef()
2294
2295        if da.chunks is None:
2296            data = interp_nd_stations(
2297                da.data,
2298                method=method,
2299                grid_def_in=grid_def_in,
2300                lats=lats,
2301                lons=lons,
2302                method_options=method_options,
2303                num_threads=num_threads,
2304            )
2305        else:
2306            data = da.data.map_blocks(
2307                interp_nd_stations,
2308                method=method,
2309                grid_def_in=grid_def_in,
2310                lats=lats,
2311                lons=lons,
2312                method_options=method_options,
2313                drop_axis=-1,
2314                chunks=da.chunks[:-2] + latitude.shape,
2315                dtype=da.dtype,
2316            )
2317
2318        new_da = xr.DataArray(data, dims=new_dims, coords=new_coords, attrs=da.attrs)
2319
2320        new_da.name = da.name
2321
2322        # Update history for provenance
2323        history = new_da.attrs.get("history", "")
2324        now = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
2325        new_da.attrs["history"] = f"{now}: Interpolated to {len(calls)} stations via {method}\n{history}"
2326
2327        return new_da

Perform spatial interpolation to station points.

Parameters
  • method (str or int): 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 (sequence of str): Station calls used for labeling new station index coordinate
  • lats (sequence of float): Latitudes of the station points.
  • lons (sequence of float): Longitudes of the station points.
  • method_options (list of int, optional): Interpolation options.
  • num_threads (int, optional): Number of threads.
Returns
  • xarray.DataArray: 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'):
2329    def to_grib2(self, filename, mode: typing.Literal["x", "w", "a"] = "x"):
2330        """
2331        Write a DataArray to a grib2 file.
2332
2333        Parameters
2334        ----------
2335        filename
2336            Name of the grib2 file to write to.
2337        mode: {"x", "w", "a"}, optional, default="x"
2338            Persistence mode
2339
2340            +------+-----------------------------------+
2341            | mode | Description                       |
2342            +======+===================================+
2343            | x    | create (fail if exists)           |
2344            +------+-----------------------------------+
2345            | w    | create (overwrite if exists)      |
2346            +------+-----------------------------------+
2347            | a    | append (create if does not exist) |
2348            +------+-----------------------------------+
2349
2350        """
2351        da = self._obj.copy(deep=True)
2352
2353        coords_keys = sorted(da.coords.keys())
2354        coords_keys = [k for k in coords_keys if k in AVAILABLE_NON_GEO_COORDS]
2355
2356        # If there are dimension coordinates, the DataArray is a hypercube of
2357        # grib2 messages.
2358
2359        # Create `indexes` which is a list of lists of dictionaries for all
2360        # dimension coordinates. Each dictionary key is the dimension
2361        # coordinate name and the value is a list of the dimension coordinate
2362        # values.  This allows for easy iteration over all possible grib2
2363        # messages in the DataArray by using itertools.product.
2364        #
2365        # For example:
2366        # indexes = [
2367        #     [
2368        #         {"leadTime": 9},
2369        #         {"leadTime": 12},
2370        #     ],
2371        #     [
2372        #         {"valueOfFirstFixedSurface": 900},
2373        #         {"valueOfFirstFixedSurface": 925},
2374        #         {"valueOfFirstFixedSurface": 950},
2375        #     ],
2376        # ]
2377
2378        # assign loc indexes to dimensions without indexes for uniform selection by name
2379        loc_indexes = list()
2380        for dim in da.dims:
2381            if dim not in da.indexes:
2382                da = da.assign_coords({dim: range(da[dim].size)})
2383                loc_indexes.append(dim)
2384
2385        indexes = []
2386        for index in [i for i in AVAILABLE_NON_GEO_DIMS if i in da.dims]:
2387            values = da.coords[index].values
2388            if len(values) != len(set(values)):
2389                raise ValueError(
2390                    f"Dimension coordinate '{index}' has duplicate values, but to_grib2 requires unique values to find each GRIB2 message in the DataArray."
2391                )
2392            listeach = [{index: value} for value in sorted(values)]
2393            indexes.append(listeach)
2394
2395        # If `dim_coords` is [], then the DataArray is a single grib2 message and
2396        # itertools.product(*dim_coords) will run once with `selectors = ()`.
2397        for selectors in itertools.product(*indexes):
2398            # Need to find the correct data in the DataArray based on the
2399            # dimension coordinates.
2400            filters = {k: v for d in selectors for k, v in d.items()}
2401
2402            # If `filters` is {}, then the DataArray is a single grib2 message
2403            # and da.sel(indexers={}) returns the DataArray.
2404            selected = da.sel(indexers=filters)
2405
2406            newmsg = Grib2Message(
2407                selected.attrs["GRIB2IO_section0"],
2408                selected.attrs["GRIB2IO_section1"],
2409                selected.attrs["GRIB2IO_section2"],
2410                selected.attrs["GRIB2IO_section3"],
2411                selected.attrs["GRIB2IO_section4"],
2412                selected.attrs["GRIB2IO_section5"],
2413            )
2414            newmsg.data = np.array(selected.data)
2415
2416            # For dimension coordinates, set the grib2 message metadata to the
2417            # dimension coordinate value.
2418            for index, value in filters.items():
2419                if index not in loc_indexes:
2420                    setattr(newmsg, index, value)
2421
2422            # For non-dimension coordinates, set the grib2 message metadata to
2423            # the DataArray coordinate value.
2424            for index in [i for i in coords_keys if i not in da.dims]:
2425                setattr(newmsg, index, selected.coords[index].values)
2426
2427            # Set section 5 attributes to the da.encoding dictionary.
2428            for key, value in selected.encoding.items():
2429                if key in ["dtype", "chunks", "original_shape"]:
2430                    continue
2431                setattr(newmsg, key, value)
2432
2433            # write the message to file
2434            with grib2io.open(filename, mode=mode) as f:
2435                f.write(newmsg)
2436            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):
2438    def update_attrs(self, **kwargs):
2439        """
2440        Update many of the attributes of the DataArray.
2441
2442        Parameters
2443        ----------
2444        **kwargs
2445            Attributes to update.  This can include many of the GRIB2IO message
2446            attributes that you can find when you print a GRIB2IO message. For
2447            conflicting updates, the last keyword will be used.
2448
2449            +-----------------------+------------------------------------------+
2450            | kwargs                | Description                              |
2451            +=======================+==========================================+
2452            | shortName="VTMP"      | Set shortName to "VTMP", along with      |
2453            |                       | appropriate discipline,                  |
2454            |                       | parameterCategory, parameterNumber,      |
2455            |                       | fullName and units.                      |
2456            +-----------------------+------------------------------------------+
2457            | discipline=0,         | Set shortName, discipline,               |
2458            | parameterCategory=0,  | parameterCategory, parameterNumber,      |
2459            | parameterNumber=1     | fullName and units appropriate for       |
2460            |                       | "Virtual Temperature".                   |
2461            +-----------------------+------------------------------------------+
2462            | discipline=0,         | Conflicting keywords but                 |
2463            | parameterCategory=0,  | 'shortName="TMP"' wins.  Set shortName,  |
2464            | parameterNumber=1,    | discipline, parameterCategory,           |
2465            | shortName="TMP"       | parameterNumber, fullName and units      |
2466            |                       | appropriate for "Temperature".           |
2467            +-----------------------+------------------------------------------+
2468
2469        Returns
2470        -------
2471        DataArray
2472            DataArray with updated attributes.
2473        """
2474        da = self._obj.copy(deep=True)
2475
2476        newmsg = Grib2Message(
2477            da.attrs["GRIB2IO_section0"],
2478            da.attrs["GRIB2IO_section1"],
2479            da.attrs["GRIB2IO_section2"],
2480            da.attrs["GRIB2IO_section3"],
2481            da.attrs["GRIB2IO_section4"],
2482            da.attrs["GRIB2IO_section5"],
2483        )
2484
2485        coords_keys = [k for k in da.coords.keys() if k in AVAILABLE_NON_GEO_COORDS]
2486
2487        for grib2_name, value in kwargs.items():
2488            if grib2_name == "gridDefinitionTemplateNumber":
2489                raise ValueError(
2490                    "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."
2491                )
2492            if grib2_name == "productDefinitionTemplateNumber":
2493                raise ValueError("The productDefinitionTemplateNumber attribute cannot be updated.")
2494            if grib2_name == "dataRepresentationTemplateNumber":
2495                raise ValueError("The dataRepresentationTemplateNumber attribute cannot be updated.")
2496            if grib2_name in coords_keys:
2497                warnings.warn(f"Skipping attribute '{grib2_name}' because it is a coordinate. Use da.assign_coords() to change coordinate values.")
2498                continue
2499            if hasattr(newmsg, grib2_name):
2500                setattr(newmsg, grib2_name, value)
2501            else:
2502                warnings.warn(f"Skipping attribute '{grib2_name}' because it is not a valid GRIB2 attribute for this message and cannot be updated.")
2503                continue
2504
2505        da.attrs["GRIB2IO_section0"] = newmsg.section0
2506        da.attrs["GRIB2IO_section1"] = newmsg.section1
2507        da.attrs["GRIB2IO_section2"] = newmsg.section2 or []
2508        da.attrs["GRIB2IO_section3"] = newmsg.section3
2509        da.attrs["GRIB2IO_section4"] = newmsg.section4
2510        da.attrs["GRIB2IO_section5"] = newmsg.section5
2511        da.attrs["fullName"] = newmsg.fullName
2512        da.attrs["shortName"] = newmsg.shortName
2513        da.attrs["units"] = newmsg.units
2514
2515        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 update_section3(self) -> xarray.core.dataarray.DataArray:
2517    def update_section3(self) -> xr.DataArray:
2518        """
2519        Update section3 attributes based on the latitude and longitude corners.
2520
2521        This makes the GRIB2IO_section3 attribute consistent with the grid's
2522        new corners after a change in the spatial extent.
2523        """
2524        da = self._obj
2525        if "GRIB2IO_section3" not in da.attrs:
2526            raise ValueError(
2527                "DataArray has no attr 'GRIB2IO_section3'.  This function only works with Datasets/DataArrrays opened with the 'grib2io' backend."
2528            )
2529        if "latitude" not in da.coords:
2530            raise ValueError("DataArray has no coord 'latitude'")
2531        if "longitude" not in da.coords:
2532            raise ValueError("DataArray has no coord 'longitude'")
2533
2534        grid = Grid(da.attrs["GRIB2IO_section3"])
2535
2536        if grid.gdtn not in [0, 1, 10, 20, 30, 31, 40, 110]:
2537            raise ValueError(
2538                textwrap.dedent("""\
2539                    update_section3 only works for:
2540
2541                    Latitude/Longitude, Equidistant Cylindrical, or Plate Carree (gdtn=0)
2542                    Rotated Latitude/Longitude (gdtn=1)
2543                    Mercator (gdtn=10)
2544                    Polar Stereographic (gdtn=20)
2545                    Lambert Conformal (gdtn=30)
2546                    Albers Equal-Area (gdtn=31)
2547                    Gaussian Latitude/Longitude (gdtn=40)
2548                    Equatorial Azimuthal Equidistant Projection (gdtn=110)
2549                    """)
2550            )
2551
2552        grid.latitudeFirstGridpoint = da.latitude.isel(y=0, x=0)
2553        grid.longitudeFirstGridpoint = da.longitude.isel(y=0, x=0)
2554        grid.nx = len(da.x)
2555        grid.ny = len(da.y)
2556
2557        # last gridpoint does not affect section3 for some gdt but set anyway
2558        grid.latitudeLastGridpoint = da.latitude.isel(y=-1, x=-1)
2559        grid.longitudeLastGridpoint = da.longitude.isel(y=-1, x=-1)
2560
2561        da.attrs["GRIB2IO_section3"] = grid.section3
2562
2563        return da

Update section3 attributes based on the latitude and longitude corners.

This makes the GRIB2IO_section3 attribute consistent with the grid's new corners after a change in the spatial extent.

def subset(self, *, lats=None, lons=None) -> xarray.core.dataarray.DataArray:
2565    def subset(self, *, lats=None, lons=None) -> xr.DataArray:
2566        """
2567        Subset the DataArray to a box defined by latitudes and/or longitudes.
2568
2569        Parameters
2570        ----------
2571        lats
2572            Two item list or tuple of latitudes.  Default is None which will
2573            return a subset unbounded by latitude.  The first term defines the
2574            southern boundary and the second term defines the northern
2575            boundary.
2576        lons
2577            Two item list or tuple of longitudes.  Default is None which will
2578            return a subset unbounded by longitude.  The first term defines the
2579            western boundary and the second term defines the eastern
2580            boundary.  Can follow either: 0 to 360 postive eastward, or 0 to
2581            -180 westward / 0 to 180 eastward conventions.  The longitude
2582            boundaries cannot cross 0.
2583
2584        Returns
2585        -------
2586        subset
2587            DataArray subset to the bounding box created by input 'lats'/'lons'.
2588            All gridpoints with lat/lon matching contraints are included within
2589            subset.
2590        """
2591
2592        def slice_from_contiguous_mask(mask):
2593            indices = np.where(mask)[0]
2594
2595            if indices.size > 0:
2596                # slice(start, stop) - stop is exclusive, so we add 1
2597                my_slice = slice(indices[0], indices[-1] + 1)
2598            else:
2599                my_slice = slice(0, 0)
2600            return my_slice
2601
2602        da = self._obj.copy()
2603
2604        if lats is None:
2605            lats = (np.min(da.latitude), np.max(da.latitude))
2606        else:
2607            lats = (min(lats), max(lats))
2608
2609        if lons is None:
2610            lons = (np.min(da.longitude), np.max(da.longitude))
2611        else:
2612            lons = (min(lons), max(lons))
2613
2614        # Internally work in common lon data representation (0->360 positive eastward from 0)
2615        lons = np.mod(np.array(lons) + 360, 360)
2616        lon_da = np.mod(da.longitude + 360, 360)
2617
2618        snap_first_point = snap_to_nearest_cell_center(da.latitude, lon_da, lats[0], lons[0])
2619        snap_last_point = snap_to_nearest_cell_center(da.latitude, lon_da, lats[1], lons[1])
2620        lats = (snap_first_point[0], snap_last_point[0])
2621        lons = (snap_first_point[1], snap_last_point[1])
2622
2623        x = ((lon_da >= lons[0]) & (lon_da <= lons[1])).any("y")
2624        if x.chunks:
2625            x = x.compute()
2626
2627        y = ((da.latitude >= lats[0]) & (da.latitude <= lats[1])).any("x")
2628        if y.chunks:
2629            y = y.compute()
2630
2631        y_slice = slice_from_contiguous_mask(y)
2632        x_slice = slice_from_contiguous_mask(x)
2633
2634        da = da.isel(y=y_slice, x=x_slice)
2635        if da.size < 1:
2636            raise ValueError("None of grid data is within given lat/lon bounds.")
2637
2638        da = da.grib2io.update_section3()
2639
2640        return da

Subset the DataArray to a box defined by latitudes and/or longitudes.

Parameters
  • lats: Two item list or tuple of latitudes. Default is None which will return a subset unbounded by latitude. The first term defines the southern boundary and the second term defines the northern boundary.
  • lons: Two item list or tuple of longitudes. Default is None which will return a subset unbounded by longitude. The first term defines the western boundary and the second term defines the eastern boundary. Can follow either: 0 to 360 postive eastward, or 0 to -180 westward / 0 to 180 eastward conventions. The longitude boundaries cannot cross 0.
Returns
  • subset: DataArray subset to the bounding box created by input 'lats'/'lons'. All gridpoints with lat/lon matching contraints are included within subset.
def compute(self, **kwargs):
2642    def compute(self, **kwargs):
2643        """
2644        Compute the Dask-backed DataArray with retries for transient errors.
2645
2646        Wraps :func:`grib2io.utils.compute_with_retries`.
2647
2648        Parameters
2649        ----------
2650        **kwargs
2651            Arguments passed to :func:`grib2io.utils.compute_with_retries`,
2652            e.g., `max_attempts` or `base_sleep`.
2653
2654        Returns
2655        -------
2656        xarray.DataArray
2657            The computed DataArray with NumPy-backed data.
2658        """
2659        from .utils import compute_with_retries
2660
2661        return compute_with_retries(self._obj, **kwargs)

Compute the Dask-backed DataArray with retries for transient errors.

Wraps grib2io.utils.compute_with_retries().

Parameters
Returns
  • xarray.DataArray: The computed DataArray with NumPy-backed data.
def open_mfdataset( filenames: Union[str, Sequence[str]], *, drop_variables: Optional[List[str]] = None, save_index: bool = True, filters: Mapping[str, Any] = {}, data_model: Optional[str] = None, parallel: bool = False, preprocess: Optional[Callable] = None, chunks: Union[int, Dict[Any, Any], Literal['auto'], NoneType] = None, **kwargs) -> xarray.core.dataset.Dataset:
2664def open_mfdataset(
2665    filenames: typing.Union[str, typing.Sequence[str]],
2666    *,
2667    drop_variables: typing.Optional[typing.List[str]] = None,
2668    save_index: bool = True,
2669    filters: typing.Mapping[str, typing.Any] = dict(),
2670    data_model: typing.Optional[str] = None,
2671    parallel: bool = False,
2672    preprocess: typing.Optional[typing.Callable] = None,
2673    chunks: typing.Optional[typing.Union[int, typing.Dict[typing.Any, typing.Any], typing.Literal["auto"]]] = None,
2674    **kwargs,
2675) -> xr.Dataset:
2676    """
2677    Open multiple GRIB2 files as a single xarray Dataset.
2678
2679    This function is optimized for GRIB2 files by combining their indices
2680    and creating a single Dataset, which is often much faster than
2681    using ``xarray.open_mfdataset``. It supports parallel index reading
2682    and dataset opening when ``parallel=True`` and ``dask`` is installed.
2683
2684    Parameters
2685    ----------
2686    filenames : str or sequence of str
2687        GRIB2 files to be opened. Can be a glob pattern.
2688    drop_variables : list of str, optional
2689        List of variables to exclude from the dataset.
2690    save_index : bool, optional
2691        Whether to save the GRIB2 index to a file (default is True).
2692    filters : dict, optional
2693        Filter GRIB2 messages to a single hypercube. Dictionary keys can be
2694        any GRIB2 metadata attribute name.
2695    data_model : str, optional
2696        Parse GRIB metadata following a defined data model convention
2697        (e.g., "nws-viz").
2698    parallel : bool, optional
2699        If True, use ``dask`` to read indices and open datasets in parallel.
2700        Requires the ``dask`` package.
2701    preprocess : callable, optional
2702        A function to apply to each file's dataset before combining.
2703    chunks : int, dict or 'auto', optional
2704        If chunks is provided, it is used to load the dataset into a
2705        dask-backed dataset.
2706    **kwargs : optional
2707        Additional arguments passed to the combination logic.
2708        If ``combine='nested'``, passed to ``xarray.combine_nested``.
2709        If ``combine='by_coords'``, passed to ``xarray.combine_by_coords``.
2710        If ``combine='merge'``, passed to ``xarray.merge``.
2711        If no ``combine`` argument is provided, the function attempts
2712        ``xarray.combine_by_coords`` followed by ``xarray.merge``.
2713
2714    Returns
2715    -------
2716    xarray.Dataset
2717        Xarray dataset of grib2 messages.
2718
2719    Notes
2720    -----
2721    - This function uses a "fast path" when ``preprocess=None`` and no
2722      combination ``**kwargs`` are provided, which concatenates all indices
2723      into a single global index before building the Dataset.
2724    - All files must share the same horizontal grid (ny, nx).
2725    """
2726    if isinstance(filenames, str):
2727        import glob
2728
2729        filenames = sorted(glob.glob(filenames))
2730
2731    storage_options = kwargs.pop("storage_options", None)
2732
2733    def _get_index(fname_and_index: typing.Tuple[str, int]) -> pd.DataFrame:
2734        """
2735        Internal utility to read GRIB2 index from a file.
2736
2737        Parameters
2738        ----------
2739        fname_and_index : tuple of (str, int)
2740            Tuple containing the filename and its position in the file list.
2741
2742        Returns
2743        -------
2744        pandas.DataFrame
2745            The GRIB2 index for the specified file.
2746        """
2747        fname, i = fname_and_index
2748        with grib2io.open(
2749            fname,
2750            save_index=save_index,
2751            _xarray_backend=True,
2752            **(storage_options or {}),
2753        ) as f:
2754            idx = pd.DataFrame(f._index)
2755            idx = idx.assign(msg=list(f))
2756            idx["file_index"] = i
2757            return idx
2758
2759    if parallel:
2760        try:
2761            import dask
2762            from dask.bag import from_sequence
2763
2764            indices = from_sequence(zip(filenames, range(len(filenames)))).map(_get_index).compute()
2765        except ImportError:
2766            warnings.warn("dask not installed, falling back to sequential index reading.")
2767            parallel = False
2768            indices = [_get_index((fname, i)) for i, fname in enumerate(filenames)]
2769    else:
2770        indices = [_get_index((fname, i)) for i, fname in enumerate(filenames)]
2771
2772    if not indices:
2773        return xr.Dataset()
2774
2775    # Validate grid consistency across files using only the first message of each file
2776    grid_cols = ["ny", "nx"]
2777    first_msgs = pd.concat([idx.head(1) for idx in indices], ignore_index=True)
2778    unique_grids = first_msgs[grid_cols].drop_duplicates()
2779    if len(unique_grids) > 1:
2780        grid_list = unique_grids.to_dict("records")
2781        raise ValueError(f"Multiple grids detected in open_mfdataset. All files must have the same grid. Found grids: {grid_list}")
2782
2783    # Determine if we can use the fast path (single index concatenation)
2784    # The fast path is only available if no preprocess is provided and no combination kwargs are used
2785    # that would require individual datasets (like concat_dim for nested combination)
2786    use_fast_path = preprocess is None and not kwargs
2787
2788    if not use_fast_path:
2789        if parallel:
2790            import dask
2791
2792            @dask.delayed
2793            def _open_delayed(idx, fname):
2794                return _open_dataset_from_index(
2795                    idx,
2796                    fname,
2797                    filters,
2798                    data_model,
2799                    drop_variables=drop_variables,
2800                    chunks=chunks,
2801                )
2802
2803            datasets = dask.compute(*[_open_delayed(idx, fname) for idx, fname in zip(indices, filenames)])
2804        else:
2805            datasets = [
2806                _open_dataset_from_index(
2807                    idx,
2808                    fname,
2809                    filters,
2810                    data_model,
2811                    drop_variables=drop_variables,
2812                    chunks=chunks,
2813                )
2814                for idx, fname in zip(indices, filenames)
2815            ]
2816
2817        if preprocess is not None:
2818            datasets = [preprocess(ds) for ds in datasets]
2819
2820        combine_opt = kwargs.pop("combine", None)
2821        if combine_opt == "nested":
2822            ds = xr.combine_nested(datasets, **kwargs)
2823        elif combine_opt == "by_coords":
2824            ds = xr.combine_by_coords(datasets, **kwargs)
2825        elif combine_opt == "merge":
2826            ds = xr.merge(datasets, **kwargs)
2827        else:
2828            # Default behavior: try by_coords, then merge
2829            try:
2830                ds = xr.combine_by_coords(datasets, **kwargs)
2831            except Exception:
2832                ds = xr.merge(datasets, **kwargs)
2833    else:
2834        file_index = pd.concat(indices, ignore_index=True)
2835        ds = _open_dataset_from_index(
2836            file_index,
2837            list(filenames),
2838            filters,
2839            data_model,
2840            drop_variables=drop_variables,
2841            chunks=chunks,
2842        )
2843
2844    # Update history for provenance
2845    history = ds.attrs.get("history", "")
2846    now = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
2847    ds.attrs["history"] = f"{now}: Initialized via grib2io.open_mfdataset from {len(filenames)} files\n{history}"
2848
2849    return ds

Open multiple GRIB2 files as a single xarray Dataset.

This function is optimized for GRIB2 files by combining their indices and creating a single Dataset, which is often much faster than using xarray.open_mfdataset. It supports parallel index reading and dataset opening when parallel=True and dask is installed.

Parameters
  • filenames (str or sequence of str): GRIB2 files to be opened. Can be a glob pattern.
  • drop_variables (list of str, optional): List of variables to exclude from the dataset.
  • save_index (bool, optional): Whether to save the GRIB2 index to a file (default is True).
  • filters (dict, optional): Filter GRIB2 messages to a single hypercube. Dictionary keys can be any GRIB2 metadata attribute name.
  • data_model (str, optional): Parse GRIB metadata following a defined data model convention (e.g., "nws-viz").
  • parallel (bool, optional): If True, use dask to read indices and open datasets in parallel. Requires the dask package.
  • preprocess (callable, optional): A function to apply to each file's dataset before combining.
  • chunks (int, dict or 'auto', optional): If chunks is provided, it is used to load the dataset into a dask-backed dataset.
  • **kwargs (optional): Additional arguments passed to the combination logic. If combine='nested', passed to xarray.combine_nested. If combine='by_coords', passed to xarray.combine_by_coords. If combine='merge', passed to xarray.merge. If no combine argument is provided, the function attempts xarray.combine_by_coords followed by xarray.merge.
Returns
  • xarray.Dataset: Xarray dataset of grib2 messages.
Notes
  • This function uses a "fast path" when preprocess=None and no combination **kwargs are provided, which concatenates all indices into a single global index before building the Dataset.
  • All files must share the same horizontal grid (ny, nx).
def build_datatree_from_grib( filename: str, file_index: pandas.core.frame.DataFrame, filters: Optional[Mapping[str, Any]] = None, stack_vertical: bool = False, drop_variables: Optional[List[str]] = None, chunks: Union[int, Dict[Any, Any], Literal['auto'], NoneType] = None) -> Any:
2952def build_datatree_from_grib(
2953    filename: str,
2954    file_index: pd.DataFrame,
2955    filters: typing.Optional[typing.Mapping[str, typing.Any]] = None,
2956    stack_vertical: bool = False,
2957    drop_variables: typing.Optional[typing.List[str]] = None,
2958    chunks: typing.Optional[typing.Union[int, typing.Dict[typing.Any, typing.Any], typing.Literal["auto"]]] = None,
2959) -> typing.Any:
2960    """
2961    Build a DataTree from GRIB2 messages.
2962
2963    This internal function organizes GRIB2 messages into a hierarchical
2964    tree structure based on level types, PDTNs, and other metadata.
2965
2966    Parameters
2967    ----------
2968    filename : str
2969        Path to the source GRIB2 file.
2970    file_index : pandas.DataFrame
2971        Index of GRIB2 messages.
2972    filters : dict, optional
2973        Filter criteria for selecting messages.
2974    stack_vertical : bool, optional
2975        If True, vertical levels will be stacked in a single dataset
2976        within each node, rather than creating separate nodes per level value.
2977    drop_variables : list of str, optional
2978        List of variable shortnames to exclude.
2979    chunks : int, dict or 'auto', optional
2980        If chunks is provided, it is used to load the dataset into a
2981        dask-backed dataset.
2982
2983    Returns
2984    -------
2985    xarray.DataTree
2986        A hierarchical tree representation of the GRIB2 data.
2987    """
2988    if filters is None:
2989        filters = {}
2990
2991    # Apply any filters from user
2992    for k, v in filters.items():
2993        if k not in file_index.columns:
2994            file_index = file_index.copy()
2995            file_index[k] = file_index.msg.apply(lambda msg: getattr(msg, k, None))
2996        file_index = filter_index(file_index, k, v)
2997
2998    # Make a copy to avoid the SettingWithCopyWarning
2999    file_index = file_index.copy()
3000
3001    # Extract metadata needed for tree organization
3002    # Use a safer approach to handle missing attributes
3003    def safe_getattr(obj, name):
3004        try:
3005            attr = getattr(obj, name)
3006            # Need to test if the attribute is Grib2Metadata. If so,
3007            # then get the value attribute.
3008            if isinstance(attr, grib2io.templates.Grib2Metadata):
3009                attr = attr.value
3010            return attr
3011        except (AttributeError, KeyError):
3012            return None
3013
3014    for attr in _TREE_HIERARCHY_LEVELS:
3015        if (attr not in file_index.columns) and (attr != "valueOfFirstFixedSurface"):
3016            file_index[attr] = file_index.msg.apply(lambda msg: safe_getattr(msg, attr))
3017
3018    # Also extract shortName for variable naming
3019    if "shortName" not in file_index.columns:
3020        file_index = file_index.assign(shortName=file_index.msg.apply(lambda msg: getattr(msg, "shortName", None)))
3021
3022    if drop_variables:
3023        file_index = file_index[~file_index["shortName"].isin(drop_variables)]
3024
3025    file_index = file_index.assign(nx=file_index.msg.apply(lambda msg: getattr(msg, "nx", None)))
3026    file_index = file_index.assign(ny=file_index.msg.apply(lambda msg: getattr(msg, "ny", None)))
3027
3028    # Create root DataTree
3029    root = xr.DataTree()
3030
3031    # Adjust hierarchy levels if we're stacking vertical levels
3032    hierarchy_levels = list(_TREE_HIERARCHY_LEVELS)  # This makes a copy
3033    if stack_vertical and "valueOfFirstFixedSurface" in hierarchy_levels:
3034        hierarchy_levels.remove("valueOfFirstFixedSurface")
3035
3036    # First group by level type
3037    level_groups = {}
3038
3039    # Create a dictionary to group data by level type
3040    for level_type in file_index["typeOfFirstFixedSurface"].unique():
3041        if pd.notna(level_type):  # Skip None/NaN values
3042            level_info = _LEVEL_NAME_MAPPING.get(level_type, f"level_{level_type}")
3043            level_name = level_info[0]
3044            # Get all rows for this level type
3045            level_data = file_index[file_index["typeOfFirstFixedSurface"] == level_type]
3046            level_groups[level_type] = {"name": level_name, "data": level_data}
3047
3048    # Process each level group
3049    for level_type, group_info in level_groups.items():
3050        level_name = group_info["name"]
3051        level_df = group_info["data"]
3052
3053        # Create a branch for this level type
3054        level_tree = xr.DataTree()
3055
3056        # Process this branch based on PDTN, perturbation number, etc.
3057        process_level_branch(level_tree, level_df, filename, chunks=chunks)
3058
3059        # Add this branch to the main tree
3060        root[level_name] = level_tree
3061
3062    return root

Build a DataTree from GRIB2 messages.

This internal function organizes GRIB2 messages into a hierarchical tree structure based on level types, PDTNs, and other metadata.

Parameters
  • filename (str): Path to the source GRIB2 file.
  • file_index (pandas.DataFrame): Index of GRIB2 messages.
  • filters (dict, optional): Filter criteria for selecting messages.
  • stack_vertical (bool, optional): If True, vertical levels will be stacked in a single dataset within each node, rather than creating separate nodes per level value.
  • drop_variables (list of str, optional): List of variable shortnames to exclude.
  • chunks (int, dict or 'auto', optional): If chunks is provided, it is used to load the dataset into a dask-backed dataset.
Returns
  • xarray.DataTree: A hierarchical tree representation of the GRIB2 data.
def process_level_branch( level_tree: Any, df: pandas.core.frame.DataFrame, filename: str, chunks: Union[int, Dict[Any, Any], Literal['auto'], NoneType] = None):
3065def process_level_branch(
3066    level_tree: typing.Any,
3067    df: pd.DataFrame,
3068    filename: str,
3069    chunks: typing.Optional[typing.Union[int, typing.Dict[typing.Any, typing.Any], typing.Literal["auto"]]] = None,
3070):
3071    """
3072    Process a level type branch of the data tree.
3073
3074    Organizes the tree by PDTN and other attributes.
3075
3076    Parameters
3077    ----------
3078    level_tree : xarray.DataTree
3079        The DataTree node for this level type.
3080    df : pandas.DataFrame
3081        DataFrame of messages for this level type.
3082    filename : str
3083        Path to the GRIB2 file.
3084    chunks : int, dict or 'auto', optional
3085        If chunks is provided, it is used to load the dataset into a
3086        dask-backed dataset.
3087    """
3088    # Group by PDTN
3089    pdtn_groups = {}
3090
3091    # Group data by PDTN first
3092    for pdtn_value in df["productDefinitionTemplateNumber"].unique():
3093        if pd.notna(pdtn_value):
3094            pdtn_df = df[df["productDefinitionTemplateNumber"] == pdtn_value]
3095            pdtn_groups[pdtn_value] = pdtn_df
3096
3097    # If there's only one PDTN value, skip creating PDTN branch level
3098    if len(pdtn_groups) == 1:
3099        pdtn, pdtn_df = next(iter(pdtn_groups.items()))
3100
3101        pdtn_name = f"pdtn_{int(pdtn)}"
3102
3103        # Check if we need to further subdivide by perturbation number
3104        has_perturbations = "perturbationNumber" in pdtn_df.columns and len(pdtn_df["perturbationNumber"].dropna().unique()) > 1
3105
3106        # Check if we need to further subdivide by probabilities unique for each variable.
3107        has_probabilities = "typeOfProbability" in pdtn_df.columns and len(pdtn_df["typeOfProbability"].dropna().unique()) > 1
3108
3109        if has_perturbations:
3110            # Process perturbations directly on the level tree
3111            process_perturbation_groups(level_tree, pdtn_df, filename, chunks=chunks)
3112        elif has_probabilities:
3113            # Process probability groups
3114            process_probability_groups(level_tree, pdtn_df, filename, chunks=chunks)
3115        else:
3116            # Try to create dataset directly on level
3117            try:
3118                dss = create_datasets_from_df(pdtn_df, filename, chunks=chunks)
3119                if dss is not None:
3120                    dt = xr.DataTree()
3121                    if len(dss) == 1:
3122                        dt.ds = dss[0]
3123                    else:
3124                        for ds in dss:
3125                            varname = list(ds.data_vars)[0]
3126                            dt[f"var_{varname}"] = ds
3127                    level_tree[pdtn_name] = dt
3128                else:
3129                    # Try to separate by variable name as a fallback
3130                    try_process_by_variables(level_tree, pdtn_df, filename, chunks=chunks)
3131            except Exception as e:
3132                print(f"Error creating dataset for level with pdtn {int(pdtn)}: {e}")
3133
3134                # Try to separate by variable name as a fallback
3135                try_process_by_variables(level_tree, pdtn_df, filename, chunks=chunks)
3136    else:
3137        # Multiple PDTN values, process each group with PDTN branch nodes
3138        for pdtn, pdtn_df in pdtn_groups.items():
3139            # Use a simple node name that's easy to use in code
3140            pdtn_name = f"pdtn_{int(pdtn)}"
3141
3142            # Check if we need to further subdivide by perturbation number
3143            has_perturbations = "perturbationNumber" in pdtn_df.columns and len(pdtn_df["perturbationNumber"].dropna().unique()) > 1
3144
3145            # Check if we need to further subdivide by probabilities unique for each variable.
3146            has_probabilities = "typeOfProbability" in pdtn_df.columns and len(pdtn_df["typeOfProbability"].dropna().unique()) > 1
3147
3148            if has_perturbations:
3149                # Create a branch for this PDTN
3150                pdtn_tree = xr.DataTree()
3151
3152                # Process perturbation groups
3153                process_perturbation_groups(pdtn_tree, pdtn_df, filename, chunks=chunks)
3154
3155                # Only add the PDTN branch if it has children
3156                if len(pdtn_tree.children) > 0 or pdtn_tree.ds is not None:
3157                    level_tree[pdtn_name] = pdtn_tree
3158            elif has_probabilities:
3159                # Create a branch for this PDTN
3160                pdtn_tree = xr.DataTree()
3161
3162                # Process probability groups
3163                process_probability_groups(pdtn_tree, pdtn_df, filename, chunks=chunks)
3164
3165                # Only add the PDTN branch if it has children
3166                if len(pdtn_tree.children) > 0 or pdtn_tree.ds is not None:
3167                    level_tree[pdtn_name] = pdtn_tree
3168            else:
3169                # Create a subtree for this PDTN
3170                pdtn_tree = xr.DataTree()
3171
3172                # Try to create dataset directly on level
3173                try:
3174                    dss = create_datasets_from_df(pdtn_df, filename, chunks=chunks)
3175                    if dss is not None:
3176                        if len(dss) == 1:
3177                            pdtn_tree.ds = dss[0]
3178                        else:
3179                            for ds in dss:
3180                                varname = list(ds.data_vars)[0]
3181                                pdtn_tree[f"var_{varname}"] = ds
3182                        level_tree[pdtn_name] = pdtn_tree
3183                    else:
3184                        # Try to separate by variable name as a fallback
3185                        try_process_by_variables(pdtn_tree, pdtn_df, filename, chunks=chunks)
3186                        level_tree[pdtn_name] = pdtn_tree
3187                except Exception as e:
3188                    print(f"Error creating dataset for level with pdtn {int(pdtn)}: {e}")
3189
3190                    # Try to separate by variable name as a fallback
3191                    try_process_by_variables(pdtn_tree, pdtn_df, filename, chunks=chunks)
3192                    level_tree[pdtn_name] = pdtn_tree

Process a level type branch of the data tree.

Organizes the tree 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.
  • chunks (int, dict or 'auto', optional): If chunks is provided, it is used to load the dataset into a dask-backed dataset.
def process_probability_groups( target_tree: Any, pdtn_df: pandas.core.frame.DataFrame, filename: str, chunks: Union[int, Dict[Any, Any], Literal['auto'], NoneType] = None) -> bool:
3195def process_probability_groups(
3196    target_tree: typing.Any,
3197    pdtn_df: pd.DataFrame,
3198    filename: str,
3199    chunks: typing.Optional[typing.Union[int, typing.Dict[typing.Any, typing.Any], typing.Literal["auto"]]] = None,
3200) -> bool:
3201    """
3202    Process probability groups and add them to the target tree.
3203
3204    Parameters
3205    ----------
3206    target_tree : xarray.DataTree
3207        The tree node to add probability groups to.
3208    pdtn_df : pandas.DataFrame
3209        DataFrame of messages for a specific PDTN.
3210    filename : str
3211        Path to the GRIB2 file.
3212    chunks : int, dict or 'auto', optional
3213        If chunks is provided, it is used to load the dataset into a
3214        dask-backed dataset.
3215
3216    Returns
3217    -------
3218    bool
3219        True if successful.
3220    """
3221    success = False
3222    # Group by type of probability
3223    prob_groups = {}
3224    for prob_value in pdtn_df["typeOfProbability"].unique():
3225        if pd.notna(prob_value):
3226            prob_df = pdtn_df[pdtn_df["typeOfProbability"] == prob_value]
3227            prob_groups[prob_value] = prob_df
3228
3229    # Process each probability group
3230    for prob_num, prob_df in prob_groups.items():
3231        prob_name = f"prob_{int(prob_num)}"
3232
3233        # Try to create dataset for this probability group
3234        try:
3235            dss = create_datasets_from_df(prob_df, filename, chunks=chunks)
3236            dt = xr.DataTree()
3237            if len(dss) == 1:
3238                dt.ds = dss[0]
3239                target_tree[prob_name] = dt
3240            elif len(dss) > 1:
3241                for ds in dss:
3242                    dt[f"var_{ds.data_vars[0]}"] = ds
3243            target_tree[prob_name] = dt
3244        except Exception as e:
3245            # Log error but continue processing other groups
3246            print(f"Error creating dataset for type of probability {prob_name}: {e}")
3247
3248    return success

Process probability groups and add them to the target tree.

Parameters
  • target_tree (xarray.DataTree): The tree node to add probability groups to.
  • pdtn_df (pandas.DataFrame): DataFrame of messages for a specific PDTN.
  • filename (str): Path to the GRIB2 file.
  • chunks (int, dict or 'auto', optional): If chunks is provided, it is used to load the dataset into a dask-backed dataset.
Returns
  • bool: True if successful.
def process_perturbation_groups( target_tree: Any, pdtn_df: pandas.core.frame.DataFrame, filename: str, chunks: Union[int, Dict[Any, Any], Literal['auto'], NoneType] = None) -> bool:
3251def process_perturbation_groups(
3252    target_tree: typing.Any,
3253    pdtn_df: pd.DataFrame,
3254    filename: str,
3255    chunks: typing.Optional[typing.Union[int, typing.Dict[typing.Any, typing.Any], typing.Literal["auto"]]] = None,
3256) -> bool:
3257    """
3258    Process perturbation groups and add them to the target tree.
3259
3260    Parameters
3261    ----------
3262    target_tree : xarray.DataTree
3263        The tree node to add perturbation groups to.
3264    pdtn_df : pandas.DataFrame
3265        DataFrame of messages for a specific PDTN.
3266    filename : str
3267        Path to the GRIB2 file.
3268    chunks : int, dict or 'auto', optional
3269        If chunks is provided, it is used to load the dataset into a
3270        dask-backed dataset.
3271
3272    Returns
3273    -------
3274    bool
3275        True if at least one perturbation was successfully processed.
3276    """
3277    success = False
3278    # Group by perturbation number
3279    pert_groups = {}
3280    for pert_value in pdtn_df["perturbationNumber"].unique():
3281        if pd.notna(pert_value):
3282            pert_df = pdtn_df[pdtn_df["perturbationNumber"] == pert_value]
3283            pert_groups[pert_value] = pert_df
3284
3285    # Process each perturbation group
3286    for pert_num, pert_df in pert_groups.items():
3287        pert_name = f"pert_{int(pert_num)}"
3288
3289        ## Try to create dataset for this perturbation group
3290        # try:
3291        #    dss = create_datasets_from_df(pert_df, filename)
3292        #    if dss is not None:
3293        #        if len(dss) == 1:
3294        #            target_tree.ds = dss[0]
3295        #        else:
3296        #            dss_dict = {f"ds_{i}": ds for i, ds in enumerate(dss)}
3297        #            atree = xr.DataTree(dss_dict)
3298        #            target_tree[prob_name] = atree
3299        #        success = True
3300        # except Exception as e:
3301        #    # Log error but continue processing other groups
3302        #    print(f"Error creating dataset for perturbation {pert_name}: {e}")
3303
3304        # Try to create dataset for this perturbation group
3305        try:
3306            dss = create_datasets_from_df(pert_df, filename, chunks=chunks)
3307            dt = xr.DataTree()
3308            if len(dss) == 1:
3309                dt.ds = dss[0]
3310                target_tree[pert_name] = dt
3311            elif len(dss) > 1:
3312                for ds in dss:
3313                    dt[f"pert{ds.data_vars[0]}"] = ds
3314            target_tree[pert_name] = dt
3315        except Exception as e:
3316            # Log error but continue processing other groups
3317            print(f"Error creating dataset for perturbation {pert_name}: {e}")
3318
3319    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.
  • chunks (int, dict or 'auto', optional): If chunks is provided, it is used to load the dataset into a dask-backed dataset.
Returns
  • bool: True if at least one perturbation was successfully processed.
def try_process_by_variables( target_tree: Any, df: pandas.core.frame.DataFrame, filename: str, chunks: Union[int, Dict[Any, Any], Literal['auto'], NoneType] = None) -> bool:
3322def try_process_by_variables(
3323    target_tree: typing.Any,
3324    df: pd.DataFrame,
3325    filename: str,
3326    chunks: typing.Optional[typing.Union[int, typing.Dict[typing.Any, typing.Any], typing.Literal["auto"]]] = None,
3327) -> bool:
3328    """
3329    Try to separate data by variable names and create datasets.
3330
3331    Parameters
3332    ----------
3333    target_tree : xarray.DataTree
3334        The tree node to add variable datasets to.
3335    df : pandas.DataFrame
3336        DataFrame of messages.
3337    filename : str
3338        Path to the GRIB2 file.
3339    chunks : int, dict or 'auto', optional
3340        If chunks is provided, it is used to load the dataset into a
3341        dask-backed dataset.
3342
3343    Returns
3344    -------
3345    bool
3346        True if at least one variable was successfully processed.
3347    """
3348    success = False
3349
3350    try:
3351        for var_name in df["shortName"].unique():
3352            if pd.notna(var_name):
3353                var_df = df[df["shortName"] == var_name]
3354                try:
3355                    var_ds = create_datasets_from_df(var_df, filename, chunks=chunks)
3356                    if var_ds is not None:
3357                        target_tree[f"var_{var_name}"] = var_ds[0]
3358                        success = True
3359                except Exception as var_e:
3360                    print(f"Error creating dataset for variable {var_name}: {var_e}")
3361    except Exception as nested_e:
3362        print(f"Failed to process variables: {nested_e}")
3363
3364    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.
  • chunks (int, dict or 'auto', optional): If chunks is provided, it is used to load the dataset into a dask-backed dataset.
Returns
  • bool: True if at least one variable was successfully processed.
def create_datasets_from_df( df: pandas.core.frame.DataFrame, filename: str, verbose: bool = False, chunks: Union[int, Dict[Any, Any], Literal['auto'], NoneType] = None) -> Optional[List[xarray.core.dataset.Dataset]]:
3367def create_datasets_from_df(
3368    df: pd.DataFrame,
3369    filename: str,
3370    verbose: bool = False,
3371    chunks: typing.Optional[typing.Union[int, typing.Dict[typing.Any, typing.Any], typing.Literal["auto"]]] = None,
3372) -> typing.Optional[typing.List[xr.Dataset]]:
3373    """
3374    Create a list of xarray Datasets from a DataFrame of messages.
3375
3376    Parameters
3377    ----------
3378    df : pandas.DataFrame
3379        DataFrame of GRIB messages.
3380    filename : str
3381        Path to the GRIB2 file.
3382    verbose : bool, optional
3383        If True, prints detailed debugging information.
3384    chunks : int, dict or 'auto', optional
3385        If chunks is provided, it is used to load the dataset into a
3386        dask-backed dataset.
3387
3388    Returns
3389    -------
3390    list of xarray.Dataset, optional
3391        List of Datasets, or None if creation failed.
3392    """
3393    try:
3394        # Use parse_grib_index to get dimensions and attributes
3395        file_index, dim_coords, attrs, coord_attrs = parse_grib_index(df, {})
3396
3397        # Divide up records by variable
3398        frames, cubes, extra_geo = make_variables(file_index, filename, dim_coords, allow_uneven_dims=True)
3399
3400        if frames is None:
3401            return None
3402
3403        ds_list = []
3404        for var_df, var_cube in zip(frames, cubes):
3405            da = build_da_without_coords(var_df, var_cube, filename, attrs)
3406
3407            # Assign variable-specific coords from its cube
3408            coords = coords_from_cube(var_cube)
3409            da = da.assign_coords(coords)
3410
3411            # Assign extra index associated coords for this variable
3412            for dim_name, coord_names in dim_coords.items():
3413                retain_index_coord = False
3414                for name in coord_names:
3415                    if name == dim_name:
3416                        retain_index_coord = True
3417                    else:
3418                        if dim_name not in da.dims:
3419                            # for assigning scalar coords
3420                            coord_data = var_df[name].unique().item()
3421                            da = da.assign_coords({name: coord_data})
3422                        else:
3423                            # Handle non-scalar coords
3424                            coord_data = [
3425                                var_df[var_df.index.get_level_values(f"{dim_name}_ix") == val][name].unique().item()
3426                                for val in range(da[dim_name].size)
3427                            ]
3428                            coord = pd.Index(coord_data, name=dim_name)
3429                            da = da.assign_coords({name: (dim_name, coord)})
3430                if not retain_index_coord and dim_name in da.coords:
3431                    da = da.drop_vars(dim_name)
3432
3433            # Create a dataset for this variable
3434            var_ds = xr.Dataset({da.name: da})
3435
3436            # Assign metadata and common coords
3437            var_ds = assign_xr_meta(var_ds, [var_df], var_cube, dim_coords, extra_geo, coord_attrs)
3438
3439            if chunks is not None:
3440                var_ds = var_ds.chunk(chunks)
3441
3442            ds_list.append(var_ds)
3443
3444        return ds_list
3445    except Exception as e:
3446        if verbose:
3447            print(f"Error in create_datasets_from_df: {e}")
3448        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.
  • chunks (int, dict or 'auto', optional): If chunks is provided, it is used to load the dataset into a dask-backed dataset.
Returns
  • list of xarray.Dataset, optional: List of Datasets, or None if creation failed.
@xr.register_datatree_accessor('grib2io')
class Grib2ioDataTree:
3453    @xr.register_datatree_accessor("grib2io")
3454    class Grib2ioDataTree:
3455        """
3456        DataTree accessor for GRIB2 files.
3457
3458        This accessor provides methods for working with GRIB2 data organized
3459        in a hierarchical tree structure.
3460        """
3461
3462        def __init__(self, datatree_obj):
3463            self._obj = datatree_obj
3464
3465        def to_grib2(self, filename, mode: typing.Literal["x", "w", "a"] = "x"):
3466            """
3467            Write all datasets in the DataTree to a GRIB2 file.
3468
3469            Parameters
3470            ----------
3471            filename : str
3472                Name of the GRIB2 file to write to.
3473            mode : {"x", "w", "a"}, optional
3474                Persistence mode, default is "x" (create, fail if exists)
3475            """
3476            # Start with the specified mode
3477            current_mode = mode
3478
3479            # Function to recursively process the tree
3480            def process_tree(node):
3481                nonlocal current_mode
3482
3483                # If this is a Dataset node with data variables
3484                if node.ds is not None and node.ds.data_vars:
3485                    # Write dataset to GRIB2 file
3486                    node.ds.grib2io.to_grib2(filename, mode=current_mode)
3487                    # Switch to append mode after first write
3488                    current_mode = "a"
3489
3490                # Process children
3491                for child_name, child_node in node.children.items():
3492                    process_tree(child_node)
3493
3494            # Start processing from the root
3495            process_tree(self._obj)
3496
3497        def griddef(self):
3498            """
3499            Get the grid definition from the first dataset in the tree that has one.
3500
3501            Returns
3502            -------
3503            grib2io.Grib2GridDef
3504                Grid definition object
3505            """
3506
3507            # Function to find first dataset with GRIB2IO_section3
3508            def find_griddef(node):
3509                if node.ds is not None and node.ds.data_vars:
3510                    for var_name in node.ds.data_vars:
3511                        if "GRIB2IO_section3" in node.ds[var_name].attrs:
3512                            return Grib2GridDef.from_section3(node.ds[var_name].attrs["GRIB2IO_section3"])
3513
3514                # Check children
3515                for child_name, child_node in node.children.items():
3516                    griddef = find_griddef(child_node)
3517                    if griddef is not None:
3518                        return griddef
3519
3520                return None
3521
3522            return find_griddef(self._obj)
3523
3524        def interp(
3525            self,
3526            method: typing.Union[str, int],
3527            grid_def_out: grib2io.Grib2GridDef,
3528            method_options: typing.Optional[typing.List[int]] = None,
3529            num_threads: int = 1,
3530        ) -> typing.Any:
3531            """
3532            Interpolate all datasets in the tree to a new grid.
3533
3534            Parameters
3535            ----------
3536            method : str or int
3537                Interpolation method to use.
3538            grid_def_out : grib2io.Grib2GridDef
3539                Target grid definition.
3540            method_options : list of int, optional
3541                Options for interpolation method.
3542            num_threads : int, optional
3543                Number of threads to use for interpolation.
3544
3545            Returns
3546            -------
3547            xarray.DataTree
3548                New DataTree with interpolated data.
3549            """
3550            new_tree = xr.DataTree()
3551
3552            # Function to recursively process the tree
3553            def process_tree(node, new_parent):
3554                # If this is a Dataset node with data variables
3555                if node.ds is not None and node.ds.data_vars:
3556                    # Interpolate dataset
3557                    interp_ds = node.ds.grib2io.interp(
3558                        method,
3559                        grid_def_out,
3560                        method_options=method_options,
3561                        num_threads=num_threads,
3562                    )
3563
3564                    # Add to new tree at the same path
3565                    if node == self._obj:  # Root node
3566                        new_parent.ds = interp_ds
3567                    else:
3568                        new_parent.ds = interp_ds
3569
3570                # Process children
3571                for child_name, child_node in node.children.items():
3572                    # Create same child in new tree
3573                    new_child = xr.DataTree()
3574                    new_parent[child_name] = new_child
3575                    process_tree(child_node, new_child)
3576
3577            # Start processing from the root
3578            process_tree(self._obj, new_tree)
3579
3580            return new_tree
3581
3582        def subset(self, lats: typing.Sequence[float], lons: typing.Sequence[float]) -> typing.Any:
3583            """
3584            Subset all datasets in the tree to a region.
3585
3586            Parameters
3587            ----------
3588            lats : sequence of float
3589                Latitude bounds [min_lat, max_lat].
3590            lons : sequence of float
3591                Longitude bounds [min_lon, max_lon].
3592
3593            Returns
3594            -------
3595            xarray.DataTree
3596                New DataTree with subset data.
3597            """
3598            new_tree = xr.DataTree()
3599
3600            # Function to recursively process the tree
3601            def process_tree(node, new_parent):
3602                # If this is a Dataset node with data variables
3603                if node.ds is not None and node.ds.data_vars:
3604                    # Subset dataset
3605                    subset_ds = node.ds.grib2io.subset(lats, lons)
3606
3607                    # Add to new tree at the same path
3608                    if node == self._obj:  # Root node
3609                        new_parent.ds = subset_ds
3610                    else:
3611                        new_parent.ds = subset_ds
3612
3613                # Process children
3614                for child_name, child_node in node.children.items():
3615                    # Create same child in new tree
3616                    new_child = xr.DataTree()
3617                    new_parent[child_name] = new_child
3618                    process_tree(child_node, new_child)
3619
3620            # Start processing from the root
3621            process_tree(self._obj, new_tree)
3622
3623            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)
3462        def __init__(self, datatree_obj):
3463            self._obj = datatree_obj
def to_grib2(self, filename, mode: Literal['x', 'w', 'a'] = 'x'):
3465        def to_grib2(self, filename, mode: typing.Literal["x", "w", "a"] = "x"):
3466            """
3467            Write all datasets in the DataTree to a GRIB2 file.
3468
3469            Parameters
3470            ----------
3471            filename : str
3472                Name of the GRIB2 file to write to.
3473            mode : {"x", "w", "a"}, optional
3474                Persistence mode, default is "x" (create, fail if exists)
3475            """
3476            # Start with the specified mode
3477            current_mode = mode
3478
3479            # Function to recursively process the tree
3480            def process_tree(node):
3481                nonlocal current_mode
3482
3483                # If this is a Dataset node with data variables
3484                if node.ds is not None and node.ds.data_vars:
3485                    # Write dataset to GRIB2 file
3486                    node.ds.grib2io.to_grib2(filename, mode=current_mode)
3487                    # Switch to append mode after first write
3488                    current_mode = "a"
3489
3490                # Process children
3491                for child_name, child_node in node.children.items():
3492                    process_tree(child_node)
3493
3494            # Start processing from the root
3495            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):
3497        def griddef(self):
3498            """
3499            Get the grid definition from the first dataset in the tree that has one.
3500
3501            Returns
3502            -------
3503            grib2io.Grib2GridDef
3504                Grid definition object
3505            """
3506
3507            # Function to find first dataset with GRIB2IO_section3
3508            def find_griddef(node):
3509                if node.ds is not None and node.ds.data_vars:
3510                    for var_name in node.ds.data_vars:
3511                        if "GRIB2IO_section3" in node.ds[var_name].attrs:
3512                            return Grib2GridDef.from_section3(node.ds[var_name].attrs["GRIB2IO_section3"])
3513
3514                # Check children
3515                for child_name, child_node in node.children.items():
3516                    griddef = find_griddef(child_node)
3517                    if griddef is not None:
3518                        return griddef
3519
3520                return None
3521
3522            return find_griddef(self._obj)

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

Returns
def interp( self, method: Union[str, int], grid_def_out: grib2io.Grib2GridDef, method_options: Optional[List[int]] = None, num_threads: int = 1) -> Any:
3524        def interp(
3525            self,
3526            method: typing.Union[str, int],
3527            grid_def_out: grib2io.Grib2GridDef,
3528            method_options: typing.Optional[typing.List[int]] = None,
3529            num_threads: int = 1,
3530        ) -> typing.Any:
3531            """
3532            Interpolate all datasets in the tree to a new grid.
3533
3534            Parameters
3535            ----------
3536            method : str or int
3537                Interpolation method to use.
3538            grid_def_out : grib2io.Grib2GridDef
3539                Target grid definition.
3540            method_options : list of int, optional
3541                Options for interpolation method.
3542            num_threads : int, optional
3543                Number of threads to use for interpolation.
3544
3545            Returns
3546            -------
3547            xarray.DataTree
3548                New DataTree with interpolated data.
3549            """
3550            new_tree = xr.DataTree()
3551
3552            # Function to recursively process the tree
3553            def process_tree(node, new_parent):
3554                # If this is a Dataset node with data variables
3555                if node.ds is not None and node.ds.data_vars:
3556                    # Interpolate dataset
3557                    interp_ds = node.ds.grib2io.interp(
3558                        method,
3559                        grid_def_out,
3560                        method_options=method_options,
3561                        num_threads=num_threads,
3562                    )
3563
3564                    # Add to new tree at the same path
3565                    if node == self._obj:  # Root node
3566                        new_parent.ds = interp_ds
3567                    else:
3568                        new_parent.ds = interp_ds
3569
3570                # Process children
3571                for child_name, child_node in node.children.items():
3572                    # Create same child in new tree
3573                    new_child = xr.DataTree()
3574                    new_parent[child_name] = new_child
3575                    process_tree(child_node, new_child)
3576
3577            # Start processing from the root
3578            process_tree(self._obj, new_tree)
3579
3580            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 of int, 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: Sequence[float], lons: Sequence[float]) -> Any:
3582        def subset(self, lats: typing.Sequence[float], lons: typing.Sequence[float]) -> typing.Any:
3583            """
3584            Subset all datasets in the tree to a region.
3585
3586            Parameters
3587            ----------
3588            lats : sequence of float
3589                Latitude bounds [min_lat, max_lat].
3590            lons : sequence of float
3591                Longitude bounds [min_lon, max_lon].
3592
3593            Returns
3594            -------
3595            xarray.DataTree
3596                New DataTree with subset data.
3597            """
3598            new_tree = xr.DataTree()
3599
3600            # Function to recursively process the tree
3601            def process_tree(node, new_parent):
3602                # If this is a Dataset node with data variables
3603                if node.ds is not None and node.ds.data_vars:
3604                    # Subset dataset
3605                    subset_ds = node.ds.grib2io.subset(lats, lons)
3606
3607                    # Add to new tree at the same path
3608                    if node == self._obj:  # Root node
3609                        new_parent.ds = subset_ds
3610                    else:
3611                        new_parent.ds = subset_ds
3612
3613                # Process children
3614                for child_name, child_node in node.children.items():
3615                    # Create same child in new tree
3616                    new_child = xr.DataTree()
3617                    new_parent[child_name] = new_child
3618                    process_tree(child_node, new_child)
3619
3620            # Start processing from the root
3621            process_tree(self._obj, new_tree)
3622
3623            return new_tree

Subset all datasets in the tree to a region.

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