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-geographic coordinate names.
Available non-geographic dimension names.
Lookup table to define surface types that should be parsed as vertical coordinates
when data_model="nws-viz".
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 astypeOfFirstFixedSurface,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/valueOfSecondFixedSurfaceand their correspondingtypeOf*FixedSurfacedefinitions. 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_limitorthreshold -> threshold_upper_limitwhentypeOfProbabilityindicates 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_nameandcell_methodsare 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.
- Selected coordinates renamed:
Notes
- Precipitation type decoding uses GRIB2 Table 4.201 via
tables.get_value_from_table(code, "4.201")and returns a NumPy array withnp.dtypes.StringDType. - CF-related lookups are performed using
tables.get_table("shortname_to_cf"). - Vertical coordinate surface names are validated against
VERTICAL_COORDINATE_SURFACESbefore 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', ...]
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
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.
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.
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.
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.
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.
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
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.
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.
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.
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.
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.
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.
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.
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)
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.
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.
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.
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.
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.
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.
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)
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.
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.
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)
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.
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.
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
- **kwargs: Arguments passed to
grib2io.utils.compute_with_retries(), e.g.,max_attemptsorbase_sleep.
Returns
- xarray.Dataset: The computed Dataset with NumPy-backed data.
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)
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.
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)
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) | +------+-----------------------------------+
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.
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.
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.
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
- **kwargs: Arguments passed to
grib2io.utils.compute_with_retries(), e.g.,max_attemptsorbase_sleep.
Returns
- xarray.DataArray: The computed DataArray with NumPy-backed data.
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
daskto read indices and open datasets in parallel. Requires thedaskpackage. - 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 toxarray.combine_nested. Ifcombine='by_coords', passed toxarray.combine_by_coords. Ifcombine='merge', passed toxarray.merge. If nocombineargument is provided, the function attemptsxarray.combine_by_coordsfollowed byxarray.merge.
Returns
- xarray.Dataset: Xarray dataset of grib2 messages.
Notes
- This function uses a "fast path" when
preprocess=Noneand no combination**kwargsare provided, which concatenates all indices into a single global index before building the Dataset. - All files must share the same horizontal grid (ny, nx).
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.
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.
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.
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.
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.
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.
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.
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)
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
- grib2io.Grib2GridDef: Grid definition object
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.
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.