Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
- Lazy coordinates for the healpix MOC index ({pull}`259`)
- Support serializing indexes to zarr with compressed coordinates ({pull}`262`
- Use `H3HexagonLayer` to visualize H3 data ({pull}`263`)
- Support missing coordinates for the zarr convention and fail on missing required properties ({pull}`260`)

## 0.6.0 (2026-02-05)

Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ classifiers = [
requires-python = ">=3.12"
dependencies = [
"xarray",
"pandas",
Comment thread
keewis marked this conversation as resolved.
"numpy>=2.0",
"healpix-geo>=0.3.0",
"h3ronpy",
Expand Down
4 changes: 2 additions & 2 deletions xdggs/conventions/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,10 @@ def _translate(key, value, table):
if isinstance(replacement, str):
return replacement, value

renamed_object = {
renamed_object = dict(
_translate(subkey, subvalue, replacement)
for subkey, subvalue in value.items()
}
)
return key, renamed_object

return dict(_translate(key, value, table) for key, value in mapping.items())
Expand Down
53 changes: 39 additions & 14 deletions xdggs/conventions/zarr.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,38 +107,63 @@ def decode(
if grid_name is None:
raise DecoderError("Required field `name` is missing or null.")

try:
index_cls = GRID_REGISTRY[grid_name]
except KeyError:
raise DecoderError(f"Unknown grid name: {grid_name}") from None

spatial_dimension = metadata.pop("spatial_dimension", None)
if spatial_dimension is None:
raise DecoderError("Required field `spatial_dimension` is missing or null.")

# optional, but required for now
coordinate = metadata.pop("coordinate", None)
if coordinate is None:
raise NotImplementedError("missing coordinate is not supported for now")
if "refinement_level" not in metadata:
raise DecoderError("Required field `refinement_level` is missing.")

# optional, but required to be `"none"` for now
compression = metadata.pop("compression", "none")

coordinate = metadata.pop("coordinate", None)
if name in ds.keys():
# name takes precedence over coordinate
coordinate = name
else:
# name becomes the new coordinate
name = name or "cell_ids"

variables_to_drop = []
if compression != "none":
index_options["compression"] = compression
index_options["dim"] = spatial_dimension
variables_to_drop.append(coordinate)

# construct index
# construct index based on coordinate presence
translation_table = self._create_translation_table(direction="xdggs")
metadata_ = translate_metadata_keys(metadata, translation_table)

var = ds.variables[coordinate].copy(deep=False)
var.attrs = metadata_

if grid_name not in GRID_REGISTRY:
raise DecoderError(f"cf convention: unknown grid name: {grid_name}")
index_cls = GRID_REGISTRY[grid_name]
index = index_cls.from_variables({name: var}, options=index_options)
if coordinate is None:
if name in ds.keys():
raise DecoderError(f"Cannot overwrite existing variable '{name}'.")

# create index for the entire domain at given refinement level
level = metadata_.pop("level")
if level is None:
raise DecoderError("No `coordinate` requires a `refinement_level`.")
options = dict(metadata_)
options.update(index_options)
index = index_cls.full_domain(
level, spatial_dimension, name, options=options
)
elif coordinate not in ds.keys():
raise DecoderError(f"Coordinate variable {coordinate}, does not exist.")
else:
var = ds.variables[coordinate].copy(deep=False)
var.attrs = metadata_
index = index_cls.from_variables({coordinate: var}, options=index_options)

# construct index
new_ds = (
ds.assign_coords(xr.Coordinates.from_xindex(index))
.drop_vars(variables_to_drop)
ds.drop_vars(variables_to_drop)
.assign_coords(xr.Coordinates.from_xindex(index))
.assign_attrs(copy.deepcopy(ds.attrs))
)
# remove redundant attrs
Expand Down
22 changes: 22 additions & 0 deletions xdggs/h3.py
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,28 @@ def from_variables(

return cls(var.data, dim, name, grid_info)

@classmethod
def full_domain(
cls,
level: int,
dim: str,
name: str,
*,
options: Mapping[str, Any],
) -> Self:
"""Create the index for the complete domain of the given level"""
# create the base_cells
nbase_cells = 122
mode = 1 << 59
base = np.arange(nbase_cells) << 45
ones = (1 << 45) - 1
base_cells = mode | base | ones
cell_ids = change_resolution(base_cells, level).to_numpy()
dict_options = dict(options)
dict_options.update(level=level)
grid_info = H3Info.from_dict(dict_options)
return cls(cell_ids, dim, name, grid_info)

@property
def grid_info(self) -> H3Info:
return self._grid
Expand Down
36 changes: 36 additions & 0 deletions xdggs/healpix/index.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from collections.abc import Mapping
from typing import Any, Self

import pandas as pd
import xarray as xr
from xarray.core.indexes import PandasIndex

Expand Down Expand Up @@ -76,6 +77,41 @@ def from_variables(

return cls(var.data, dim=dim, name=name, grid_info=grid_info, **options_)

@classmethod
def full_domain(
cls,
level: int,
dim: str,
name: str,
*,
options: Mapping[str, Any],
) -> Self:
"""Create the index for the complete domain of the given level"""
size = 12 * 4**level
indexing_scheme = options.get("indexing_scheme", "nested")
if indexing_scheme == "zuniq":
start = 1 << 2 * (29 - level)
step = start << 1
stop = size * step
cell_ids = pd.RangeIndex(start, stop, step)
# Note: I do not understand why level must be None for zuniq
# with H3 we also use a multi-level index for a fixed level
level = None
# not yet supported
# elif indexing_scheme == "nuniq":
# start = 4 ** (1 + level)
# stop = start + size
# cell_ids = pd.RangeIndex(start, stop)
else:
cell_ids = pd.RangeIndex(size)
dict_options = dict(options)
dict_options.update(level=level)
index_kind = dict_options.pop("index_kind", None)
grid_info = HealpixInfo.from_dict(dict_options)
return cls(
cell_ids, dim=dim, name=name, grid_info=grid_info, index_kind=index_kind
)

def _replace(self, new_index: xr.Index):
return type(self)(
new_index,
Expand Down
12 changes: 12 additions & 0 deletions xdggs/index.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,18 @@ def from_variables(

return index

@classmethod
def full_domain(
cls,
level: int,
dim: str,
name: str,
*,
options: Mapping[str, Any],
) -> Self:
"""Create the index for the complete domain of the given level"""
raise NotImplementedError("To be implemented in child class")

def values(self):
return self._index.index.values

Expand Down
Loading