Skip to content

Commit f7e424a

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 f7e424a

File tree

2 files changed

+127
-1
lines changed

2 files changed

+127
-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

test_crs_fix.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Quick validation script to verify CRS metadata is embedded in Zarr attributes.
4+
Tests the fix for TiTiler 500 error caused by missing CRS.
5+
"""
6+
7+
import tempfile
8+
from pathlib import Path
9+
10+
import numpy as np
11+
import xarray as xr
12+
13+
from eopf_geozarr.conversion.geozarr import prepare_dataset_with_crs_info
14+
15+
16+
def test_crs_metadata_embedding() -> None:
17+
"""Test that prepare_dataset_with_crs_info embeds EPSG in attrs."""
18+
19+
# Create a minimal test dataset with spatial coordinates
20+
x = np.arange(0, 110000, 10) # 10m resolution, 10km extent
21+
y = np.arange(5300000, 5200000, -10) # UTM coordinates
22+
data = np.random.rand(len(y), len(x))
23+
24+
ds = xr.Dataset(
25+
{
26+
"reflectance": (
27+
["y", "x"],
28+
data,
29+
{"standard_name": "toa_bidirectional_reflectance"},
30+
),
31+
},
32+
coords={
33+
"x": (["x"], x, {"standard_name": "projection_x_coordinate", "units": "m"}),
34+
"y": (["y"], y, {"standard_name": "projection_y_coordinate", "units": "m"}),
35+
},
36+
)
37+
38+
# Apply CRS preparation (this is what happens before to_zarr calls)
39+
reference_crs = "EPSG:32632" # UTM Zone 32N
40+
ds_prepared = prepare_dataset_with_crs_info(ds, reference_crs=reference_crs)
41+
42+
# Drop encoding to avoid conflicts (this is done in real conversion code)
43+
ds_prepared = ds_prepared.drop_encoding()
44+
45+
# Verify CRS metadata is in attrs (this persists to Zarr)
46+
assert "proj:epsg" in ds_prepared.attrs, "proj:epsg not found in dataset attrs"
47+
assert "proj:code" in ds_prepared.attrs, "proj:code not found in dataset attrs"
48+
assert ds_prepared.attrs["proj:epsg"] == 32632, (
49+
f"Expected EPSG:32632, got {ds_prepared.attrs['proj:epsg']}"
50+
)
51+
assert ds_prepared.attrs["proj:code"] == "EPSG:32632", (
52+
f"Expected 'EPSG:32632', got {ds_prepared.attrs['proj:code']}"
53+
)
54+
55+
print("✓ CRS metadata correctly embedded in dataset attrs")
56+
57+
# Test that it persists through Zarr write/read cycle
58+
with tempfile.TemporaryDirectory() as tmpdir:
59+
zarr_path = Path(tmpdir) / "test.zarr"
60+
61+
# Write to Zarr v3
62+
ds_prepared.to_zarr(
63+
zarr_path,
64+
mode="w",
65+
zarr_format=3,
66+
consolidated=True,
67+
)
68+
69+
# Read back and verify
70+
ds_read = xr.open_zarr(zarr_path, zarr_format=3, consolidated=True)
71+
72+
assert "proj:epsg" in ds_read.attrs, "proj:epsg lost after Zarr write/read"
73+
assert ds_read.attrs["proj:epsg"] == 32632, (
74+
f"EPSG changed after write/read: {ds_read.attrs['proj:epsg']}"
75+
)
76+
assert ds_read.attrs["proj:code"] == "EPSG:32632", (
77+
f"proj:code changed: {ds_read.attrs['proj:code']}"
78+
)
79+
80+
print("✓ CRS metadata persists through Zarr write/read cycle")
81+
82+
# Verify rioxarray can read the CRS
83+
assert ds_read.rio.crs is not None, "rioxarray cannot read CRS from Zarr"
84+
assert ds_read.rio.crs.to_epsg() == 32632, (
85+
f"Expected EPSG:32632 from rio.crs, got {ds_read.rio.crs.to_epsg()}"
86+
)
87+
88+
print("✓ rioxarray successfully reads CRS from persisted metadata")
89+
90+
# Check bounds are in UTM meters (not lat/lon)
91+
bounds = ds_read.rio.bounds()
92+
# UTM coordinates are typically in range [0, 1000000] for easting, [0, 10000000] for northing
93+
assert bounds[0] < 200000 and bounds[2] < 200000, (
94+
f"X bounds don't look like UTM coordinates: {bounds}"
95+
)
96+
assert bounds[1] > 5000000 and bounds[3] > 5000000, (
97+
f"Y bounds don't look like UTM coordinates: {bounds}"
98+
)
99+
print(f"✓ Bounds in UTM meters: {bounds}")
100+
101+
102+
if __name__ == "__main__":
103+
try:
104+
test_crs_metadata_embedding()
105+
print("\n✅ All CRS metadata tests passed!")
106+
print(
107+
"The fix ensures TiTiler will read native CRS instead of defaulting to EPSG:4326"
108+
)
109+
except AssertionError as e:
110+
print(f"\n❌ Test failed: {e}")
111+
exit(1)
112+
except Exception as e:
113+
print(f"\n❌ Unexpected error: {e}")
114+
import traceback
115+
116+
traceback.print_exc()
117+
exit(1)

0 commit comments

Comments
 (0)