grib2io.kerchunk

Kerchunk Reference Manifest Generator

Provides ReferenceGenerator, which scans one or more GRIB2 files using grib2io's build_index() infrastructure and produces a Kerchunk v1 reference manifest mapping Zarr chunk keys to [url, offset, length] tuples within the original files.

The manifest can be serialized to JSON or Parquet and later opened with fsspec.filesystem("reference") to create a virtual Zarr store that reads data lazily from the original GRIB2 bytes, decoded on-the-fly by grib2io.codecs.Grib2Codec.

Example
>>> from grib2io.kerchunk import ReferenceGenerator
>>> gen = ReferenceGenerator("gfs.grib2")
>>> manifest = gen.generate()
>>> gen.to_json("gfs_refs.json")
   1"""
   2Kerchunk Reference Manifest Generator
   3======================================
   4
   5Provides :class:`ReferenceGenerator`, which scans one or more GRIB2 files
   6using grib2io's :func:`build_index` infrastructure and produces a
   7`Kerchunk v1 reference manifest <https://fsspec.github.io/kerchunk/spec>`_
   8mapping Zarr chunk keys to ``[url, offset, length]`` tuples within the
   9original files.
  10
  11The manifest can be serialized to JSON or Parquet and later opened with
  12``fsspec.filesystem("reference")`` to create a virtual Zarr store that
  13reads data lazily from the original GRIB2 bytes, decoded on-the-fly by
  14:class:`grib2io.codecs.Grib2Codec`.
  15
  16Example
  17-------
  18>>> from grib2io.kerchunk import ReferenceGenerator
  19>>> gen = ReferenceGenerator("gfs.grib2")
  20>>> manifest = gen.generate()
  21>>> gen.to_json("gfs_refs.json")
  22"""
  23
  24from __future__ import annotations
  25
  26import base64
  27import json
  28import logging
  29import os
  30import re
  31from urllib.parse import urlparse
  32from typing import Any, Dict, List, Optional, Set, Union
  33
  34import numpy as np
  35
  36import grib2io
  37
  38_logger = logging.getLogger(__name__)
  39
  40
  41# ---------------------------------------------------------------------------
  42# Lazy import guards
  43# ---------------------------------------------------------------------------
  44
  45
  46def _ensure_kerchunk():
  47    """Raise ``ImportError`` if *kerchunk* is not available."""
  48    try:
  49        import kerchunk  # noqa: F401
  50    except ImportError:
  51        raise ImportError("kerchunk is required for reference generation. Install with: pip install grib2io[kerchunk]")
  52
  53
  54def _ensure_numcodecs():
  55    """Raise ``ImportError`` if *numcodecs* is not available."""
  56    try:
  57        import numcodecs  # noqa: F401
  58    except ImportError:
  59        raise ImportError("numcodecs is required for the GRIB2 codec. Install with: pip install grib2io[kerchunk]")
  60
  61
  62# ---------------------------------------------------------------------------
  63# Dimension names used for grouping (mirrors xarray_backend logic)
  64# ---------------------------------------------------------------------------
  65
  66# These are the non-geographic dimensions that can appear in GRIB2 data.
  67# The order here determines the dimension order in the Zarr array
  68# (before the trailing y, x spatial dims).
  69_ORDERED_DIM_NAMES = [
  70    "valid_time",
  71    "perturbationNumber",
  72    "duration",
  73    "percentileValue",
  74    "level",
  75]
  76
  77# These dims are always emitted (even at size 1) so that manifests from
  78# different files can be concatenated along them without shape errors.
  79_ALWAYS_INCLUDE_DIMS = frozenset({"valid_time"})
  80
  81# Lazy-loaded level name mapping (typeOfFirstFixedSurface int -> (name, source))
  82_LEVEL_NAME_MAPPING: Optional[dict] = None
  83
  84
  85def _get_level_name_mapping() -> dict:
  86    global _LEVEL_NAME_MAPPING
  87    if _LEVEL_NAME_MAPPING is None:
  88        _LEVEL_NAME_MAPPING = grib2io.tables.get_table("4.5.grib2io.level.name")
  89    return _LEVEL_NAME_MAPPING
  90
  91
  92def _level_dim_name(msg) -> str:
  93    """Return a surface-type-specific level dimension name.
  94
  95    Mirrors the xarray backend's ``swap_dims({"level": key})`` logic so
  96    variables at different surface types get distinct dimension names
  97    (e.g. ``isobaric_surface``, ``height_above_ground``) and can
  98    coexist in a single flat xarray Dataset without conflicting sizes.
  99    """
 100    toffs = getattr(msg, "typeOfFirstFixedSurface", None)
 101    if toffs is None:
 102        return "level"
 103    val = toffs.value if hasattr(toffs, "value") else int(toffs)
 104    entry = _get_level_name_mapping().get(int(val))
 105    if entry:
 106        return entry[0]  # e.g. 'isobaric_surface', 'height_above_ground'
 107    return "level"
 108
 109
 110# ---------------------------------------------------------------------------
 111# Public API
 112# ---------------------------------------------------------------------------
 113
 114
 115class ReferenceGenerator:
 116    """Generate Kerchunk v1 reference manifests from GRIB2 files.
 117
 118    Parameters
 119    ----------
 120    file_paths : str or list of str
 121        One or more GRIB2 file paths (local paths or URIs).
 122    filters : dict, optional
 123        Filter GRIB2 messages by metadata attributes.  Keys can be any
 124        ``Grib2Message`` attribute name (e.g. ``shortName``, ``leadTime``).
 125    storage_options : dict, optional
 126        Extra options passed to ``fsspec.open`` for remote URIs
 127        (e.g. ``{"anon": True}`` for public S3 buckets).
 128    """
 129
 130    def __init__(
 131        self,
 132        file_paths: Union[str, List[str]],
 133        filters: Optional[Dict[str, Any]] = None,
 134        storage_options: Optional[Dict[str, Any]] = None,
 135        max_workers: Optional[int] = None,
 136    ):
 137        _ensure_numcodecs()
 138
 139        if isinstance(file_paths, (str, os.PathLike)):
 140            file_paths = [str(file_paths)]
 141        else:
 142            file_paths = [str(p) for p in file_paths]
 143
 144        # Validate file accessibility.
 145        # Local filesystem paths must exist. URI inputs are handled by
 146        # grib2io.open/fsspec at scan time and should not be rejected here.
 147        for fp in file_paths:
 148            if _is_local_path(fp) and not os.path.isfile(fp):
 149                raise FileNotFoundError(f"GRIB2 file not found: {fp}")
 150
 151        self.file_paths = file_paths
 152        self.filters = filters or {}
 153        self.storage_options = storage_options or {}
 154        self.max_workers = max_workers
 155        self._manifest: Optional[dict] = None
 156
 157    def generate(self) -> dict:
 158        """Scan files and produce a Kerchunk v1 reference manifest.
 159
 160        Returns
 161        -------
 162        dict
 163            Kerchunk reference spec v1 dict with keys ``"version"`` and
 164            ``"refs"``.
 165        """
 166        refs: Dict[str, Any] = {}
 167
 168        # .zgroup at root
 169        refs[".zgroup"] = json.dumps({"zarr_format": 2})
 170
 171        # Collect all messages across files, keyed by variable group
 172        # group_key = (shortName, typeOfFirstFixedSurface, pdtn, typeOfSecondFixedSurface)
 173        # This ensures messages with different surface types are not mixed.
 174        all_var_messages: Dict[tuple, list] = {}
 175
 176        n_files = len(self.file_paths)
 177        use_parallel = self.max_workers != 1 and n_files > 1 and not _is_local_path(self.file_paths[0])
 178
 179        if use_parallel:
 180            import concurrent.futures
 181
 182            workers = self.max_workers or min(n_files, 8)
 183
 184            def _scan_one(file_path):
 185                file_uri = _file_uri(file_path)
 186                local_msgs: Dict[tuple, list] = {}
 187                self._scan_file(file_path, file_uri, local_msgs)
 188                return local_msgs
 189
 190            with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as pool:
 191                futures = {pool.submit(_scan_one, fp): fp for fp in self.file_paths}
 192                for future in concurrent.futures.as_completed(futures):
 193                    fp = futures[future]
 194                    try:
 195                        local_msgs = future.result()
 196                        for key, entries in local_msgs.items():
 197                            all_var_messages.setdefault(key, []).extend(entries)
 198                    except Exception as e:
 199                        raise ValueError(f"Failed to parse GRIB2 file '{fp}': {e}") from e
 200        else:
 201            for file_path in self.file_paths:
 202                file_uri = _file_uri(file_path)
 203                try:
 204                    self._scan_file(file_path, file_uri, all_var_messages)
 205                except Exception as e:
 206                    raise ValueError(f"Failed to parse GRIB2 file '{file_path}': {e}") from e
 207
 208        # For each variable group, map messages to dimensions and build refs.
 209        # Track used variable names to handle collisions (same shortName
 210        # but different surface types).
 211        # Also track level coord name -> level values so that the same surface
 212        # type but different level extents get disambiguated names (mirrors
 213        # how the xarray backend keeps each surface type in its own Dataset).
 214        used_var_names: Dict[str, int] = {}
 215        level_coord_registry: Dict[str, list] = {}  # name -> sorted level values
 216        for group_key, msg_entries in all_var_messages.items():
 217            var_name = group_key[0]  # shortName is the first element
 218            if var_name in used_var_names:
 219                used_var_names[var_name] += 1
 220                zarr_var_name = f"{var_name}_{used_var_names[var_name]}"
 221            else:
 222                used_var_names[var_name] = 0
 223                zarr_var_name = var_name
 224            self._build_variable_refs(zarr_var_name, msg_entries, refs, level_coord_registry)
 225
 226        # Build latitude/longitude coordinate arrays from the grid definition.
 227        # All messages are assumed to share the same grid (required by the
 228        # xarray backend too), so we use the first available message.
 229        if all_var_messages:
 230            first_entries = next(iter(all_var_messages.values()))
 231            rep_msg = first_entries[0].msg
 232            _build_latlon_coord_refs(rep_msg, refs)
 233
 234        self._manifest = {"version": 1, "refs": refs}
 235        return self._manifest
 236
 237    def to_json(self, output_path: str) -> None:
 238        """Serialize the manifest to a JSON file.
 239
 240        Parameters
 241        ----------
 242        output_path : str
 243            Path to the output JSON file.
 244        """
 245        if self._manifest is None:
 246            self.generate()
 247        with open(output_path, "w") as f:
 248            json.dump(self._manifest, f)
 249
 250    def to_parquet(self, output_path: str) -> None:
 251        """Serialize the manifest to a Parquet reference store.
 252
 253        Parameters
 254        ----------
 255        output_path : str
 256            Path to the output Parquet directory.
 257        """
 258        _ensure_kerchunk()
 259        if self._manifest is None:
 260            self.generate()
 261
 262        import fsspec
 263        from fsspec.implementations.reference import LazyReferenceMapper
 264
 265        fs, _ = fsspec.core.url_to_fs(output_path)
 266        out = LazyReferenceMapper.create(output_path, fs=fs, record_size=100_000, engine="pyarrow")
 267        refs = self._manifest.get("refs", self._manifest)
 268        for k in sorted(refs):
 269            out[k] = refs[k]
 270        out.flush()
 271
 272    # ------------------------------------------------------------------
 273    # Internal scanning
 274    # ------------------------------------------------------------------
 275
 276    def _build_remote_index_filtered(
 277        self,
 278        file_path: str,
 279        shortname_filter: Optional[Union[str, Set[str]]] = None,
 280        scan_storage_options: Optional[dict] = None,
 281    ):
 282        """Build a GRIB2 index for a remote file using sidecar pre-filtering.
 283
 284        Works with any combination of filters: a ``shortName`` alone,
 285        ``shortName`` plus additional filters (e.g. ``typeOfFirstFixedSurface``
 286        / ``level``), or filters without a ``shortName`` at all.
 287
 288        Instead of fetching headers for every message (~700 HTTP requests for a
 289        full GFS 0.25° file), this method:
 290
 291        1. Checks grib2io's local cache – if the full or filtered index was
 292           saved from a previous run, it loads it instantly.
 293        2. Checks for a remote grib2io ``.grib2ioidx`` sidecar (binary index)
 294           alongside the GRIB2 file — the most efficient format, containing
 295           pre-parsed section offsets and avoiding header reads entirely.
 296        3. Fetches the wgrib2 ``.idx`` text sidecar and keeps only the byte
 297           offsets whose shortName matches the filter, reducing HTTP range
 298           requests from ~700 to ~1–50.
 299        4. Saves the partial index to a filter-specific cache key so the next
 300           call for the same file+filter is also instant.
 301        5. Falls back to ``grib2io.open`` (full index, slow on first call) if
 302           no sidecar is available.
 303
 304        Returns
 305        -------
 306        tuple(dict, list)
 307            ``(index, msgs)`` where *index* is a grib2io index dict and *msgs*
 308            is a list of :class:`~grib2io.Grib2Message` objects.
 309        """
 310        import builtins
 311        import hashlib
 312        import pickle
 313
 314        import fsspec
 315
 316        from grib2io._grib2io import build_index
 317        from grib2io import msgs_from_index
 318
 319        scan_storage_options = scan_storage_options or {}
 320        cache_root = os.path.join(os.path.expanduser("~"), ".cache", "grib2io")
 321
 322        # Open the remote file to obtain its size (one lightweight HEAD/info call).
 323        # For the filtered fast-path we override cache settings: "readahead" with
 324        # a small block size means consecutive section-header reads within the same
 325        # message share one HTTP range request instead of each triggering a new one.
 326        fh_options = dict(scan_storage_options)
 327        fh_options["default_cache_type"] = "readahead"
 328        fh_options["default_block_size"] = 4096
 329        fh = fsspec.open(file_path, "rb", **fh_options).open()
 330        try:
 331            size = int(fh.info().get("size", 0) or 0)
 332        except Exception:
 333            size = 0
 334
 335        # ------------------------------------------------------------------ #
 336        # 1. Full-index cache (populated by unfiltered grib2io.open calls)    #
 337        # ------------------------------------------------------------------ #
 338        full_cache_key = hashlib.sha1((file_path + str(size)).encode("ASCII")).hexdigest()
 339        full_cache_path = os.path.join(cache_root, f"{full_cache_key}.grib2ioidx")
 340        if os.path.exists(full_cache_path):
 341            with builtins.open(full_cache_path, "rb") as cf:
 342                index = pickle.load(cf)
 343            msgs = msgs_from_index(index, filehandle=fh)
 344            fh.close()
 345            return index, msgs
 346
 347        # ------------------------------------------------------------------ #
 348        # 2. Filter-specific partial-index cache                              #
 349        # ------------------------------------------------------------------ #
 350        filter_repr = ":".join(f"{k}={v}" for k, v in sorted(self.filters.items()))
 351        filtered_cache_key = hashlib.sha1((file_path + str(size) + ":" + filter_repr).encode("ASCII")).hexdigest()
 352        filtered_cache_path = os.path.join(cache_root, f"{filtered_cache_key}.grib2ioidx")
 353        if os.path.exists(filtered_cache_path):
 354            with builtins.open(filtered_cache_path, "rb") as cf:
 355                index = pickle.load(cf)
 356            msgs = msgs_from_index(index, filehandle=fh)
 357            fh.close()
 358            return index, msgs
 359
 360        # ------------------------------------------------------------------ #
 361        # 3. Remote grib2io index sidecar (.grib2ioidx)                       #
 362        # ------------------------------------------------------------------ #
 363        # grib2io publishes its own binary index alongside the GRIB2 file.
 364        # This is the most efficient index format — it contains the full
 365        # parsed section offsets/sizes and avoids any header reads.  Check
 366        # for it before falling back to the wgrib2 text .idx sidecar.
 367        grib2io_idx_url = file_path + ".grib2ioidx"
 368        try:
 369            with fsspec.open(grib2io_idx_url, "rb", **scan_storage_options) as gf:
 370                index = pickle.load(gf)
 371            msgs = msgs_from_index(index, filehandle=fh)
 372            fh.close()
 373            # Cache locally so subsequent calls are instant.
 374            try:
 375                os.makedirs(cache_root, exist_ok=True)
 376                with builtins.open(full_cache_path, "wb") as cf:
 377                    pickle.dump(index, cf)
 378            except Exception:
 379                pass
 380            return index, msgs
 381        except Exception:
 382            pass
 383
 384        # ------------------------------------------------------------------ #
 385        # 4. wgrib2 .idx sidecar pre-filtering                                #
 386        # ------------------------------------------------------------------ #
 387        idx_url = file_path + ".idx"
 388        idx_fetch_ok = False
 389        filtered_offsets: List[int] = []
 390        try:
 391            with fsspec.open(idx_url, "r", **scan_storage_options) as idxf:
 392                filtered_offsets = _prefilter_idx_offsets(idxf, shortname_filter, self.filters)
 393            idx_fetch_ok = True
 394        except Exception:
 395            pass
 396
 397        if idx_fetch_ok:
 398            if not filtered_offsets:
 399                # shortName simply does not appear in this file.
 400                fh.close()
 401                return {}, []
 402            index = build_index(fh, offsets=filtered_offsets)
 403            msgs = msgs_from_index(index, filehandle=fh)
 404            fh.close()
 405            # Persist the partial index so the next call is instant.
 406            try:
 407                os.makedirs(cache_root, exist_ok=True)
 408                with builtins.open(filtered_cache_path, "wb") as cf:
 409                    pickle.dump(index, cf)
 410            except Exception:
 411                pass
 412            return index, msgs
 413
 414        # ------------------------------------------------------------------ #
 415        # 5. Fall back: let grib2io.open build the full index (slow on first  #
 416        #    call, but saves to grib2io's own cache for future calls).         #
 417        # ------------------------------------------------------------------ #
 418        fh.close()
 419        with grib2io.open(file_path, save_index=True, use_index=True, **scan_storage_options) as f:
 420            return f._index, list(f)
 421
 422    def _scan_file(
 423        self,
 424        file_path: str,
 425        file_uri: str,
 426        all_var_messages: Dict[str, list],
 427    ) -> None:
 428        """Scan a single GRIB2 file and collect message entries."""
 429        if _is_local_path(file_path):
 430            with grib2io.open(file_path, save_index=False, use_index=True) as f:
 431                index = f._index
 432                msgs = list(f)
 433        else:
 434            scan_storage_options = _remote_scan_storage_options(file_path, self.storage_options)
 435            shortname_filter = self.filters.get("shortName") if self.filters else None
 436            # Normalise list/tuple to a set so _prefilter_idx_offsets can do a
 437            # fast membership test; leave scalar strings as-is.
 438            if isinstance(shortname_filter, (list, tuple)):
 439                shortname_filter = set(shortname_filter)
 440            # Fast path: resolve the index from a sidecar (.grib2ioidx or the
 441            # wgrib2 .idx) instead of fetching headers for every message.  This
 442            # works whether or not a shortName filter is given: when shortName
 443            # is present it is combined with any other filters; when it is
 444            # absent, other filters (e.g. typeOfFirstFixedSurface/level) are
 445            # still applied to the .idx, and the .grib2ioidx sidecar yields the
 446            # full parsed index with no header reads at all.
 447            index, msgs = self._build_remote_index_filtered(file_path, shortname_filter, scan_storage_options)
 448
 449        n_msgs = len(msgs)
 450        for i in range(n_msgs):
 451            msg = msgs[i]
 452
 453            # Apply filters
 454            if not self._matches_filters(msg):
 455                continue
 456
 457            sec_offsets = index["sectionOffset"][i]
 458            sec_sizes = index["sectionSize"][i]
 459            bmapflag = index["bmapflag"][i]
 460
 461            # Section 7 offset and length
 462            sec7_offset = sec_offsets[7]
 463            sec7_length = sec_sizes[7]
 464
 465            # Section 5 (data representation) — always present
 466            sec5_offset = sec_offsets[5]
 467            sec5_length = sec_sizes[5]
 468
 469            # Section 6 (bitmap) — always present (6 bytes when no bitmap)
 470            sec6_length = sec_sizes[6]
 471            # Offset only needed for the old bitmap-conditional path (kept for reference)
 472            sec6_offset = sec_offsets[6] if bmapflag in {0, 254} else None
 473
 474            # Build a composite variable key that includes the surface type
 475            # to avoid grouping messages with different surface types together.
 476            # This mirrors how the xarray backend requires filtering to a
 477            # single typeOfFirstFixedSurface.
 478            var_name = str(msg.shortName)
 479            type_of_first_fixed_surface = msg.typeOfFirstFixedSurface
 480            if hasattr(type_of_first_fixed_surface, "value"):
 481                toffs_val = type_of_first_fixed_surface.value
 482            else:
 483                toffs_val = type_of_first_fixed_surface
 484
 485            # Also include typeOfGeneratingProcess and
 486            # productDefinitionTemplateNumber to disambiguate further
 487            # (same approach as xarray backend's required_uniques)
 488            pdtn = msg.productDefinitionTemplateNumber
 489            if hasattr(pdtn, "value"):
 490                pdtn_val = pdtn.value
 491            else:
 492                pdtn_val = pdtn
 493
 494            type_of_second_fixed_surface = msg.typeOfSecondFixedSurface
 495            if hasattr(type_of_second_fixed_surface, "value"):
 496                tosfs_val = type_of_second_fixed_surface.value
 497            else:
 498                tosfs_val = type_of_second_fixed_surface
 499
 500            # Group key: shortName + surface type + pdtn + second surface type
 501            group_key = (var_name, int(toffs_val), int(pdtn_val), int(tosfs_val))
 502
 503            entry = _MsgEntry(
 504                msg=msg,
 505                file_uri=file_uri,
 506                sec5_offset=sec5_offset,
 507                sec5_length=sec5_length,
 508                sec6_length=sec6_length,
 509                sec7_offset=sec7_offset,
 510                sec7_length=sec7_length,
 511                sec6_offset=sec6_offset,
 512                bmapflag=bmapflag,
 513                index_section3=index["section3"][i],
 514                index_section5=index["section5"][i],
 515            )
 516
 517            all_var_messages.setdefault(group_key, []).append(entry)
 518
 519    def _matches_filters(self, msg) -> bool:
 520        """Check if a message matches all user-supplied filters.
 521
 522        Filter values may be:
 523
 524        * **scalar** – exact equality (``{"shortName": "TMP"}``)
 525        * **list / tuple / set** – membership test
 526          (``{"level": [500, 850, 250]}``)
 527        * **slice** – inclusive range test
 528          (``{"level": slice(500, 850)}``)
 529        """
 530        for key, value in self.filters.items():
 531            msg_val = getattr(msg, key, None)
 532            if msg_val is None:
 533                return False
 534            # Unwrap Grib2Metadata wrapper objects
 535            if hasattr(msg_val, "value"):
 536                msg_val = msg_val.value
 537            if isinstance(value, slice):
 538                lo = value.start if value.start is not None else float("-inf")
 539                hi = value.stop if value.stop is not None else float("inf")
 540                try:
 541                    if not (lo <= msg_val <= hi):
 542                        return False
 543                except TypeError:
 544                    return False
 545            elif isinstance(value, (list, tuple, set)):
 546                if msg_val not in value:
 547                    return False
 548            else:
 549                if msg_val != value:
 550                    return False
 551        return True
 552
 553    # ------------------------------------------------------------------
 554    # Variable reference building
 555    # ------------------------------------------------------------------
 556
 557    def _build_variable_refs(
 558        self,
 559        var_name: str,
 560        msg_entries: list,
 561        refs: Dict[str, Any],
 562        level_coord_registry: Optional[Dict[str, list]] = None,
 563    ) -> None:
 564        """Build all Zarr refs for a single variable."""
 565        # Derive surface-type-specific level dim name (mirrors xarray_backend)
 566        level_name = _level_dim_name(msg_entries[0].msg)
 567        # Map messages to dimensions
 568        dim_mapping = _map_messages_to_dimensions(msg_entries, level_dim_name=level_name)
 569
 570        # Disambiguate the level dim name if this surface type already appears
 571        # in the manifest with different level values.  This prevents xarray
 572        # from seeing conflicting dimension sizes when variables at the same
 573        # surface type have different level counts.
 574        if level_coord_registry is not None and level_name in dim_mapping["dim_values"]:
 575            level_vals = dim_mapping["dim_values"][level_name]
 576            if level_name in level_coord_registry:
 577                if level_coord_registry[level_name] != level_vals:
 578                    # Same surface type, different level values — append suffix
 579                    suffix = 2
 580                    candidate = f"{level_name}_{suffix}"
 581                    while candidate in level_coord_registry and level_coord_registry[candidate] != level_vals:
 582                        suffix += 1
 583                        candidate = f"{level_name}_{suffix}"
 584                    level_name = candidate
 585                    dim_mapping = _map_messages_to_dimensions(msg_entries, level_dim_name=level_name)
 586            if level_name not in level_coord_registry:
 587                level_coord_registry[level_name] = dim_mapping["dim_values"].get(level_name, [])
 588
 589        dim_names = dim_mapping["dim_names"]  # ordered list of dim names
 590        dim_values = dim_mapping["dim_values"]  # dict: dim_name -> sorted unique values
 591        msg_index_map = dim_mapping["msg_index_map"]  # dict: dim_tuple -> msg_entry index
 592
 593        # Representative message for metadata
 594        rep_msg = msg_entries[0].msg
 595
 596        # Compute shape (ensure plain Python ints for JSON serialization)
 597        shape = [len(dim_values[d]) for d in dim_names] + [int(rep_msg.ny), int(rep_msg.nx)]
 598        chunks = [1] * len(dim_names) + [int(rep_msg.ny), int(rep_msg.nx)]
 599
 600        # Build .zarray
 601        codec_config = _build_codec_config(msg_entries[0])
 602        zarray = _build_zarray_metadata(rep_msg, shape, chunks, codec_config)
 603        refs[f"{var_name}/.zarray"] = json.dumps(zarray)
 604
 605        # Build .zattrs
 606        dim_labels = dim_names + ["y", "x"]
 607        zattrs = _build_zattrs(rep_msg, dim_labels)
 608        refs[f"{var_name}/.zattrs"] = json.dumps(zattrs)
 609
 610        # Build data chunk refs
 611        for dim_tuple, entry_idx in msg_index_map.items():
 612            entry = msg_entries[entry_idx]
 613            dim_indices = []
 614            for i, d in enumerate(dim_names):
 615                val = dim_tuple[i]
 616                idx = list(dim_values[d]).index(val)
 617                dim_indices.append(idx)
 618
 619            chunk_key = _build_chunk_key(var_name, dim_indices)
 620
 621            # Store a combined reference covering sections 5+6+7 so that the
 622            # codec can parse the per-chunk data representation template (sec5)
 623            # and bitmap (sec6) dynamically.  Sections 5, 6, and 7 are always
 624            # contiguous in the GRIB2 byte stream.
 625            refs[chunk_key] = [
 626                entry.file_uri,
 627                entry.sec5_offset,
 628                entry.sec5_length + entry.sec6_length + entry.sec7_length,
 629            ]
 630
 631        # Build coordinate arrays as inline base64-encoded refs
 632        for dim_name in dim_names:
 633            values = dim_values[dim_name]
 634            _build_coord_refs(dim_name, values, refs)
 635
 636    # ------------------------------------------------------------------
 637    # Manifest access
 638    # ------------------------------------------------------------------
 639
 640    @property
 641    def manifest(self) -> Optional[dict]:
 642        """The generated manifest, or ``None`` if :meth:`generate` has not
 643        been called yet."""
 644        return self._manifest
 645
 646
 647# ---------------------------------------------------------------------------
 648# Internal data class for message entries
 649# ---------------------------------------------------------------------------
 650
 651
 652class _MsgEntry:
 653    """Lightweight container for a scanned GRIB2 message."""
 654
 655    __slots__ = (
 656        "msg",
 657        "file_uri",
 658        "sec5_offset",
 659        "sec5_length",
 660        "sec6_length",
 661        "sec7_offset",
 662        "sec7_length",
 663        "sec6_offset",
 664        "bmapflag",
 665        "index_section3",
 666        "index_section5",
 667    )
 668
 669    def __init__(
 670        self,
 671        msg,
 672        file_uri: str,
 673        sec5_offset: int,
 674        sec5_length: int,
 675        sec6_length: int,
 676        sec7_offset: int,
 677        sec7_length: int,
 678        sec6_offset: Optional[int],
 679        bmapflag: int,
 680        index_section3: np.ndarray,
 681        index_section5: np.ndarray,
 682    ):
 683        self.msg = msg
 684        self.file_uri = file_uri
 685        self.sec5_offset = sec5_offset
 686        self.sec5_length = sec5_length
 687        self.sec6_length = sec6_length
 688        self.sec7_offset = sec7_offset
 689        self.sec7_length = sec7_length
 690        self.sec6_offset = sec6_offset
 691        self.bmapflag = bmapflag
 692        self.index_section3 = index_section3
 693        self.index_section5 = index_section5
 694
 695
 696# ---------------------------------------------------------------------------
 697# Helper functions
 698# ---------------------------------------------------------------------------
 699
 700
 701def _file_uri(file_path: str) -> str:
 702    """Convert a local file path to ``file://`` URI, preserving URI inputs."""
 703    if not _is_local_path(file_path):
 704        return file_path
 705    abs_path = os.path.abspath(file_path)
 706    return f"file://{abs_path}"
 707
 708
 709def _is_local_path(path: str) -> bool:
 710    """Return ``True`` if *path* looks like a local filesystem path."""
 711    parsed = urlparse(path)
 712    return parsed.scheme == ""
 713
 714
 715def _remote_scan_storage_options(file_path: str, storage_options: Dict[str, Any]) -> Dict[str, Any]:
 716    """Build tuned fsspec options for remote metadata scans.
 717
 718    These defaults reduce accidental large-block downloads while scanning
 719    message headers/indices across large remote GRIB2 objects.
 720    """
 721    tuned = {
 722        "default_fill_cache": False,
 723        "default_cache_type": "none",
 724        "default_block_size": 131072,
 725    }
 726
 727    # Public S3 GRIB2 archives are common; default to anonymous access
 728    # unless the caller explicitly requests credentialed access.
 729    if urlparse(file_path).scheme in {"s3", "s3a"} and "anon" not in storage_options:
 730        tuned["anon"] = True
 731
 732    tuned.update(storage_options)
 733    return tuned
 734
 735
 736def _value_matches(val: float, filter_val: Any) -> bool:
 737    """Return True if *val* satisfies the scalar / list / slice *filter_val*."""
 738    if isinstance(filter_val, (list, tuple, set)):
 739        return val in filter_val or val in {float(v) for v in filter_val}
 740    if isinstance(filter_val, slice):
 741        lo = float(filter_val.start) if filter_val.start is not None else float("-inf")
 742        hi = float(filter_val.stop) if filter_val.stop is not None else float("inf")
 743        return lo <= val <= hi
 744    return val == filter_val or val == float(filter_val)
 745
 746
 747# Mapping from GRIB2 Table 4.5 typeOfFirstFixedSurface to wgrib2 .idx level
 748# string prefixes for single-valued surfaces (no numeric level component).
 749_TOFS_FIXED_STRINGS: Dict[int, tuple] = {
 750    1: ("surface", "ground or water surface"),
 751    6: ("max wind",),
 752    7: ("tropopause",),
 753    8: ("top of atmosphere", "nominal top of atmosphere"),
 754    10: ("entire atmosphere",),
 755    101: ("mean sea level",),
 756}
 757
 758
 759def _idx_level_matches(level_str: str, tofs: Any, level_filter: Any) -> bool:
 760    """Return True if the wgrib2 ``.idx`` level string is consistent with filters.
 761
 762    Conservative: returns True when the level type is unrecognised so that
 763    false negatives (silently skipping a matching message) are avoided.
 764
 765    Recognised mappings:
 766
 767    * ``typeOfFirstFixedSurface=103`` (height above ground, m):
 768      ``"2 m above ground"``
 769    * ``typeOfFirstFixedSurface=100`` (isobaric surface, Pa):
 770      ``"500 mb"`` — grib2io returns Pa so ``500 mb`` → ``level=50000``.
 771      Both hPa and Pa values are tried to handle version differences.
 772    * Fixed-label surfaces (1, 6, 7, 10, 11, 101): matched by keyword.
 773    """
 774    if tofs is None and level_filter is None:
 775        return True
 776
 777    # Height above ground in metres (tofs = 103)
 778    if tofs == 103:
 779        m = re.match(r"^(\d+(?:\.\d+)?)\s+m\s+above\s+ground", level_str)
 780        if not m:
 781            return False
 782        return level_filter is None or _value_matches(float(m.group(1)), level_filter)
 783
 784    # Isobaric surface in Pa (tofs = 100); wgrib2 uses hPa ("mb")
 785    if tofs == 100:
 786        m = re.match(r"^(\d+(?:\.\d+)?)\s+mb", level_str)
 787        if not m:
 788            return False
 789        if level_filter is None:
 790            return True
 791        idx_mb = float(m.group(1))
 792        # grib2io returns Pa (500 mb → 50000); accept both Pa and hPa
 793        return _value_matches(idx_mb * 100, level_filter) or _value_matches(idx_mb, level_filter)
 794
 795    # Fixed-label surfaces with no numeric level component
 796    if tofs in _TOFS_FIXED_STRINGS:
 797        ls = level_str.lower()
 798        return any(ls.startswith(s) for s in _TOFS_FIXED_STRINGS[tofs])
 799
 800    # Unknown surface type — keep conservatively
 801    return True
 802
 803
 804def _prefilter_idx_offsets(
 805    filehandle,
 806    shortname: Optional[Union[str, Set[str]]] = None,
 807    filters: Optional[Dict[str, Any]] = None,
 808) -> List[int]:
 809    """Parse a wgrib2 ``.idx`` sidecar and return byte offsets matching filters.
 810
 811    The wgrib2 ``.idx`` line format is::
 812
 813        MSG_NUM:BYTE_OFFSET:d=YYYYMMDDCC:SHORTNAME:LEVEL:FORECAST:
 814
 815    *shortname* may be a single string, a set/list of strings, or ``None``.
 816    When ``None`` the shortName is not constrained and every message is kept
 817    unless another filter rules it out.  When *filters* is provided, the level
 818    string (``parts[4]``) is also checked against ``typeOfFirstFixedSurface``
 819    and ``level`` entries in *filters*, which can drastically reduce the number
 820    of messages passed to :func:`build_index` (e.g. from ~50 TMP pressure
 821    levels to 1 for T2M).
 822
 823    Returns an empty list if the sidecar cannot be parsed or contains no
 824    matching messages.
 825    """
 826    names: Optional[Set[str]] = None
 827    if shortname is not None:
 828        names = {shortname} if isinstance(shortname, str) else set(shortname)
 829    tofs = filters.get("typeOfFirstFixedSurface") if filters else None
 830    level_filter = filters.get("level") if filters else None
 831    offsets: List[int] = []
 832    for line in filehandle:
 833        if isinstance(line, bytes):
 834            line = line.decode("utf-8", errors="replace")
 835        parts = line.split(":")
 836        if len(parts) >= 5 and (names is None or parts[3] in names):
 837            # Level-string pre-filter: skip if we can definitively rule out a
 838            # match from the .idx level description (e.g. "500 mb" vs "2 m
 839            # above ground").  Conservative: unknown formats are kept.
 840            if not _idx_level_matches(parts[4], tofs, level_filter):
 841                continue
 842            try:
 843                offsets.append(int(parts[1]))
 844            except ValueError:
 845                continue
 846    return offsets
 847
 848
 849def _build_chunk_key(var_name: str, dim_indices: List[int]) -> str:
 850    """Construct a Zarr chunk key like ``"TMP/0.0.0"``.
 851
 852    Parameters
 853    ----------
 854    var_name : str
 855        Variable name (top-level Zarr array name).
 856    dim_indices : list of int
 857        Integer indices along each non-spatial dimension.
 858
 859    Returns
 860    -------
 861    str
 862        Zarr chunk key, e.g. ``"TMP/0.1.0.0"`` where the trailing
 863        two zeros are for the y and x spatial dimensions (always 0
 864        since each message is one full grid).
 865    """
 866    parts = [str(i) for i in dim_indices] + ["0", "0"]
 867    return f"{var_name}/{'.'.join(parts)}"
 868
 869
 870def _build_zarray_metadata(
 871    msg,
 872    shape: List[int],
 873    chunks: List[int],
 874    codec_config: dict,
 875) -> dict:
 876    """Build ``.zarray`` JSON metadata for a variable.
 877
 878    Parameters
 879    ----------
 880    msg : Grib2Message
 881        Representative message for dtype info.
 882    shape : list of int
 883        Full array shape including spatial dims.
 884    chunks : list of int
 885        Chunk shape (one message per chunk).
 886    codec_config : dict
 887        ``Grib2Codec`` configuration dict.
 888
 889    Returns
 890    -------
 891    dict
 892        Zarr ``.zarray`` metadata.
 893    """
 894    dtype = "<f4" if msg.typeOfValues == 0 else "<i4"
 895
 896    # Place the codec config in `filters` (a list) rather than `compressor`
 897    # (a single dict). VirtualiZarr v2's translator iterates the `compressor`
 898    # field directly, which unpacks a dict's keys instead of the dict itself.
 899    # Using `filters: [codec_config]` with `compressor: null` is handled
 900    # correctly by both VirtualiZarr and zarr v2/numcodecs via fsspec.
 901    return {
 902        "zarr_format": 2,
 903        "shape": shape,
 904        "chunks": chunks,
 905        "dtype": dtype,
 906        "fill_value": "NaN" if dtype == "<f4" else 0,
 907        "order": "C",
 908        "compressor": None,
 909        "filters": [codec_config],
 910    }
 911
 912
 913def _build_zattrs(msg, dim_labels: List[str]) -> dict:
 914    """Extract GRIB2 section metadata as Zarr attributes.
 915
 916    Parameters
 917    ----------
 918    msg : Grib2Message
 919        Representative message.
 920    dim_labels : list of str
 921        Ordered dimension names including ``"y"`` and ``"x"``.
 922
 923    Returns
 924    -------
 925    dict
 926        Zarr ``.zattrs`` metadata.
 927    """
 928    # Extract typeOfFirstFixedSurface - handle Grib2Metadata objects
 929    type_of_first_fixed_surface = msg.typeOfFirstFixedSurface
 930    if hasattr(type_of_first_fixed_surface, "value"):
 931        type_of_first_fixed_surface = type_of_first_fixed_surface.value
 932
 933    # Extract valueOfFirstFixedSurface
 934    value_of_first_fixed_surface = msg.valueOfFirstFixedSurface
 935    if hasattr(value_of_first_fixed_surface, "value"):
 936        value_of_first_fixed_surface = value_of_first_fixed_surface.value
 937
 938    # Extract valid_time (= refDate + leadTime = msg.validDate)
 939    vt = getattr(msg, "validDate", None)
 940    if vt is None:
 941        try:
 942            vt = msg.refDate + msg.leadTime
 943        except Exception:
 944            vt = msg.refDate
 945    if hasattr(vt, "isoformat"):
 946        valid_time_str = vt.isoformat()
 947    elif isinstance(vt, np.datetime64):
 948        valid_time_str = str(vt)
 949    else:
 950        valid_time_str = str(vt)
 951
 952    return {
 953        "_ARRAY_DIMENSIONS": dim_labels,
 954        "coordinates": "latitude longitude",
 955        "discipline": int(msg.section0[2]),
 956        "parameterCategory": int(msg.parameterCategory),
 957        "parameterNumber": int(msg.parameterNumber),
 958        "typeOfFirstFixedSurface": int(type_of_first_fixed_surface),
 959        "valueOfFirstFixedSurface": float(value_of_first_fixed_surface),
 960        "valid_time": valid_time_str,
 961        "shortName": str(msg.shortName),
 962        "fullName": str(msg.fullName),
 963        "units": str(msg.units),
 964    }
 965
 966
 967def _build_codec_config(entry: _MsgEntry) -> dict:
 968    """Build ``Grib2Codec`` configuration from a message entry.
 969
 970    Parameters
 971    ----------
 972    entry : _MsgEntry
 973        Scanned message entry with index metadata.
 974
 975    Returns
 976    -------
 977    dict
 978        Codec configuration suitable for ``Grib2Codec.from_config()``.
 979    """
 980    msg = entry.msg
 981    sec3 = entry.index_section3
 982    sec5 = entry.index_section5
 983
 984    # GDS: first 5 elements of section 3
 985    gds = [int(x) for x in sec3[:5]]
 986    # GDT: remaining elements of section 3
 987    gdt = [int(x) for x in sec3[5:]]
 988    # DRT number and template
 989    drtn = int(sec5[1])
 990    drt = [int(x) for x in sec5[2:]]
 991
 992    # Grid dimensions
 993    nx = int(msg.nx)
 994    ny = int(msg.ny)
 995
 996    # Scan mode flags
 997    scan_mode_flags = None
 998    if hasattr(msg, "scanModeFlags"):
 999        scan_mode_flags = [int(x) for x in msg.scanModeFlags]
1000
1001    # Bitmap info
1002    bitmap_flag = int(entry.bmapflag)
1003    bitmap_offset = None
1004    bitmap_length = None
1005    if bitmap_flag in {0, 254} and entry.sec6_offset is not None:
1006        bitmap_offset = int(entry.sec6_offset)
1007        bitmap_length = int(entry.sec6_length)
1008
1009    # Number of data points and packed values
1010    number_of_data_points = int(msg.numberOfDataPoints)
1011    number_of_packed_values = int(msg.numberOfPackedValues)
1012
1013    # Type of values
1014    type_of_values = int(msg.typeOfValues) if hasattr(msg, "typeOfValues") else 0
1015
1016    # Emit a Zarr v2/v3-compatible codec config dict for VirtualiZarr compatibility.
1017    config = {
1018        "id": "grib2io",
1019        "drtn": drtn,
1020        "drt": drt,
1021        "gdtn": int(msg.gdtn),
1022        "gdt": gdt,
1023        "gds": gds,
1024        "nx": nx,
1025        "ny": ny,
1026        "bitmap_flag": bitmap_flag,
1027        "bitmap_offset": bitmap_offset,
1028        "bitmap_length": bitmap_length,
1029        "scan_mode_flags": scan_mode_flags,
1030        "type_of_values": type_of_values,
1031        "number_of_data_points": number_of_data_points,
1032        "number_of_packed_values": number_of_packed_values,
1033    }
1034    # For VirtualiZarr, the 'compressor' field must be a dict, not a string or id.
1035    return config
1036
1037
1038def _get_dim_value(msg, dim_name: str) -> Any:
1039    """Extract a dimension coordinate value from a message.
1040
1041    Parameters
1042    ----------
1043    msg : Grib2Message
1044        The GRIB2 message.
1045    dim_name : str
1046        Dimension name.
1047
1048    Returns
1049    -------
1050    Any
1051        The coordinate value, converted to a hashable/sortable type.
1052    """
1053    if dim_name == "level":
1054        # Use the tuple (valueOfFirstFixedSurface, valueOfSecondFixedSurface)
1055        # as the level identifier, matching xarray_backend logic
1056        v1 = msg.valueOfFirstFixedSurface
1057        v2 = msg.valueOfSecondFixedSurface
1058        return (float(v1), float(v2))
1059    elif dim_name == "valid_time":
1060        # valid_time = refDate + leadTime (i.e. msg.validDate)
1061        vt = getattr(msg, "validDate", None)
1062        if vt is None:
1063            rd = msg.refDate
1064            lt = msg.leadTime
1065            try:
1066                vt = rd + lt
1067            except Exception:
1068                vt = rd
1069        if hasattr(vt, "isoformat"):
1070            return vt.isoformat()
1071        if isinstance(vt, np.datetime64):
1072            return str(vt)
1073        return str(vt)
1074    elif dim_name == "duration":
1075        d = msg.duration
1076        if hasattr(d, "total_seconds"):
1077            return d.total_seconds()
1078        return str(d)
1079    else:
1080        val = getattr(msg, dim_name, None)
1081        if hasattr(val, "value"):
1082            val = val.value
1083        return val
1084
1085
1086def _map_messages_to_dimensions(
1087    msg_entries: List[_MsgEntry],
1088    level_dim_name: str = "level",
1089) -> dict:
1090    """Group messages by variable and map to dimension indices.
1091
1092    This mirrors the logic in ``parse_grib_index()`` from the xarray
1093    backend: for each message, extract the values of potential dimension
1094    coordinates (level, leadTime, refDate, perturbationNumber, etc.),
1095    determine which dimensions have more than one unique value, and
1096    build a mapping from dimension-value tuples to message indices.
1097
1098    Parameters
1099    ----------
1100    msg_entries : list of _MsgEntry
1101        All message entries for a single variable.
1102
1103    Returns
1104    -------
1105    dict
1106        Dictionary with keys:
1107        - ``"dim_names"``: ordered list of active dimension names
1108        - ``"dim_values"``: dict mapping dim name to sorted unique values
1109        - ``"msg_index_map"``: dict mapping dim-value tuple to entry index
1110    """
1111    # Build ordered dim names, substituting the surface-type-specific level name
1112    ordered_dims = [level_dim_name if d == "level" else d for d in _ORDERED_DIM_NAMES]
1113
1114    # Collect dimension values for each message
1115    all_dim_vals: Dict[str, list] = {d: [] for d in ordered_dims}
1116
1117    for entry in msg_entries:
1118        msg = entry.msg
1119        for dim_name in ordered_dims:
1120            # Map the (possibly renamed) level dim back to "level" for _get_dim_value
1121            orig_name = "level" if dim_name == level_dim_name else dim_name
1122            try:
1123                val = _get_dim_value(msg, orig_name)
1124                all_dim_vals[dim_name].append(val)
1125            except (AttributeError, TypeError):
1126                all_dim_vals[dim_name].append(None)
1127
1128    # Determine which dimensions are active (have >1 unique value)
1129    active_dims = []
1130    dim_values: Dict[str, list] = {}
1131
1132    for dim_name in ordered_dims:
1133        vals = all_dim_vals[dim_name]
1134        # Filter out None values
1135        non_none = [v for v in vals if v is not None]
1136        if not non_none:
1137            continue
1138        unique_vals = sorted(set(non_none))
1139        if len(unique_vals) > 1:
1140            active_dims.append(dim_name)
1141            dim_values[dim_name] = unique_vals
1142        elif len(unique_vals) == 1:
1143            # Always emit valid_time and the level dim (so multi-file concat
1144            # can grow those axes).  All other dims are optional: only emit
1145            # them when they actually vary within this variable group.
1146            if dim_name in _ALWAYS_INCLUDE_DIMS or dim_name == level_dim_name:
1147                active_dims.append(dim_name)
1148                dim_values[dim_name] = unique_vals
1149
1150    # If no dimensions are active at all, fall back to a single valid_time
1151    if not active_dims:
1152        msg = msg_entries[0].msg
1153        vt = _get_dim_value(msg, "valid_time")
1154        active_dims = ["valid_time"]
1155        dim_values = {"valid_time": [vt]}
1156
1157    # Remap back so callers can use dim_names as coordinate keys directly
1158    # (level_dim_name is already baked in via ordered_dims)
1159
1160    # Build the mapping from dimension-value tuples to entry indices
1161    msg_index_map: Dict[tuple, int] = {}
1162    for idx, entry in enumerate(msg_entries):
1163        msg = entry.msg
1164        dim_tuple = tuple(_get_dim_value(msg, "level" if d == level_dim_name else d) for d in active_dims)
1165        msg_index_map[dim_tuple] = idx
1166
1167    return {
1168        "dim_names": active_dims,
1169        "dim_values": dim_values,
1170        "msg_index_map": msg_index_map,
1171    }
1172
1173
1174def _build_coord_refs(
1175    dim_name: str,
1176    values: list,
1177    refs: Dict[str, Any],
1178) -> None:
1179    """Build inline base64-encoded coordinate array refs.
1180
1181    Parameters
1182    ----------
1183    dim_name : str
1184        Coordinate/dimension name.
1185    values : list
1186        Sorted unique coordinate values.
1187    refs : dict
1188        The refs dict to populate.
1189    """
1190    if values and isinstance(values[0], tuple):
1191        # Level-type coordinate: values are (v1, v2) tuples; use v1
1192        coord_values = np.array(
1193            [v[0] if isinstance(v, tuple) else float(v) for v in values],
1194            dtype=np.float64,
1195        )
1196    elif dim_name == "valid_time":
1197        # Store as int64 nanoseconds since epoch so xarray decodes as datetime64
1198        ns_vals = [int(np.datetime64(v, "ns").astype(np.int64)) for v in values]
1199        coord_values = np.array(ns_vals, dtype=np.int64)
1200    elif dim_name == "duration":
1201        # Store as float seconds
1202        coord_values = np.array(values, dtype=np.float64)
1203    elif dim_name == "perturbationNumber":
1204        coord_values = np.array(values, dtype=np.int32)
1205    elif dim_name == "percentileValue":
1206        coord_values = np.array(values, dtype=np.float64)
1207    else:
1208        coord_values = np.array(values, dtype=np.float64)
1209
1210    # Encode as base64
1211    raw_bytes = coord_values.tobytes()
1212    b64_data = base64.b64encode(raw_bytes).decode("ascii")
1213
1214    # .zarray for the coordinate
1215    coord_zarray = {
1216        "zarr_format": 2,
1217        "shape": [len(values)],
1218        "chunks": [len(values)],
1219        "dtype": coord_values.dtype.str,
1220        "fill_value": None if coord_values.dtype.kind in {"U", "S"} else 0,
1221        "order": "C",
1222        "compressor": None,
1223        "filters": None,
1224    }
1225    refs[f"{dim_name}/.zarray"] = json.dumps(coord_zarray)
1226
1227    # .zattrs for the coordinate
1228    coord_zattrs: dict = {"_ARRAY_DIMENSIONS": [dim_name]}
1229    if dim_name == "valid_time":
1230        # CF-compliant time metadata so xarray decodes int64 ns as datetime64
1231        coord_zattrs["units"] = "nanoseconds since 1970-01-01T00:00:00"
1232        coord_zattrs["calendar"] = "proleptic_gregorian"
1233    refs[f"{dim_name}/.zattrs"] = json.dumps(coord_zattrs)
1234
1235    # Inline data chunk
1236    refs[f"{dim_name}/0"] = "base64:" + b64_data
1237
1238
1239def _build_latlon_coord_refs(msg, refs: Dict[str, Any]) -> None:
1240    """Build inline latitude/longitude 2-D coordinate arrays from the grid.
1241
1242    Calls ``msg.latlons()`` to compute the full (ny, nx) grids and encodes
1243    them as base64 inline Zarr refs, matching the xarray backend's behaviour.
1244    """
1245    try:
1246        lats, lons = msg.latlons()
1247    except Exception:
1248        return
1249
1250    ny, nx = int(msg.ny), int(msg.nx)
1251
1252    for name, data, attrs in [
1253        (
1254            "latitude",
1255            lats.astype(np.float64),
1256            {
1257                "_ARRAY_DIMENSIONS": ["y", "x"],
1258                "standard_name": "latitude",
1259                "units": "degrees_north",
1260            },
1261        ),
1262        (
1263            "longitude",
1264            lons.astype(np.float64),
1265            {
1266                "_ARRAY_DIMENSIONS": ["y", "x"],
1267                "standard_name": "longitude",
1268                "units": "degrees_east",
1269            },
1270        ),
1271    ]:
1272        # Skip if already present (e.g. from a prior variable group)
1273        if f"{name}/.zarray" in refs:
1274            continue
1275
1276        zarray = {
1277            "zarr_format": 2,
1278            "shape": [ny, nx],
1279            "chunks": [ny, nx],
1280            "dtype": "<f8",
1281            "fill_value": None,
1282            "order": "C",
1283            "compressor": None,
1284            "filters": None,
1285        }
1286        refs[f"{name}/.zarray"] = json.dumps(zarray)
1287        refs[f"{name}/.zattrs"] = json.dumps(attrs)
1288        refs[f"{name}/0.0"] = "base64:" + base64.b64encode(data.tobytes()).decode("ascii")
class ReferenceGenerator:
116class ReferenceGenerator:
117    """Generate Kerchunk v1 reference manifests from GRIB2 files.
118
119    Parameters
120    ----------
121    file_paths : str or list of str
122        One or more GRIB2 file paths (local paths or URIs).
123    filters : dict, optional
124        Filter GRIB2 messages by metadata attributes.  Keys can be any
125        ``Grib2Message`` attribute name (e.g. ``shortName``, ``leadTime``).
126    storage_options : dict, optional
127        Extra options passed to ``fsspec.open`` for remote URIs
128        (e.g. ``{"anon": True}`` for public S3 buckets).
129    """
130
131    def __init__(
132        self,
133        file_paths: Union[str, List[str]],
134        filters: Optional[Dict[str, Any]] = None,
135        storage_options: Optional[Dict[str, Any]] = None,
136        max_workers: Optional[int] = None,
137    ):
138        _ensure_numcodecs()
139
140        if isinstance(file_paths, (str, os.PathLike)):
141            file_paths = [str(file_paths)]
142        else:
143            file_paths = [str(p) for p in file_paths]
144
145        # Validate file accessibility.
146        # Local filesystem paths must exist. URI inputs are handled by
147        # grib2io.open/fsspec at scan time and should not be rejected here.
148        for fp in file_paths:
149            if _is_local_path(fp) and not os.path.isfile(fp):
150                raise FileNotFoundError(f"GRIB2 file not found: {fp}")
151
152        self.file_paths = file_paths
153        self.filters = filters or {}
154        self.storage_options = storage_options or {}
155        self.max_workers = max_workers
156        self._manifest: Optional[dict] = None
157
158    def generate(self) -> dict:
159        """Scan files and produce a Kerchunk v1 reference manifest.
160
161        Returns
162        -------
163        dict
164            Kerchunk reference spec v1 dict with keys ``"version"`` and
165            ``"refs"``.
166        """
167        refs: Dict[str, Any] = {}
168
169        # .zgroup at root
170        refs[".zgroup"] = json.dumps({"zarr_format": 2})
171
172        # Collect all messages across files, keyed by variable group
173        # group_key = (shortName, typeOfFirstFixedSurface, pdtn, typeOfSecondFixedSurface)
174        # This ensures messages with different surface types are not mixed.
175        all_var_messages: Dict[tuple, list] = {}
176
177        n_files = len(self.file_paths)
178        use_parallel = self.max_workers != 1 and n_files > 1 and not _is_local_path(self.file_paths[0])
179
180        if use_parallel:
181            import concurrent.futures
182
183            workers = self.max_workers or min(n_files, 8)
184
185            def _scan_one(file_path):
186                file_uri = _file_uri(file_path)
187                local_msgs: Dict[tuple, list] = {}
188                self._scan_file(file_path, file_uri, local_msgs)
189                return local_msgs
190
191            with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as pool:
192                futures = {pool.submit(_scan_one, fp): fp for fp in self.file_paths}
193                for future in concurrent.futures.as_completed(futures):
194                    fp = futures[future]
195                    try:
196                        local_msgs = future.result()
197                        for key, entries in local_msgs.items():
198                            all_var_messages.setdefault(key, []).extend(entries)
199                    except Exception as e:
200                        raise ValueError(f"Failed to parse GRIB2 file '{fp}': {e}") from e
201        else:
202            for file_path in self.file_paths:
203                file_uri = _file_uri(file_path)
204                try:
205                    self._scan_file(file_path, file_uri, all_var_messages)
206                except Exception as e:
207                    raise ValueError(f"Failed to parse GRIB2 file '{file_path}': {e}") from e
208
209        # For each variable group, map messages to dimensions and build refs.
210        # Track used variable names to handle collisions (same shortName
211        # but different surface types).
212        # Also track level coord name -> level values so that the same surface
213        # type but different level extents get disambiguated names (mirrors
214        # how the xarray backend keeps each surface type in its own Dataset).
215        used_var_names: Dict[str, int] = {}
216        level_coord_registry: Dict[str, list] = {}  # name -> sorted level values
217        for group_key, msg_entries in all_var_messages.items():
218            var_name = group_key[0]  # shortName is the first element
219            if var_name in used_var_names:
220                used_var_names[var_name] += 1
221                zarr_var_name = f"{var_name}_{used_var_names[var_name]}"
222            else:
223                used_var_names[var_name] = 0
224                zarr_var_name = var_name
225            self._build_variable_refs(zarr_var_name, msg_entries, refs, level_coord_registry)
226
227        # Build latitude/longitude coordinate arrays from the grid definition.
228        # All messages are assumed to share the same grid (required by the
229        # xarray backend too), so we use the first available message.
230        if all_var_messages:
231            first_entries = next(iter(all_var_messages.values()))
232            rep_msg = first_entries[0].msg
233            _build_latlon_coord_refs(rep_msg, refs)
234
235        self._manifest = {"version": 1, "refs": refs}
236        return self._manifest
237
238    def to_json(self, output_path: str) -> None:
239        """Serialize the manifest to a JSON file.
240
241        Parameters
242        ----------
243        output_path : str
244            Path to the output JSON file.
245        """
246        if self._manifest is None:
247            self.generate()
248        with open(output_path, "w") as f:
249            json.dump(self._manifest, f)
250
251    def to_parquet(self, output_path: str) -> None:
252        """Serialize the manifest to a Parquet reference store.
253
254        Parameters
255        ----------
256        output_path : str
257            Path to the output Parquet directory.
258        """
259        _ensure_kerchunk()
260        if self._manifest is None:
261            self.generate()
262
263        import fsspec
264        from fsspec.implementations.reference import LazyReferenceMapper
265
266        fs, _ = fsspec.core.url_to_fs(output_path)
267        out = LazyReferenceMapper.create(output_path, fs=fs, record_size=100_000, engine="pyarrow")
268        refs = self._manifest.get("refs", self._manifest)
269        for k in sorted(refs):
270            out[k] = refs[k]
271        out.flush()
272
273    # ------------------------------------------------------------------
274    # Internal scanning
275    # ------------------------------------------------------------------
276
277    def _build_remote_index_filtered(
278        self,
279        file_path: str,
280        shortname_filter: Optional[Union[str, Set[str]]] = None,
281        scan_storage_options: Optional[dict] = None,
282    ):
283        """Build a GRIB2 index for a remote file using sidecar pre-filtering.
284
285        Works with any combination of filters: a ``shortName`` alone,
286        ``shortName`` plus additional filters (e.g. ``typeOfFirstFixedSurface``
287        / ``level``), or filters without a ``shortName`` at all.
288
289        Instead of fetching headers for every message (~700 HTTP requests for a
290        full GFS 0.25° file), this method:
291
292        1. Checks grib2io's local cache – if the full or filtered index was
293           saved from a previous run, it loads it instantly.
294        2. Checks for a remote grib2io ``.grib2ioidx`` sidecar (binary index)
295           alongside the GRIB2 file — the most efficient format, containing
296           pre-parsed section offsets and avoiding header reads entirely.
297        3. Fetches the wgrib2 ``.idx`` text sidecar and keeps only the byte
298           offsets whose shortName matches the filter, reducing HTTP range
299           requests from ~700 to ~1–50.
300        4. Saves the partial index to a filter-specific cache key so the next
301           call for the same file+filter is also instant.
302        5. Falls back to ``grib2io.open`` (full index, slow on first call) if
303           no sidecar is available.
304
305        Returns
306        -------
307        tuple(dict, list)
308            ``(index, msgs)`` where *index* is a grib2io index dict and *msgs*
309            is a list of :class:`~grib2io.Grib2Message` objects.
310        """
311        import builtins
312        import hashlib
313        import pickle
314
315        import fsspec
316
317        from grib2io._grib2io import build_index
318        from grib2io import msgs_from_index
319
320        scan_storage_options = scan_storage_options or {}
321        cache_root = os.path.join(os.path.expanduser("~"), ".cache", "grib2io")
322
323        # Open the remote file to obtain its size (one lightweight HEAD/info call).
324        # For the filtered fast-path we override cache settings: "readahead" with
325        # a small block size means consecutive section-header reads within the same
326        # message share one HTTP range request instead of each triggering a new one.
327        fh_options = dict(scan_storage_options)
328        fh_options["default_cache_type"] = "readahead"
329        fh_options["default_block_size"] = 4096
330        fh = fsspec.open(file_path, "rb", **fh_options).open()
331        try:
332            size = int(fh.info().get("size", 0) or 0)
333        except Exception:
334            size = 0
335
336        # ------------------------------------------------------------------ #
337        # 1. Full-index cache (populated by unfiltered grib2io.open calls)    #
338        # ------------------------------------------------------------------ #
339        full_cache_key = hashlib.sha1((file_path + str(size)).encode("ASCII")).hexdigest()
340        full_cache_path = os.path.join(cache_root, f"{full_cache_key}.grib2ioidx")
341        if os.path.exists(full_cache_path):
342            with builtins.open(full_cache_path, "rb") as cf:
343                index = pickle.load(cf)
344            msgs = msgs_from_index(index, filehandle=fh)
345            fh.close()
346            return index, msgs
347
348        # ------------------------------------------------------------------ #
349        # 2. Filter-specific partial-index cache                              #
350        # ------------------------------------------------------------------ #
351        filter_repr = ":".join(f"{k}={v}" for k, v in sorted(self.filters.items()))
352        filtered_cache_key = hashlib.sha1((file_path + str(size) + ":" + filter_repr).encode("ASCII")).hexdigest()
353        filtered_cache_path = os.path.join(cache_root, f"{filtered_cache_key}.grib2ioidx")
354        if os.path.exists(filtered_cache_path):
355            with builtins.open(filtered_cache_path, "rb") as cf:
356                index = pickle.load(cf)
357            msgs = msgs_from_index(index, filehandle=fh)
358            fh.close()
359            return index, msgs
360
361        # ------------------------------------------------------------------ #
362        # 3. Remote grib2io index sidecar (.grib2ioidx)                       #
363        # ------------------------------------------------------------------ #
364        # grib2io publishes its own binary index alongside the GRIB2 file.
365        # This is the most efficient index format — it contains the full
366        # parsed section offsets/sizes and avoids any header reads.  Check
367        # for it before falling back to the wgrib2 text .idx sidecar.
368        grib2io_idx_url = file_path + ".grib2ioidx"
369        try:
370            with fsspec.open(grib2io_idx_url, "rb", **scan_storage_options) as gf:
371                index = pickle.load(gf)
372            msgs = msgs_from_index(index, filehandle=fh)
373            fh.close()
374            # Cache locally so subsequent calls are instant.
375            try:
376                os.makedirs(cache_root, exist_ok=True)
377                with builtins.open(full_cache_path, "wb") as cf:
378                    pickle.dump(index, cf)
379            except Exception:
380                pass
381            return index, msgs
382        except Exception:
383            pass
384
385        # ------------------------------------------------------------------ #
386        # 4. wgrib2 .idx sidecar pre-filtering                                #
387        # ------------------------------------------------------------------ #
388        idx_url = file_path + ".idx"
389        idx_fetch_ok = False
390        filtered_offsets: List[int] = []
391        try:
392            with fsspec.open(idx_url, "r", **scan_storage_options) as idxf:
393                filtered_offsets = _prefilter_idx_offsets(idxf, shortname_filter, self.filters)
394            idx_fetch_ok = True
395        except Exception:
396            pass
397
398        if idx_fetch_ok:
399            if not filtered_offsets:
400                # shortName simply does not appear in this file.
401                fh.close()
402                return {}, []
403            index = build_index(fh, offsets=filtered_offsets)
404            msgs = msgs_from_index(index, filehandle=fh)
405            fh.close()
406            # Persist the partial index so the next call is instant.
407            try:
408                os.makedirs(cache_root, exist_ok=True)
409                with builtins.open(filtered_cache_path, "wb") as cf:
410                    pickle.dump(index, cf)
411            except Exception:
412                pass
413            return index, msgs
414
415        # ------------------------------------------------------------------ #
416        # 5. Fall back: let grib2io.open build the full index (slow on first  #
417        #    call, but saves to grib2io's own cache for future calls).         #
418        # ------------------------------------------------------------------ #
419        fh.close()
420        with grib2io.open(file_path, save_index=True, use_index=True, **scan_storage_options) as f:
421            return f._index, list(f)
422
423    def _scan_file(
424        self,
425        file_path: str,
426        file_uri: str,
427        all_var_messages: Dict[str, list],
428    ) -> None:
429        """Scan a single GRIB2 file and collect message entries."""
430        if _is_local_path(file_path):
431            with grib2io.open(file_path, save_index=False, use_index=True) as f:
432                index = f._index
433                msgs = list(f)
434        else:
435            scan_storage_options = _remote_scan_storage_options(file_path, self.storage_options)
436            shortname_filter = self.filters.get("shortName") if self.filters else None
437            # Normalise list/tuple to a set so _prefilter_idx_offsets can do a
438            # fast membership test; leave scalar strings as-is.
439            if isinstance(shortname_filter, (list, tuple)):
440                shortname_filter = set(shortname_filter)
441            # Fast path: resolve the index from a sidecar (.grib2ioidx or the
442            # wgrib2 .idx) instead of fetching headers for every message.  This
443            # works whether or not a shortName filter is given: when shortName
444            # is present it is combined with any other filters; when it is
445            # absent, other filters (e.g. typeOfFirstFixedSurface/level) are
446            # still applied to the .idx, and the .grib2ioidx sidecar yields the
447            # full parsed index with no header reads at all.
448            index, msgs = self._build_remote_index_filtered(file_path, shortname_filter, scan_storage_options)
449
450        n_msgs = len(msgs)
451        for i in range(n_msgs):
452            msg = msgs[i]
453
454            # Apply filters
455            if not self._matches_filters(msg):
456                continue
457
458            sec_offsets = index["sectionOffset"][i]
459            sec_sizes = index["sectionSize"][i]
460            bmapflag = index["bmapflag"][i]
461
462            # Section 7 offset and length
463            sec7_offset = sec_offsets[7]
464            sec7_length = sec_sizes[7]
465
466            # Section 5 (data representation) — always present
467            sec5_offset = sec_offsets[5]
468            sec5_length = sec_sizes[5]
469
470            # Section 6 (bitmap) — always present (6 bytes when no bitmap)
471            sec6_length = sec_sizes[6]
472            # Offset only needed for the old bitmap-conditional path (kept for reference)
473            sec6_offset = sec_offsets[6] if bmapflag in {0, 254} else None
474
475            # Build a composite variable key that includes the surface type
476            # to avoid grouping messages with different surface types together.
477            # This mirrors how the xarray backend requires filtering to a
478            # single typeOfFirstFixedSurface.
479            var_name = str(msg.shortName)
480            type_of_first_fixed_surface = msg.typeOfFirstFixedSurface
481            if hasattr(type_of_first_fixed_surface, "value"):
482                toffs_val = type_of_first_fixed_surface.value
483            else:
484                toffs_val = type_of_first_fixed_surface
485
486            # Also include typeOfGeneratingProcess and
487            # productDefinitionTemplateNumber to disambiguate further
488            # (same approach as xarray backend's required_uniques)
489            pdtn = msg.productDefinitionTemplateNumber
490            if hasattr(pdtn, "value"):
491                pdtn_val = pdtn.value
492            else:
493                pdtn_val = pdtn
494
495            type_of_second_fixed_surface = msg.typeOfSecondFixedSurface
496            if hasattr(type_of_second_fixed_surface, "value"):
497                tosfs_val = type_of_second_fixed_surface.value
498            else:
499                tosfs_val = type_of_second_fixed_surface
500
501            # Group key: shortName + surface type + pdtn + second surface type
502            group_key = (var_name, int(toffs_val), int(pdtn_val), int(tosfs_val))
503
504            entry = _MsgEntry(
505                msg=msg,
506                file_uri=file_uri,
507                sec5_offset=sec5_offset,
508                sec5_length=sec5_length,
509                sec6_length=sec6_length,
510                sec7_offset=sec7_offset,
511                sec7_length=sec7_length,
512                sec6_offset=sec6_offset,
513                bmapflag=bmapflag,
514                index_section3=index["section3"][i],
515                index_section5=index["section5"][i],
516            )
517
518            all_var_messages.setdefault(group_key, []).append(entry)
519
520    def _matches_filters(self, msg) -> bool:
521        """Check if a message matches all user-supplied filters.
522
523        Filter values may be:
524
525        * **scalar** – exact equality (``{"shortName": "TMP"}``)
526        * **list / tuple / set** – membership test
527          (``{"level": [500, 850, 250]}``)
528        * **slice** – inclusive range test
529          (``{"level": slice(500, 850)}``)
530        """
531        for key, value in self.filters.items():
532            msg_val = getattr(msg, key, None)
533            if msg_val is None:
534                return False
535            # Unwrap Grib2Metadata wrapper objects
536            if hasattr(msg_val, "value"):
537                msg_val = msg_val.value
538            if isinstance(value, slice):
539                lo = value.start if value.start is not None else float("-inf")
540                hi = value.stop if value.stop is not None else float("inf")
541                try:
542                    if not (lo <= msg_val <= hi):
543                        return False
544                except TypeError:
545                    return False
546            elif isinstance(value, (list, tuple, set)):
547                if msg_val not in value:
548                    return False
549            else:
550                if msg_val != value:
551                    return False
552        return True
553
554    # ------------------------------------------------------------------
555    # Variable reference building
556    # ------------------------------------------------------------------
557
558    def _build_variable_refs(
559        self,
560        var_name: str,
561        msg_entries: list,
562        refs: Dict[str, Any],
563        level_coord_registry: Optional[Dict[str, list]] = None,
564    ) -> None:
565        """Build all Zarr refs for a single variable."""
566        # Derive surface-type-specific level dim name (mirrors xarray_backend)
567        level_name = _level_dim_name(msg_entries[0].msg)
568        # Map messages to dimensions
569        dim_mapping = _map_messages_to_dimensions(msg_entries, level_dim_name=level_name)
570
571        # Disambiguate the level dim name if this surface type already appears
572        # in the manifest with different level values.  This prevents xarray
573        # from seeing conflicting dimension sizes when variables at the same
574        # surface type have different level counts.
575        if level_coord_registry is not None and level_name in dim_mapping["dim_values"]:
576            level_vals = dim_mapping["dim_values"][level_name]
577            if level_name in level_coord_registry:
578                if level_coord_registry[level_name] != level_vals:
579                    # Same surface type, different level values — append suffix
580                    suffix = 2
581                    candidate = f"{level_name}_{suffix}"
582                    while candidate in level_coord_registry and level_coord_registry[candidate] != level_vals:
583                        suffix += 1
584                        candidate = f"{level_name}_{suffix}"
585                    level_name = candidate
586                    dim_mapping = _map_messages_to_dimensions(msg_entries, level_dim_name=level_name)
587            if level_name not in level_coord_registry:
588                level_coord_registry[level_name] = dim_mapping["dim_values"].get(level_name, [])
589
590        dim_names = dim_mapping["dim_names"]  # ordered list of dim names
591        dim_values = dim_mapping["dim_values"]  # dict: dim_name -> sorted unique values
592        msg_index_map = dim_mapping["msg_index_map"]  # dict: dim_tuple -> msg_entry index
593
594        # Representative message for metadata
595        rep_msg = msg_entries[0].msg
596
597        # Compute shape (ensure plain Python ints for JSON serialization)
598        shape = [len(dim_values[d]) for d in dim_names] + [int(rep_msg.ny), int(rep_msg.nx)]
599        chunks = [1] * len(dim_names) + [int(rep_msg.ny), int(rep_msg.nx)]
600
601        # Build .zarray
602        codec_config = _build_codec_config(msg_entries[0])
603        zarray = _build_zarray_metadata(rep_msg, shape, chunks, codec_config)
604        refs[f"{var_name}/.zarray"] = json.dumps(zarray)
605
606        # Build .zattrs
607        dim_labels = dim_names + ["y", "x"]
608        zattrs = _build_zattrs(rep_msg, dim_labels)
609        refs[f"{var_name}/.zattrs"] = json.dumps(zattrs)
610
611        # Build data chunk refs
612        for dim_tuple, entry_idx in msg_index_map.items():
613            entry = msg_entries[entry_idx]
614            dim_indices = []
615            for i, d in enumerate(dim_names):
616                val = dim_tuple[i]
617                idx = list(dim_values[d]).index(val)
618                dim_indices.append(idx)
619
620            chunk_key = _build_chunk_key(var_name, dim_indices)
621
622            # Store a combined reference covering sections 5+6+7 so that the
623            # codec can parse the per-chunk data representation template (sec5)
624            # and bitmap (sec6) dynamically.  Sections 5, 6, and 7 are always
625            # contiguous in the GRIB2 byte stream.
626            refs[chunk_key] = [
627                entry.file_uri,
628                entry.sec5_offset,
629                entry.sec5_length + entry.sec6_length + entry.sec7_length,
630            ]
631
632        # Build coordinate arrays as inline base64-encoded refs
633        for dim_name in dim_names:
634            values = dim_values[dim_name]
635            _build_coord_refs(dim_name, values, refs)
636
637    # ------------------------------------------------------------------
638    # Manifest access
639    # ------------------------------------------------------------------
640
641    @property
642    def manifest(self) -> Optional[dict]:
643        """The generated manifest, or ``None`` if :meth:`generate` has not
644        been called yet."""
645        return self._manifest

Generate Kerchunk v1 reference manifests from GRIB2 files.

Parameters
  • file_paths (str or list of str): One or more GRIB2 file paths (local paths or URIs).
  • filters (dict, optional): Filter GRIB2 messages by metadata attributes. Keys can be any Grib2Message attribute name (e.g. shortName, leadTime).
  • storage_options (dict, optional): Extra options passed to fsspec.open for remote URIs (e.g. {"anon": True} for public S3 buckets).
ReferenceGenerator( file_paths: Union[str, List[str]], filters: Optional[Dict[str, Any]] = None, storage_options: Optional[Dict[str, Any]] = None, max_workers: Optional[int] = None)
131    def __init__(
132        self,
133        file_paths: Union[str, List[str]],
134        filters: Optional[Dict[str, Any]] = None,
135        storage_options: Optional[Dict[str, Any]] = None,
136        max_workers: Optional[int] = None,
137    ):
138        _ensure_numcodecs()
139
140        if isinstance(file_paths, (str, os.PathLike)):
141            file_paths = [str(file_paths)]
142        else:
143            file_paths = [str(p) for p in file_paths]
144
145        # Validate file accessibility.
146        # Local filesystem paths must exist. URI inputs are handled by
147        # grib2io.open/fsspec at scan time and should not be rejected here.
148        for fp in file_paths:
149            if _is_local_path(fp) and not os.path.isfile(fp):
150                raise FileNotFoundError(f"GRIB2 file not found: {fp}")
151
152        self.file_paths = file_paths
153        self.filters = filters or {}
154        self.storage_options = storage_options or {}
155        self.max_workers = max_workers
156        self._manifest: Optional[dict] = None
file_paths
filters
storage_options
max_workers
def generate(self) -> dict:
158    def generate(self) -> dict:
159        """Scan files and produce a Kerchunk v1 reference manifest.
160
161        Returns
162        -------
163        dict
164            Kerchunk reference spec v1 dict with keys ``"version"`` and
165            ``"refs"``.
166        """
167        refs: Dict[str, Any] = {}
168
169        # .zgroup at root
170        refs[".zgroup"] = json.dumps({"zarr_format": 2})
171
172        # Collect all messages across files, keyed by variable group
173        # group_key = (shortName, typeOfFirstFixedSurface, pdtn, typeOfSecondFixedSurface)
174        # This ensures messages with different surface types are not mixed.
175        all_var_messages: Dict[tuple, list] = {}
176
177        n_files = len(self.file_paths)
178        use_parallel = self.max_workers != 1 and n_files > 1 and not _is_local_path(self.file_paths[0])
179
180        if use_parallel:
181            import concurrent.futures
182
183            workers = self.max_workers or min(n_files, 8)
184
185            def _scan_one(file_path):
186                file_uri = _file_uri(file_path)
187                local_msgs: Dict[tuple, list] = {}
188                self._scan_file(file_path, file_uri, local_msgs)
189                return local_msgs
190
191            with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as pool:
192                futures = {pool.submit(_scan_one, fp): fp for fp in self.file_paths}
193                for future in concurrent.futures.as_completed(futures):
194                    fp = futures[future]
195                    try:
196                        local_msgs = future.result()
197                        for key, entries in local_msgs.items():
198                            all_var_messages.setdefault(key, []).extend(entries)
199                    except Exception as e:
200                        raise ValueError(f"Failed to parse GRIB2 file '{fp}': {e}") from e
201        else:
202            for file_path in self.file_paths:
203                file_uri = _file_uri(file_path)
204                try:
205                    self._scan_file(file_path, file_uri, all_var_messages)
206                except Exception as e:
207                    raise ValueError(f"Failed to parse GRIB2 file '{file_path}': {e}") from e
208
209        # For each variable group, map messages to dimensions and build refs.
210        # Track used variable names to handle collisions (same shortName
211        # but different surface types).
212        # Also track level coord name -> level values so that the same surface
213        # type but different level extents get disambiguated names (mirrors
214        # how the xarray backend keeps each surface type in its own Dataset).
215        used_var_names: Dict[str, int] = {}
216        level_coord_registry: Dict[str, list] = {}  # name -> sorted level values
217        for group_key, msg_entries in all_var_messages.items():
218            var_name = group_key[0]  # shortName is the first element
219            if var_name in used_var_names:
220                used_var_names[var_name] += 1
221                zarr_var_name = f"{var_name}_{used_var_names[var_name]}"
222            else:
223                used_var_names[var_name] = 0
224                zarr_var_name = var_name
225            self._build_variable_refs(zarr_var_name, msg_entries, refs, level_coord_registry)
226
227        # Build latitude/longitude coordinate arrays from the grid definition.
228        # All messages are assumed to share the same grid (required by the
229        # xarray backend too), so we use the first available message.
230        if all_var_messages:
231            first_entries = next(iter(all_var_messages.values()))
232            rep_msg = first_entries[0].msg
233            _build_latlon_coord_refs(rep_msg, refs)
234
235        self._manifest = {"version": 1, "refs": refs}
236        return self._manifest

Scan files and produce a Kerchunk v1 reference manifest.

Returns
  • dict: Kerchunk reference spec v1 dict with keys "version" and "refs".
def to_json(self, output_path: str) -> None:
238    def to_json(self, output_path: str) -> None:
239        """Serialize the manifest to a JSON file.
240
241        Parameters
242        ----------
243        output_path : str
244            Path to the output JSON file.
245        """
246        if self._manifest is None:
247            self.generate()
248        with open(output_path, "w") as f:
249            json.dump(self._manifest, f)

Serialize the manifest to a JSON file.

Parameters
  • output_path (str): Path to the output JSON file.
def to_parquet(self, output_path: str) -> None:
251    def to_parquet(self, output_path: str) -> None:
252        """Serialize the manifest to a Parquet reference store.
253
254        Parameters
255        ----------
256        output_path : str
257            Path to the output Parquet directory.
258        """
259        _ensure_kerchunk()
260        if self._manifest is None:
261            self.generate()
262
263        import fsspec
264        from fsspec.implementations.reference import LazyReferenceMapper
265
266        fs, _ = fsspec.core.url_to_fs(output_path)
267        out = LazyReferenceMapper.create(output_path, fs=fs, record_size=100_000, engine="pyarrow")
268        refs = self._manifest.get("refs", self._manifest)
269        for k in sorted(refs):
270            out[k] = refs[k]
271        out.flush()

Serialize the manifest to a Parquet reference store.

Parameters
  • output_path (str): Path to the output Parquet directory.
manifest: Optional[dict]
641    @property
642    def manifest(self) -> Optional[dict]:
643        """The generated manifest, or ``None`` if :meth:`generate` has not
644        been called yet."""
645        return self._manifest

The generated manifest, or None if generate() has not been called yet.