Skip to content

The cube as a nested dict#

Export a cube's whole structure to plain Python objects and rebuild it from them. It needs a multidimensional open — reopen with open_as_multi_dimensional=True if the cube was opened classically.

Every other export goes to a file or a foreign object — to_file, to_zarr, to_kerchunk, to_dataframe, to_xarray, to_stac_item. This is the one that goes to nothing but dict, list, str and numbers, which is what inspection, diffing two cubes, JSON transport and asserting on structure in a test all want.

payload = cube.to_dict()           # values included
structure = cube.to_dict(data=False)   # structure only: no read_array call
rebuilt = NetCDF.from_dict(payload)

The schema is a superset of xarray's#

dims, coords, data_vars and attrs keep their xarray meanings, so a reader that knows only xarray's schema finds what it expects. Everything GDAL needs rides under one extra pyramids key:

key what it carries
schema the schema version, refused by from_dict if it is newer than this reader
epsg the CRS, None only under data=False
geotransform the six affine coefficients
spatial_dims the (y, x) pair every variable in the payload shares
no_data_value the sentinel per variable

coords carries the band dimensions only — the spatial axes are the geotransform rather than coordinate arrays, so an xarray-only reader of this payload gets a Dataset with no spatial coordinates. Under data=False both variables and coordinates report dims/dtype/shape/attrs and no data.

xarray's schema has no slot for a CRS or a geotransform — a crs attribute survives there only because it happens to ride in attrs. For a GDAL-backed cube the georeferencing is the object, so matching xarray exactly would have made the round trip silently drop it.

The payload records pyramids' dimension layout — band axes first, (y, x) last — which is the shape read_array returns. A store's own declared order is not always that: variable U of the CAM/CESM fixture in this repo declares ['time', 'lat', 'lev', 'lon'], interleaving a spatial axis between two band axes.

What round-trips, and what does not#

Round-trips: dimensions and their order, a band dimension's coordinates and their CF attributes (so a time axis keeps its units and calendar), variable arrays and dtypes, the CRS, the geotransform, the no-data sentinels and the spatial dimension names.

When variables sit on different band axes, add_variable leaves the later ones' coordinate map empty upstream of this module, so those axes arrive already unstamped and from_dict warns that it rebuilt them so.

Does not, in each case because from_array is the only constructor and takes neither:

  • Per-variable attributes. to_dict reports every one it finds — 32 on this repo's cf__5v__1d4-3d1__geog__y-desc.nc — and the rebuilt variable carries only the grid_mapping that from_array regenerates. There is no per-variable attribute setter to restore them through.
  • Arbitrary global attributes. Globals reach from_array as a CFAttributes, a fixed set of CF fields rather than a free mapping, so the CF-recognised ones are restored and the rest dropped.
  • Non-gridded variables. They have no raster plane, so from_array cannot rebuild them; to_dict drops them with a warning naming each. 12 of the 43 variables in the CAM/CESM fixture are of this kind.

A no-data sentinel also widens to a float for an integer cube (-1 becomes -1.0); the array dtype itself round-trips exactly.

Because of the attribute losses above, to_dict(a) == to_dict(from_dict(to_dict(a))) is not an identity — the two payloads differ in attrs and in each variable's attrs, while dims, coords, shape, dtype and every value agree. Diff on those keys rather than on the whole dict.

JSON#

Python's json emits NaN and Infinity and reads them back, so a payload round-trips through json.dumps/loads between Python processes. Those tokens are not RFC-8259 JSON, so JSON.parse, serde_json and strict Python parsers reject them: a gappy raster's payload is Python-JSON, not wire-JSON. Convert the non-finite cells before sending one somewhere strict.

API#

The cube as plain Python objects: NetCDF.to_dict out, NetCDF.from_dict back.

The methods are the public spelling. The module-level functions behind them are cube_to_dict and cube_from_dict rather than to_dict / from_dict, because pyramids.netcdf already exports a to_dict — metadata.to_dict(NetCDFMetadata), a different function taking a different argument — and from pyramids.netcdf import to_dict binds that one. Two same-named functions in one package is a trap for anyone following a docs page to the obvious import.

Every other export goes to a file or a foreign object — to_file, to_zarr, to_kerchunk, to_dataframe, to_xarray, to_stac_item. This is the one that goes to nothing but dict, list, str and numbers, which is what inspection, diffing two cubes, JSON transport and asserting on structure in a test all want.

The schema is a superset of xarray's, and that is the whole design decision. xarray's to_dict is keyed dims / coords / data_vars / attrs and has no slot for a CRS or a geotransform — a crs attribute survives there only because it happens to ride in attrs. For a GDAL-backed cube the georeferencing is the object, so matching xarray exactly would make the round trip silently drop it. Instead those four keys keep their xarray meaning exactly, and everything GDAL needs lives under one extra pyramids key. A reader that knows only xarray's schema still finds what it expects.

One limit on that: coords carries the band dimensions only, because those are the axes a cube stamps. The spatial axes are not coordinate arrays here — they are the geotransform, from which y and x are derived — so an xarray-only reader of this payload gets a Dataset with no spatial coordinates. xr.Dataset.from_dict accepts it, but the georeferencing is in the pyramids block, which by definition such a reader is not looking at.

What round-trips: dimensions and their order, a band dimension's coordinates and their CF attributes (a time axis keeps its units and calendar), variable arrays and dtypes, the CRS, the geotransform, the no-data sentinels and the spatial dimension names.

One caveat on the coordinates, which is upstream of this module rather than in it: when variables sit on different band axes, add_variable leaves the later ones' coordinate map empty, so those axes arrive here already unstamped and from_dict warns that it rebuilt them so. The warning names the payload because that is what it can see; the loss happened before to_dict was called.

What does not round-trip, in both cases because from_array is the only constructor and it takes neither:

  • Per-variable attributes. to_dict reports every one it finds — 32 of them on this repo's cf__5v__1d4-3d1__geog__y-desc.nc — and the rebuilt variable carries exactly one, grid_mapping, which from_array regenerates itself. There is no per-variable attribute setter on the class to restore them through. The payload is complete; the constructor is the narrow part, and until it widens this is a real asymmetry rather than a stated-and-harmless one.
  • Arbitrary global attributes. from_array takes globals as a CFAttributes, a fixed set of CF fields rather than a free mapping, so from_dict restores the CF ones it recognises and drops the rest.

One narrower asymmetry: a no-data sentinel comes back as a float even for an integer cube (-1 becomes -1.0), because it travels through from_array's no_data_value. The array dtype itself round-trips exactly.

to_dict(a) == to_dict(from_dict(to_dict(a))) is therefore not an identity, and an earlier version of this note wrongly said it was. Measured on the COARDS sample this repo ships, the two payloads differ in attrs and in every variable's attrs — the attribute losses above — while dims, coords, shape, dtype and every value agree. Diff on those keys, not on the whole dict.

A cube holding non-gridded variables is lossy for a third reason: they have no raster plane, so from_array cannot rebuild them, and to_dict drops them with a warning naming each.

cube_to_dict(nc, *, data=True) #

Export a cube's full structure as a nested dict.

Parameters:

Name Type Description Default
nc NetCDF

The container or variable to export.

required
data bool

Whether to include the values. False emits structure only — every variable and every coordinate keeps its dims, dtype, shape and attrs but no data key — which is the useful mode for inspecting or diffing two cubes. It makes no read_array call, so no per-cell array is materialised; each band dimension's coordinate vector still is, being the structure the mode reports.

True

Returns:

Type Description
dict[str, Any]

dict[str, Any]: The payload, keyed dims, coords, data_vars, attrs in their xarray

dict[str, Any]

meanings, plus pyramids carrying the CRS, geotransform, spatial dimension names, no-data

dict[str, Any]

sentinels and schema version.

Raises:

Type Description
ValueError

The cube has no data variables, or declares no CRS, so the payload could only rebuild into an unreferenced cube.

Examples:

  • The four xarray keys, plus the one that carries the georeferencing:

>>> import numpy as np
>>> from pyramids.netcdf import ExtraDimensions, GeoReference, NetCDF
>>> cube = NetCDF.from_array(
...     np.array([1.0, 2.0]).reshape(2, 1, 1),
...     geo_ref=GeoReference(geo=(0.0, 1.0, 0.0, 1.0, 0.0, -1.0), epsg=4326),
...     variable_name="t",
...     dims=ExtraDimensions(name="time", values=[0.0, 6.0]),
... )
>>> payload = cube.to_dict()
>>> sorted(payload)
['attrs', 'coords', 'data_vars', 'dims', 'pyramids']
>>> payload["dims"]
{'time': 2, 'y': 1, 'x': 1}
>>> payload["coords"]["time"]["data"]
[0.0, 6.0]
- The pyramids block is what xarray's schema has nowhere to put:

>>> import numpy as np
>>> from pyramids.netcdf import ExtraDimensions, GeoReference, NetCDF
>>> cube = NetCDF.from_array(
...     np.array([1.0, 2.0]).reshape(2, 1, 1),
...     geo_ref=GeoReference(geo=(0.0, 1.0, 0.0, 1.0, 0.0, -1.0), epsg=4326),
...     variable_name="t",
...     dims=ExtraDimensions(name="time", values=[0.0, 6.0]),
... )
>>> block = cube.to_dict()["pyramids"]
>>> block["epsg"], block["spatial_dims"]
(4326, ['y', 'x'])
>>> block["geotransform"]
[0.0, 1.0, 0.0, 1.0, 0.0, -1.0]
- data=False keeps the shape and drops the values, for inspecting or diffing:

>>> import numpy as np
>>> from pyramids.netcdf import ExtraDimensions, GeoReference, NetCDF
>>> cube = NetCDF.from_array(
...     np.array([1.0, 2.0]).reshape(2, 1, 1),
...     geo_ref=GeoReference(geo=(0.0, 1.0, 0.0, 1.0, 0.0, -1.0), epsg=4326),
...     variable_name="t",
...     dims=ExtraDimensions(name="time", values=[0.0, 6.0]),
... )
>>> entry = cube.to_dict(data=False)["data_vars"]["t"]
>>> entry["shape"], entry["dtype"]
([2, 1, 1], 'float64')
>>> "data" in entry
False
See Also

cube_from_dict: Rebuilds a cube from what this returns. pyramids.netcdf.NetCDF.to_dataframe: The cube as a pandas frame instead, which is a flat table rather than the structure.

Source code in src/pyramids/netcdf/dict_io.py
def cube_to_dict(nc: NetCDF, *, data: bool = True) -> dict[str, Any]:
    """Export a cube's full structure as a nested dict.

    Args:
        nc: The container or variable to export.
        data: Whether to include the values. `False` emits structure only — every variable and
            every coordinate keeps its `dims`, `dtype`, `shape` and `attrs` but no `data` key —
            which is the useful mode for inspecting or diffing two cubes. It makes **no**
            `read_array` call, so no per-cell array is materialised; each band dimension's
            coordinate vector still is, being the structure the mode reports.

    Returns:
        dict[str, Any]: The payload, keyed `dims`, `coords`, `data_vars`, `attrs` in their xarray
        meanings, plus `pyramids` carrying the CRS, geotransform, spatial dimension names, no-data
        sentinels and schema version.

    Raises:
        ValueError: The cube has no data variables, or declares no CRS, so the payload could
            only rebuild into an unreferenced cube.

    Examples:
        - The four xarray keys, plus the one that carries the georeferencing:

          ```python
          >>> import numpy as np
          >>> from pyramids.netcdf import ExtraDimensions, GeoReference, NetCDF
          >>> cube = NetCDF.from_array(
          ...     np.array([1.0, 2.0]).reshape(2, 1, 1),
          ...     geo_ref=GeoReference(geo=(0.0, 1.0, 0.0, 1.0, 0.0, -1.0), epsg=4326),
          ...     variable_name="t",
          ...     dims=ExtraDimensions(name="time", values=[0.0, 6.0]),
          ... )
          >>> payload = cube.to_dict()
          >>> sorted(payload)
          ['attrs', 'coords', 'data_vars', 'dims', 'pyramids']
          >>> payload["dims"]
          {'time': 2, 'y': 1, 'x': 1}
          >>> payload["coords"]["time"]["data"]
          [0.0, 6.0]

          ```
        - The `pyramids` block is what xarray's schema has nowhere to put:

          ```python
          >>> import numpy as np
          >>> from pyramids.netcdf import ExtraDimensions, GeoReference, NetCDF
          >>> cube = NetCDF.from_array(
          ...     np.array([1.0, 2.0]).reshape(2, 1, 1),
          ...     geo_ref=GeoReference(geo=(0.0, 1.0, 0.0, 1.0, 0.0, -1.0), epsg=4326),
          ...     variable_name="t",
          ...     dims=ExtraDimensions(name="time", values=[0.0, 6.0]),
          ... )
          >>> block = cube.to_dict()["pyramids"]
          >>> block["epsg"], block["spatial_dims"]
          (4326, ['y', 'x'])
          >>> block["geotransform"]
          [0.0, 1.0, 0.0, 1.0, 0.0, -1.0]

          ```
        - `data=False` keeps the shape and drops the values, for inspecting or diffing:

          ```python
          >>> import numpy as np
          >>> from pyramids.netcdf import ExtraDimensions, GeoReference, NetCDF
          >>> cube = NetCDF.from_array(
          ...     np.array([1.0, 2.0]).reshape(2, 1, 1),
          ...     geo_ref=GeoReference(geo=(0.0, 1.0, 0.0, 1.0, 0.0, -1.0), epsg=4326),
          ...     variable_name="t",
          ...     dims=ExtraDimensions(name="time", values=[0.0, 6.0]),
          ... )
          >>> entry = cube.to_dict(data=False)["data_vars"]["t"]
          >>> entry["shape"], entry["dtype"]
          ([2, 1, 1], 'float64')
          >>> "data" in entry
          False

          ```

    See Also:
        cube_from_dict: Rebuilds a cube from what this returns.
        pyramids.netcdf.NetCDF.to_dataframe: The cube as a pandas frame instead, which is a
            flat table rather than the structure.
    """
    pairs, whole_cube, skipped = _export_targets(nc)
    if skipped:
        warnings.warn(
            f"to_dict() exported {len(pairs)} variable(s) and dropped {skipped}: each either "
            f"has no raster plane or does not resolve to its band dimensions plus exactly two "
            f"more, so from_array() could not rebuild them. The round trip is therefore lossy "
            f"for this cube.",
            UserWarning,
            stacklevel=_user_stacklevel(),
        )
    epsg = nc.epsg
    if epsg is None and data:
        raise ValueError(
            "to_dict() needs a CRS to export values, and this cube declares none. Set one with "
            "set_crs() first — the payload carries it so from_dict() can georeference the "
            "result, and a dict without it would rebuild into an unreferenced cube. "
            "to_dict(data=False) describes a CRS-less cube without this refusal, since "
            "structure-only payloads are for inspection and from_dict() refuses them anyway."
        )
    group = whole_cube._working_group()
    dims: dict[str, int] = {}
    coords: dict[str, Any] = {}
    data_vars: dict[str, Any] = {}
    no_data: dict[str, Any] = {}
    layouts: set[tuple[str, ...]] = set()
    for name, var in pairs:
        described = _described_variable(whole_cube, group, name, var, data)
        layouts.add(described.spatial)
        for position, size in zip(described.declared, described.shape, strict=True):
            dims[position] = int(size)
        coords.update(described.coords)
        data_vars[name] = described.entry
        no_data[name] = described.sentinel
    if len(layouts) > 1:
        # Last-variable-wins would have stamped one layout over the whole payload and rebuilt
        # the others against the wrong axes.
        raise ValueError(
            f"to_dict() cannot describe this cube as one grid: its variables declare the "
            f"spatial layouts {sorted(layouts)}. Export them separately."
        )
    return {
        "dims": dims,
        "coords": coords,
        "data_vars": data_vars,
        # The **global** attributes, which on the variable route live on the container rather
        # than on the receiver: a variable's `.attrs` is its own set, and writing those into the
        # payload's global slot both duplicated them under `data_vars` and lost the real globals.
        "attrs": _plain(dict(whole_cube.attrs)),
        "pyramids": {
            "schema": SCHEMA_VERSION,
            "epsg": None if epsg is None else int(epsg),
            "geotransform": [float(value) for value in nc.geotransform],
            "spatial_dims": list(next(iter(layouts))),
            "no_data_value": no_data,
        },
    }

cube_from_dict(payload) #

Rebuild a cube from a nested dict as :func:to_dict produces.

The acceptance criterion is the round trip, not the parse: to_dict then from_dict gives back an equivalent cube, georeferencing included.

Parameters:

Name Type Description Default
payload Any

The dict to rebuild from.

required

Returns:

Name Type Description
Container Container

The rebuilt cube.

Raises:

Type Description
TypeError

payload is not a dict, or its pyramids block is not a dict.

ValueError

A required key is missing; data_vars is empty; the geotransform is not six values; a variable has no values because the payload came from to_dict(data=False); a variable declares an unknown dimension; or a variable's array shape disagrees with its dimensions.

Examples:

  • The round trip, which is the contract: values and stamps come back:

>>> import numpy as np
>>> from pyramids.netcdf import ExtraDimensions, GeoReference, NetCDF
>>> cube = NetCDF.from_array(
...     np.array([1.0, 2.0]).reshape(2, 1, 1),
...     geo_ref=GeoReference(geo=(0.0, 1.0, 0.0, 1.0, 0.0, -1.0), epsg=4326),
...     variable_name="t",
...     dims=ExtraDimensions(name="time", values=[0.0, 6.0]),
... )
>>> rebuilt = NetCDF.from_dict(cube.to_dict())
>>> rebuilt.get_variable("t").read_array().ravel().tolist()
[1.0, 2.0]
>>> np.asarray(rebuilt.get_variable("t").coords["time"]).tolist()
[0.0, 6.0]
- The georeferencing survives, which is why the schema carries it at all:

>>> import numpy as np
>>> from pyramids.netcdf import ExtraDimensions, GeoReference, NetCDF
>>> cube = NetCDF.from_array(
...     np.array([1.0, 2.0]).reshape(2, 1, 1),
...     geo_ref=GeoReference(geo=(0.0, 1.0, 0.0, 1.0, 0.0, -1.0), epsg=4326),
...     variable_name="t",
...     dims=ExtraDimensions(name="time", values=[0.0, 6.0]),
... )
>>> rebuilt = NetCDF.from_dict(cube.to_dict())
>>> rebuilt.epsg
4326
>>> tuple(rebuilt.geotransform)
(0.0, 1.0, 0, 1.0, 0, -1.0)
- A payload carrying structure only cannot be rebuilt, and says so:

>>> import numpy as np
>>> from pyramids.netcdf import ExtraDimensions, GeoReference, NetCDF
>>> cube = NetCDF.from_array(
...     np.array([1.0, 2.0]).reshape(2, 1, 1),
...     geo_ref=GeoReference(geo=(0.0, 1.0, 0.0, 1.0, 0.0, -1.0), epsg=4326),
...     variable_name="t",
...     dims=ExtraDimensions(name="time", values=[0.0, 6.0]),
... )
>>> NetCDF.from_dict(cube.to_dict(data=False))  # doctest: +ELLIPSIS
Traceback (most recent call last):
    ...
ValueError: from_dict() needs values for 't', ... structure only and cannot be rebuilt.
See Also

cube_to_dict: Produces the payload this consumes. pyramids.netcdf.NetCDF.from_array: The constructor this builds on, and the reason only the CF-recognised global attributes are restored.

Source code in src/pyramids/netcdf/dict_io.py
def cube_from_dict(payload: Any) -> Container:
    """Rebuild a cube from a nested dict as :func:`to_dict` produces.

    The acceptance criterion is the round trip, not the parse: `to_dict` then `from_dict` gives
    back an equivalent cube, georeferencing included.

    Args:
        payload: The dict to rebuild from.

    Returns:
        Container: The rebuilt cube.

    Raises:
        TypeError: `payload` is not a dict, or its `pyramids` block is not a dict.
        ValueError: A required key is missing; `data_vars` is empty; the geotransform is not six
            values; a variable has no values because the payload came from `to_dict(data=False)`;
            a variable declares an unknown dimension; or a variable's array shape disagrees with
            its dimensions.

    Examples:
        - The round trip, which is the contract: values and stamps come back:

          ```python
          >>> import numpy as np
          >>> from pyramids.netcdf import ExtraDimensions, GeoReference, NetCDF
          >>> cube = NetCDF.from_array(
          ...     np.array([1.0, 2.0]).reshape(2, 1, 1),
          ...     geo_ref=GeoReference(geo=(0.0, 1.0, 0.0, 1.0, 0.0, -1.0), epsg=4326),
          ...     variable_name="t",
          ...     dims=ExtraDimensions(name="time", values=[0.0, 6.0]),
          ... )
          >>> rebuilt = NetCDF.from_dict(cube.to_dict())
          >>> rebuilt.get_variable("t").read_array().ravel().tolist()
          [1.0, 2.0]
          >>> np.asarray(rebuilt.get_variable("t").coords["time"]).tolist()
          [0.0, 6.0]

          ```
        - The georeferencing survives, which is why the schema carries it at all:

          ```python
          >>> import numpy as np
          >>> from pyramids.netcdf import ExtraDimensions, GeoReference, NetCDF
          >>> cube = NetCDF.from_array(
          ...     np.array([1.0, 2.0]).reshape(2, 1, 1),
          ...     geo_ref=GeoReference(geo=(0.0, 1.0, 0.0, 1.0, 0.0, -1.0), epsg=4326),
          ...     variable_name="t",
          ...     dims=ExtraDimensions(name="time", values=[0.0, 6.0]),
          ... )
          >>> rebuilt = NetCDF.from_dict(cube.to_dict())
          >>> rebuilt.epsg
          4326
          >>> tuple(rebuilt.geotransform)
          (0.0, 1.0, 0, 1.0, 0, -1.0)

          ```
        - A payload carrying structure only cannot be rebuilt, and says so:

          ```python
          >>> import numpy as np
          >>> from pyramids.netcdf import ExtraDimensions, GeoReference, NetCDF
          >>> cube = NetCDF.from_array(
          ...     np.array([1.0, 2.0]).reshape(2, 1, 1),
          ...     geo_ref=GeoReference(geo=(0.0, 1.0, 0.0, 1.0, 0.0, -1.0), epsg=4326),
          ...     variable_name="t",
          ...     dims=ExtraDimensions(name="time", values=[0.0, 6.0]),
          ... )
          >>> NetCDF.from_dict(cube.to_dict(data=False))  # doctest: +ELLIPSIS
          Traceback (most recent call last):
              ...
          ValueError: from_dict() needs values for 't', ... structure only and cannot be rebuilt.

          ```

    See Also:
        cube_to_dict: Produces the payload this consumes.
        pyramids.netcdf.NetCDF.from_array: The constructor this builds on, and the reason only
            the CF-recognised global attributes are restored.
    """
    # Local import breaks the netcdf.py <-> dict_io import cycle: netcdf.py's facades call these
    # functions, and the constructor here is netcdf.py's own.
    from pyramids.netcdf.netcdf import NetCDF

    payload = _assert_payload(payload)
    dims = payload["dims"]
    geo = payload["pyramids"]
    spatial = [str(item) for item in geo["spatial_dims"]]
    coords = payload["coords"]
    sentinels = geo.get("no_data_value") or {}
    transform = [float(value) for value in geo["geotransform"]]
    if len(transform) != 6:
        raise ValueError(
            f"from_dict() needs a 6-value geotransform, but this payload's has "
            f"{len(transform)}."
        )
    reference = GeoReference(
        geo=(
            transform[0],
            transform[1],
            transform[2],
            transform[3],
            transform[4],
            transform[5],
        ),
        epsg=geo["epsg"],
    )
    attributes = _cf_attributes(payload.get("attrs"))
    names_pair = (spatial[-2], spatial[-1]) if len(spatial) >= 2 else None
    pieces: list[tuple[str, Container]] = []
    for name, entry in payload["data_vars"].items():
        array = _variable_array(str(name), entry, dims)
        declared = [str(item) for item in entry["dims"]]
        if declared[-2:] != spatial[-2:]:
            # Without this the mismatch reaches `from_array` as an extra-dimension count and
            # fails there with a message about `extra_dims`, which says nothing about the key
            # that is actually wrong.
            raise ValueError(
                f"from_dict() found {name!r} declaring {declared}, whose last two dimensions "
                f"are not the payload's spatial pair {spatial[-2:]}. Every variable in one "
                f"payload has to share the grid the 'pyramids' block describes."
            )
        band = [item for item in declared if item not in spatial]
        unstamped = [item for item in band if not coords.get(item, {}).get("data")]
        if unstamped:
            # Rebuilding an unstamped axis silently loses the one thing `to_dict` recorded about
            # it, and every coordinate-aware member then refuses the result for a reason that
            # points at the cube rather than at the payload.
            warnings.warn(
                f"from_dict() found no coordinates for {unstamped} on {name!r}, so those axes "
                f"are rebuilt unstamped. Members that measure against coordinates "
                f"(differentiate, integrate, interp) will refuse the result.",
                UserWarning,
                stacklevel=_user_stacklevel(),
            )
        dim_attrs = {
            item: {
                str(key): str(value)
                for key, value in (coords.get(item, {}).get("attrs") or {}).items()
            }
            for item in band
            if coords.get(item, {}).get("attrs")
        }
        extra = (
            ExtraDimensions(
                dims=[
                    (item, _plain(coords.get(item, {}).get("data"))) for item in band
                ],
                attrs=dim_attrs or None,
            )
            if band
            else None
        )
        one = NetCDF.from_array(
            array,
            geo_ref=reference,
            no_data_value=sentinels.get(name),
            variable_name=str(name),
            dims=extra,
            attrs=attributes,
            spatial_names=names_pair,
        )
        pieces.append((str(name), one))
    # `_assert_payload` refuses an empty `data_vars`, so there is always a first piece. Written
    # as a list rather than an accumulator seeded with `None` so that the return type needs no
    # cast to assert what the validation already guarantees.
    (_, first), *rest = pieces
    for later_name, later_cube in rest:
        first.add_variable(later_cube, later_name)
    return first