Skip to content

Commit 0d4a880

Browse files
feat: add store-root spatial:bbox and tighten minispec requirements (EOPF-Explorer#164)
* feat: add store-root spatial:bbox and tighten minispec requirements Introduces a GeoZarr "Store Root" layer in the minispec so clients can read a summary footprint without walking into child groups, and tightens the multiscale profile so `spatial:bbox` at the root and `spatial:transform` + `spatial:shape` on every layout entry are mandatory. Adds a new `geozarr.store` pydantic module (`GeoZarrStoreAttrs`, `GeoZarrMultiscaleGroupAttrs`, `GeoZarr`) enforcing the tightened profile, and updates the S2 converter to union child-group bboxes into an EPSG:4326 footprint written at the store root. Closes EOPF-Explorer#156. Addresses the clear parts of EOPF-Explorer#163; the array-level and non-multiscale-group parts of that issue need further clarification. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat: update GeoZarr store model and optimize S2 converter for improved bbox handling * Addresses review feedback on EOPF-Explorer#164: - Adds a "GeoZarr Hierarchy & Identification" subsection to the Store Root section of the minispec, codifying d-v-b's proposed rules: single root, root prefix ends with `.zarr`, suffix occurs at most once in the hierarchy, and explicit terminal-path conditions. - Promotes the store-root CRS to mandatory: at least one of `proj:code`, `proj:wkt2`, `proj:projjson` MUST be set; there is no implicit EPSG:4326 default. Per @vincentsarago's review. - Promotes `zarr_conventions` declaration at the store root from RECOMMENDED to required. - Converter now writes `proj:code: "EPSG:4326"` at the root alongside `spatial:bbox`. - Pydantic `GeoZarrStoreAttrs` enforces the new CRS-required rule. Cross-links the new hierarchy + spatial:extent follow-ups upstream: zarr-developers/geozarr-spec#132 (hierarchy & root identification, also addresses EOPF-Explorer#124 URL parsing) and EOPF-Explorer#133 (STAC-style spatial:extent). --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 27e1823 commit 0d4a880

6 files changed

Lines changed: 362 additions & 6 deletions

File tree

docs/geozarr-minispec.md

Lines changed: 106 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,107 @@ and their attributes before moving to higher-level structures like groups and th
4949

5050
This specification targets **Zarr V3** exclusively. The geo-proj, spatial, and multiscales conventions are all Zarr V3.
5151

52+
## Store Root
53+
54+
A GeoZarr Store is the outermost Zarr group of a dataset — the group located at the root of the Zarr store (e.g. `my_dataset.zarr/zarr.json`). It sits above any [Dataset](#dataset) or [Multiscale Dataset](#multiscale-dataset) nodes in the hierarchy and is intended to give clients a single place to read a **summary spatial footprint** for the whole store without having to walk into child groups.
55+
56+
> [!Note]
57+
> The hierarchy and identification rules below are local choices made for this implementation. They are subject to revision once the OGC GeoZarr Standards Working Group decides on canonical wording. A follow-up to surface these rules upstream is tracked in [zarr-developers/geozarr-spec#132](https://github.com/zarr-developers/geozarr-spec/issues/132). See also [GeoZarr Specification Contribution](geozarr-specification-contribution.md).
58+
59+
### Hierarchy & Identification
60+
61+
A GeoZarr hierarchy has these properties:
62+
63+
- **Single root**: every GeoZarr store has exactly one root group.
64+
- **Root naming**: the root group is stored under a prefix ending with `.zarr` (e.g. `my_dataset.zarr/`). Clients given any sub-path inside a GeoZarr store MAY recover the root by walking up the path until they hit a segment ending in `.zarr`.
65+
- **No nested stores**: the `.zarr` suffix SHOULD occur exactly once in any path within the hierarchy. Nesting GeoZarr stores (e.g. `a.zarr/b/c.zarr/`) is strongly discouraged because it breaks the root-recovery rule above.
66+
- **Terminal paths**: a path `p` within the hierarchy is *terminal* (a leaf) when any of the following holds:
67+
- an array node is reached at `p`,
68+
- a group with no members is reached at `p`,
69+
- `p` ends with a `zarr.json` file.
70+
71+
### Attributes
72+
73+
The store root carries a top-level spatial footprint. This is **informative**: it helps clients place the store spatially (STAC-style discovery) but is not authoritative — per-Dataset / per-Multiscale Dataset `spatial:` and `proj:` attributes remain the source of truth for reading individual variables.
74+
75+
| key | type | required | notes |
76+
| --- | ---- | -------- | ----- |
77+
| `zarr_conventions` | array | yes | MUST declare the `spatial:` and `proj:` conventions used at the root |
78+
| `spatial:bbox` | number[4] | yes | Bounding box `[xmin, ymin, xmax, ymax]` covering the union of all spatial assets in the store, expressed in the CRS named by `proj:code` (or `proj:wkt2` / `proj:projjson`) |
79+
| `proj:code` | string | conditional* | Authority:code identifier of the bbox CRS, e.g. `"EPSG:4326"` |
80+
| `proj:wkt2` | string | conditional* | WKT2 representation of the bbox CRS |
81+
| `proj:projjson` | object | conditional* | PROJJSON representation of the bbox CRS |
82+
83+
\* At least one of `proj:code`, `proj:wkt2`, `proj:projjson` MUST be provided. Use `"EPSG:4326"` (longitude/latitude, degrees) when no other CRS is meaningful — the store-root CRS is always declared explicitly; there is no implicit default.
84+
85+
> [!Note]
86+
> The store-root `spatial:bbox` is **a summary** — e.g. the union of footprints from all nested Datasets / Multiscale Datasets. It is not a substitute for the per-group `spatial:bbox` defined by the [spatial convention](https://github.com/zarr-conventions/spatial).
87+
88+
> [!Note]
89+
> A future extension may carry a list-of-bboxes at the root (one global + one per child group), STAC-style. This is tracked in [zarr-developers/geozarr-spec#133](https://github.com/zarr-developers/geozarr-spec/issues/133).
90+
91+
### Example — store root in EPSG:4326
92+
93+
```json
94+
{
95+
"zarr.json": {
96+
"node_type": "group",
97+
"zarr_format": 3,
98+
"attributes": {
99+
"zarr_conventions": [
100+
{
101+
"uuid": "f17cb550-5864-4468-aeb7-f3180cfb622f",
102+
"schema_url": "https://raw.githubusercontent.com/zarr-experimental/geo-proj/refs/tags/v1/schema.json",
103+
"spec_url": "https://github.com/zarr-experimental/geo-proj/blob/v1/README.md",
104+
"name": "proj:",
105+
"description": "Coordinate reference system information for geospatial data"
106+
},
107+
{
108+
"uuid": "689b58e2-cf7b-45e0-9fff-9cfc0883d6b4",
109+
"schema_url": "https://raw.githubusercontent.com/zarr-conventions/spatial/refs/tags/v1/schema.json",
110+
"spec_url": "https://github.com/zarr-conventions/spatial/blob/v1/README.md",
111+
"name": "spatial:",
112+
"description": "Spatial coordinate information"
113+
}
114+
],
115+
"proj:code": "EPSG:4326",
116+
"spatial:bbox": [6.45686, 44.13800, 7.87192, 45.14807]
117+
}
118+
}
119+
}
120+
```
121+
122+
### Example — store root in a non-4326 CRS
123+
124+
```json
125+
{
126+
"zarr.json": {
127+
"node_type": "group",
128+
"zarr_format": 3,
129+
"attributes": {
130+
"zarr_conventions": [
131+
{
132+
"uuid": "f17cb550-5864-4468-aeb7-f3180cfb622f",
133+
"schema_url": "https://raw.githubusercontent.com/zarr-experimental/geo-proj/refs/tags/v1/schema.json",
134+
"spec_url": "https://github.com/zarr-experimental/geo-proj/blob/v1/README.md",
135+
"name": "proj:",
136+
"description": "Coordinate reference system information for geospatial data"
137+
},
138+
{
139+
"uuid": "689b58e2-cf7b-45e0-9fff-9cfc0883d6b4",
140+
"schema_url": "https://raw.githubusercontent.com/zarr-conventions/spatial/refs/tags/v1/schema.json",
141+
"spec_url": "https://github.com/zarr-conventions/spatial/blob/v1/README.md",
142+
"name": "spatial:",
143+
"description": "Spatial coordinate information"
144+
}
145+
],
146+
"proj:code": "EPSG:32632",
147+
"spatial:bbox": [296577.1, 4887794.6, 413081.4, 5004299.4]
148+
}
149+
}
150+
}
151+
```
152+
52153
## DataArray
53154

54155
A DataArray is a Zarr V3 array with named axes.
@@ -244,7 +345,7 @@ The `zarr_conventions` array at the Multiscale Dataset's root group MUST declare
244345
| `multiscales` | [MultiscalesMetadata](#multiscalesmetadata) | yes | Pyramid layout for this group |
245346
| `proj:code` | string | conditional* | CRS for all resolution levels (group-level inheritance) |
246347
| `spatial:dimensions` | string[] | yes | Names of spatial dimensions, e.g. `["Y", "X"]` |
247-
| `spatial:bbox` | number[] | no | Overall bounding box in the CRS coordinate space |
348+
| `spatial:bbox` | number[4] | yes | Overall bounding box `[xmin, ymin, xmax, ymax]` in the CRS coordinate space, covering every level in the pyramid |
248349

249350
\* At least one of `proj:code`, `proj:wkt2`, or `proj:projjson` must be provided for geospatial datasets.
250351

@@ -267,8 +368,8 @@ Each object in the `layout` array represents one resolution level:
267368
| `derived_from` | string | no | Path to the source level from which this level was generated. Required to define the transform |
268369
| `transform` | [TransformObject](#transformobject) | conditional | Required when `derived_from` is present. Describes the relative coordinate transformation from the source level |
269370
| `resampling_method` | string | no | Per-level override of the default resampling method |
270-
| `spatial:shape` | integer[] | no | Shape of the spatial dimensions at this level `[height, width]` |
271-
| `spatial:transform` | number[6] | no | Affine transform coefficients for this level, in Rasterio/Affine ordering `[a, b, c, d, e, f]` |
371+
| `spatial:shape` | integer[2] | yes | Shape of the spatial dimensions at this level `[height, width]` |
372+
| `spatial:transform` | number[6] | yes | Absolute affine transform coefficients for this level, in Rasterio/Affine ordering `[a, b, c, d, e, f]` |
272373

273374
#### TransformObject
274375

@@ -496,8 +597,8 @@ Key aspects:
496597
| `derived_from` | string | no | Relative path to the source level |
497598
| `transform` | TransformObject | conditional | Required when `derived_from` is present |
498599
| `resampling_method` | string | no | Per-level resampling override |
499-
| `spatial:shape` | integer[] | no | Spatial dimensions shape `[height, width]` at this level |
500-
| `spatial:transform` | number[6] | no | Absolute affine transform for this level (Rasterio/Affine ordering) |
600+
| `spatial:shape` | integer[2] | yes | Spatial dimensions shape `[height, width]` at this level |
601+
| `spatial:transform` | number[6] | yes | Absolute affine transform for this level (Rasterio/Affine ordering) |
501602

502603
#### TransformObject
503604

docs/geozarr-specification-contribution.md

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,37 @@ def calculate_aligned_chunk_size(dimension_size: int, target_chunk_size: int) ->
4848

4949
**Impact:** This approach prevents chunk overlap issues with Dask while optimizing for actual data dimensions rather than arbitrary tile sizes, significantly improving performance.
5050

51-
### 3. Multiscale Hierarchy Structure Clarification
51+
### 3. GeoZarr Hierarchy Boundaries & Root Identification
52+
53+
**Issue:** [zarr-developers/geozarr-spec#132](https://github.com/zarr-developers/geozarr-spec/issues/132)
54+
55+
**Problem:** The current draft does not define how a GeoZarr store is bounded as a set of paths. The base Zarr spec treats the `.zarr` suffix as advisory only, so two implementations can disagree on whether nested stores (e.g. `a.zarr/b/c.zarr/`) are allowed, how a client given a deep URL recovers the root, and where a traversal should stop.
56+
57+
**Our Solution:** We adopted an explicit set of rules in our [Store Root section](geozarr-minispec.md#hierarchy--identification): single root, root prefix ends with `.zarr`, the suffix occurs at most once in the hierarchy, and an enumerated list of terminal-path conditions.
58+
59+
**Impact:** Clients reading a sub-path like `https://example.org/foo.zarr/measurements/reflectance/r10m` can now reliably recover the store root and read its summary `spatial:bbox` + `proj:code`. This also resolves a recurring URL-parsing question raised in [EOPF-Explorer/data-model#124](https://github.com/EOPF-Explorer/data-model/issues/124) without needing fragment-based URL workarounds.
60+
61+
### 4. Store-root Summary Footprint (`spatial:bbox` + `proj:code`)
62+
63+
**Issue:** [EOPF-Explorer/data-model#156](https://github.com/EOPF-Explorer/data-model/issues/156) — to be surfaced upstream once [#132](https://github.com/zarr-developers/geozarr-spec/issues/132) lands.
64+
65+
**Problem:** Without a top-level summary footprint, clients have to walk into child groups to discover where a store sits geographically. That is expensive over network, and prevents STAC-style discovery patterns.
66+
67+
**Our Solution:** A mandatory `spatial:bbox` plus an explicit CRS (one of `proj:code`, `proj:wkt2`, or `proj:projjson`) at the store root, defined in the [Store Root section](geozarr-minispec.md#store-root). The CRS is always declared explicitly — there is no implicit default.
68+
69+
**Impact:** A single read of the root `zarr.json` is enough for catalogues and viewers to place a store on a map, without disturbing per-group `spatial:` attributes which remain authoritative for individual variables.
70+
71+
### 5. STAC-style `spatial:extent` (multi-bbox at root)
72+
73+
**Issue:** [zarr-developers/geozarr-spec#133](https://github.com/zarr-developers/geozarr-spec/issues/133)
74+
75+
**Problem:** A single union `spatial:bbox` at the store root loses per-asset footprint information. Stores that mix several Datasets / Multiscale Datasets cannot expose individual extents without forcing clients to walk every child group.
76+
77+
**Our Solution (proposed):** Mirror STAC's `extent.spatial.bbox` by introducing `spatial:extent` as a list of bboxes — first entry is the global union, subsequent entries are per-child extents. Suggested by @vincentsarago during review of #4 above; deferred until the upstream `spatial:` convention adopts it.
78+
79+
**Impact:** Lets clients fetch all per-child footprints in one request, aligning Zarr discovery with STAC conventions.
80+
81+
### 6. Multiscale Hierarchy Structure Clarification
5282

5383
**Issue:** [zarr-developers/geozarr-spec#83](https://github.com/zarr-developers/geozarr-spec/issues/83)
5484

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,7 @@ module = [
180180
"eopf_geozarr.data_api.s1",
181181
"eopf_geozarr.data_api.s2",
182182
"eopf_geozarr.data_api.geozarr.v2",
183+
"eopf_geozarr.data_api.geozarr.store",
183184
]
184185
disable_error_code = ["valid-type"]
185186

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
"""GeoZarr store model.
2+
3+
Enforces the store-level GeoZarr mini-spec profile: the store root carries a
4+
mandatory spatial footprint (`spatial:bbox` + a CRS via one of `proj:code`,
5+
`proj:wkt2`, `proj:projjson`), and nested multiscale groups carry mandatory
6+
`spatial:bbox` at the root plus `spatial:transform` + `spatial:shape` on every
7+
layout entry.
8+
9+
Tightens the zarr convention-level models defined in `geozarr.multiscales`,
10+
`geozarr.spatial` and `geozarr.geoproj` without replacing them.
11+
"""
12+
13+
from __future__ import annotations
14+
15+
from typing import Any, Self
16+
17+
from pydantic import BaseModel, ConfigDict, Field, model_validator
18+
from pydantic.experimental.missing_sentinel import MISSING # noqa: F401 (re-export for mypy)
19+
from pydantic_zarr.v3 import ArraySpec, GroupSpec
20+
21+
from eopf_geozarr.data_api.geozarr.common import is_none
22+
from eopf_geozarr.data_api.geozarr.multiscales import MultiscaleMeta
23+
from eopf_geozarr.data_api.geozarr.multiscales.geozarr import MultiscaleGroupAttrs
24+
from eopf_geozarr.data_api.geozarr.multiscales.zcm import ScaleLevel
25+
from eopf_geozarr.data_api.geozarr.projjson import (
26+
ProjJSON, # noqa: TC001 (runtime use by pydantic)
27+
)
28+
29+
30+
class GeoZarrStoreAttrs(BaseModel):
31+
"""Attributes required at the store root (outermost Zarr group).
32+
33+
Both `spatial:bbox` and a CRS are mandatory. The CRS is encoded by exactly
34+
one of `proj:code`, `proj:wkt2`, or `proj:projjson`; there is no implicit
35+
default. Use `"EPSG:4326"` when no other CRS is meaningful.
36+
"""
37+
38+
bbox: list[float] = Field(alias="spatial:bbox", min_length=4, max_length=4)
39+
code: str | None = Field(None, alias="proj:code", exclude_if=is_none, pattern="^[A-Z]+:[0-9]+$")
40+
wkt2: str | None = Field(None, alias="proj:wkt2", exclude_if=is_none)
41+
projjson: ProjJSON | None = Field(None, alias="proj:projjson", exclude_if=is_none)
42+
43+
model_config = ConfigDict(
44+
extra="allow",
45+
populate_by_name=True,
46+
serialize_by_alias=True,
47+
)
48+
49+
@model_validator(mode="after")
50+
def validate_bbox_order(self) -> Self:
51+
xmin, ymin, xmax, ymax = self.bbox
52+
if xmin > xmax:
53+
raise ValueError(
54+
f"spatial:bbox: xmin ({xmin}) must be <= xmax ({xmax}); "
55+
"expected [xmin, ymin, xmax, ymax]"
56+
)
57+
if ymin > ymax:
58+
raise ValueError(
59+
f"spatial:bbox: ymin ({ymin}) must be <= ymax ({ymax}); "
60+
"expected [xmin, ymin, xmax, ymax]"
61+
)
62+
return self
63+
64+
@model_validator(mode="after")
65+
def validate_crs(self) -> Self:
66+
crs_fields_set = sum(1 for v in (self.code, self.wkt2, self.projjson) if v is not None)
67+
if crs_fields_set == 0:
68+
raise ValueError(
69+
"Store root requires a CRS: set exactly one of proj:code, proj:wkt2, or proj:projjson"
70+
)
71+
if crs_fields_set > 1:
72+
raise ValueError(
73+
"At most one of proj:code, proj:wkt2, proj:projjson may be set at the store root"
74+
)
75+
return self
76+
77+
78+
class GeoZarrScaleLevel(ScaleLevel):
79+
"""Multiscale layout entry with mandatory `spatial:transform` + `spatial:shape`."""
80+
81+
spatial_shape: list[int] = Field(alias="spatial:shape", min_length=2, max_length=2)
82+
spatial_transform: list[float] = Field(alias="spatial:transform", min_length=6, max_length=6)
83+
84+
model_config = ConfigDict(
85+
extra="allow",
86+
populate_by_name=True,
87+
serialize_by_alias=True,
88+
)
89+
90+
91+
class GeoZarrMultiscaleMeta(MultiscaleMeta):
92+
"""Multiscale metadata where every layout entry is a `GeoZarrScaleLevel`."""
93+
94+
layout: tuple[GeoZarrScaleLevel, ...]
95+
96+
97+
class GeoZarrMultiscaleGroupAttrs(MultiscaleGroupAttrs):
98+
"""Multiscale group attributes with a mandatory `spatial:bbox`."""
99+
100+
multiscales: GeoZarrMultiscaleMeta
101+
spatial_bbox: list[float] = Field(alias="spatial:bbox", min_length=4, max_length=4)
102+
103+
model_config = ConfigDict(
104+
extra="allow",
105+
populate_by_name=True,
106+
serialize_by_alias=True,
107+
)
108+
109+
@model_validator(mode="after")
110+
def validate_bbox_order(self) -> Self:
111+
xmin, ymin, xmax, ymax = self.spatial_bbox
112+
if xmin > xmax or ymin > ymax:
113+
raise ValueError(
114+
"spatial:bbox must be ordered as [xmin, ymin, xmax, ymax] with xmin<=xmax and ymin<=ymax"
115+
)
116+
return self
117+
118+
119+
GeoZarrMember = GroupSpec[Any, Any] | ArraySpec
120+
121+
122+
class GeoZarr(GroupSpec[GeoZarrStoreAttrs, GeoZarrMember]):
123+
"""A GeoZarr store.
124+
125+
Pairs the required store-root attributes with arbitrary Zarr children.
126+
Intended as a reusable building block: downstream models can constrain the
127+
`members` type further, e.g. require a nested `GeoZarrMultiscaleGroupAttrs`
128+
group.
129+
"""

src/eopf_geozarr/s2_optimization/s2_converter.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,13 +303,63 @@ def simple_root_consolidation(output_path: str, datasets: dict[str, dict]) -> No
303303
)
304304
log.info("Root zarr group created")
305305

306+
# Write the store-root spatial footprint (geozarr minispec, Store Root section).
307+
# Aggregates child-group `spatial:bbox` values, reprojects them to EPSG:4326
308+
# and writes the union on the root `zarr.json`.
309+
write_store_root_bbox(output_path)
310+
306311
# consolidate reflectance group metadata
307312
zarr.consolidate_metadata(output_path + "/measurements/reflectance", zarr_format=3)
308313

309314
# consolidate root group metadata
310315
zarr.consolidate_metadata(output_path, zarr_format=3)
311316

312317

318+
def write_store_root_bbox(output_path: str) -> None:
319+
"""Write `spatial:bbox` and `proj:code` at the store root.
320+
321+
Walks the zarr store, collects every child-group `spatial:bbox` along with
322+
its `proj:code`, reprojects each to EPSG:4326 and writes the union plus
323+
the CRS code on the root group. The CRS is always declared explicitly per
324+
the Store Root section of the minispec — there is no implicit default.
325+
"""
326+
from pyproj import Transformer
327+
328+
root = zarr.open_group(output_path, mode="r+")
329+
330+
bboxes_4326: list[tuple[float, float, float, float]] = []
331+
332+
def _walk(group: zarr.Group) -> None:
333+
attrs = dict(group.attrs)
334+
bbox = attrs.get("spatial:bbox")
335+
code = attrs.get("proj:code")
336+
if bbox is not None and len(bbox) == 4:
337+
if code and code != "EPSG:4326":
338+
transformer = Transformer.from_crs(code, "EPSG:4326", always_xy=True)
339+
xmin, ymin = transformer.transform(bbox[0], bbox[1])
340+
xmax, ymax = transformer.transform(bbox[2], bbox[3])
341+
bboxes_4326.append((xmin, ymin, xmax, ymax))
342+
else:
343+
bboxes_4326.append(tuple(float(v) for v in bbox)) # type: ignore[arg-type]
344+
for child in group.groups():
345+
_walk(child[1])
346+
347+
for _, child_group in root.groups():
348+
_walk(child_group)
349+
350+
if not bboxes_4326:
351+
log.warning("No child-group spatial:bbox found; skipping store-root bbox")
352+
return
353+
354+
xmin = min(b[0] for b in bboxes_4326)
355+
ymin = min(b[1] for b in bboxes_4326)
356+
xmax = max(b[2] for b in bboxes_4326)
357+
ymax = max(b[3] for b in bboxes_4326)
358+
root.attrs["spatial:bbox"] = [xmin, ymin, xmax, ymax]
359+
root.attrs["proj:code"] = "EPSG:4326"
360+
log.info("Wrote store-root spatial:bbox", bbox=[xmin, ymin, xmax, ymax])
361+
362+
313363
def optimization_summary(dt_input: xr.DataTree, dt_output: xr.DataTree, output_path: str) -> None:
314364
"""Print optimization summary statistics."""
315365
# Count groups

0 commit comments

Comments
 (0)