Skip to content

Commit 450e5d2

Browse files
committed
fix: embed EPSG in Zarr attrs for TiTiler CRS detection
TiTiler defaults to EPSG:4326 when ds.rio.crs is None, causing validation errors on UTM coordinates. Extract helper to embed proj:epsg in attrs so rioxarray reconstructs CRS after Zarr I/O.
1 parent 055f090 commit 450e5d2

File tree

2 files changed

+63
-1
lines changed

2 files changed

+63
-1
lines changed

src/eopf_geozarr/conversion/geozarr.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,13 @@ def iterative_copy(
352352
return dt_result if isinstance(dt_result, xr.DataTree) else xr.DataTree(dt_result)
353353

354354

355+
def _embed_crs_in_attrs(ds: xr.Dataset) -> None:
356+
"""Embed EPSG code in dataset attrs for Zarr persistence (modifies in-place)."""
357+
if epsg_code := ds.rio.crs.to_epsg():
358+
ds.attrs["proj:epsg"] = epsg_code
359+
ds.attrs["proj:code"] = f"EPSG:{epsg_code}"
360+
361+
355362
def prepare_dataset_with_crs_info(
356363
ds: xr.Dataset, reference_crs: str | None = None
357364
) -> xr.Dataset:
@@ -380,6 +387,7 @@ def prepare_dataset_with_crs_info(
380387
print(f" Adding CRS information: {reference_crs}")
381388
ds = ds.rio.write_crs(reference_crs)
382389
ds.attrs["grid_mapping"] = "spatial_ref"
390+
_embed_crs_in_attrs(ds)
383391

384392
# Ensure spatial_ref variable has proper attributes
385393
if "spatial_ref" in ds:
@@ -1003,9 +1011,10 @@ def create_overview_dataset_all_vars(
10031011
# Create overview dataset
10041012
overview_ds = xr.Dataset(overview_data_vars, coords=overview_coords)
10051013

1006-
# Set CRS using rioxarray first
1014+
# Set CRS using rioxarray
10071015
overview_ds.rio.write_crs(native_crs, inplace=True)
10081016
overview_ds.attrs["grid_mapping"] = grid_mapping_var_name
1017+
_embed_crs_in_attrs(overview_ds)
10091018

10101019
# Add grid_mapping variable after setting CRS
10111020
# TODO: refactor? grid mapping attributes and variables are handled
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
"""Tests for CRS metadata embedding in Zarr attributes."""
2+
3+
import numpy as np
4+
import pytest
5+
import xarray as xr
6+
7+
from eopf_geozarr.conversion.geozarr import prepare_dataset_with_crs_info
8+
9+
EPSG_UTM32N = 32632
10+
CRS_UTM32N = f"EPSG:{EPSG_UTM32N}"
11+
12+
13+
@pytest.fixture
14+
def utm_dataset():
15+
"""Minimal UTM Zone 32N dataset."""
16+
return xr.Dataset(
17+
{"data": (["y", "x"], np.random.rand(10000, 11000))},
18+
coords={"x": np.arange(0, 110000, 10), "y": np.arange(5300000, 5200000, -10)},
19+
)
20+
21+
22+
@pytest.fixture
23+
def zarr_roundtrip(utm_dataset, tmp_path):
24+
"""CRS-prepared dataset after Zarr write/read cycle."""
25+
ds = prepare_dataset_with_crs_info(utm_dataset, reference_crs=CRS_UTM32N)
26+
path = tmp_path / "test.zarr"
27+
ds.drop_encoding().to_zarr(path, mode="w", zarr_format=3, consolidated=True)
28+
return xr.open_zarr(path, zarr_format=3, consolidated=True)
29+
30+
31+
def test_crs_attrs_embedded(utm_dataset):
32+
"""EPSG metadata written to dataset attrs."""
33+
ds = prepare_dataset_with_crs_info(utm_dataset, reference_crs=CRS_UTM32N)
34+
assert ds.attrs["proj:epsg"] == EPSG_UTM32N
35+
assert ds.attrs["proj:code"] == CRS_UTM32N
36+
37+
38+
def test_crs_persists_through_zarr(zarr_roundtrip):
39+
"""CRS metadata survives Zarr write/read."""
40+
assert zarr_roundtrip.attrs["proj:epsg"] == EPSG_UTM32N
41+
assert zarr_roundtrip.attrs["proj:code"] == CRS_UTM32N
42+
43+
44+
def test_rioxarray_reads_crs(zarr_roundtrip):
45+
"""rioxarray reads CRS from Zarr metadata."""
46+
assert zarr_roundtrip.rio.crs.to_epsg() == EPSG_UTM32N
47+
48+
49+
def test_utm_bounds_preserved(zarr_roundtrip):
50+
"""Bounds remain UTM meters (not lat/lon degrees)."""
51+
left, bottom, right, top = zarr_roundtrip.rio.bounds()
52+
assert left < 200000 and right < 200000 # easting in valid UTM range
53+
assert bottom > 5000000 and top > 5000000 # northing in valid UTM range

0 commit comments

Comments
 (0)