Skip to content

Commit dbad20e

Browse files
committed
add titiler integration test
1 parent e842ece commit dbad20e

3 files changed

Lines changed: 2108 additions & 1391 deletions

File tree

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,10 @@ test = [
6161
"jsondiff>=2.0.0",
6262
"pytest-examples>=0.0.18",
6363
]
64+
downstream-titiler = [
65+
"titiler-xarray>=2.0.0",
66+
"httpx>=0.27.0",
67+
]
6468
docs = [
6569
"mkdocs>=1.4.0",
6670
"mkdocs-material>=9.1.0",

tests/test_titiler_integration.py

Lines changed: 362 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,362 @@
1+
"""
2+
Integration tests verifying that S2 converter output can be consumed by titiler-xarray.
3+
4+
These tests run the actual S2 converter on real data, then verify that titiler's
5+
APIs (info, tiles, point queries, bbox crops) work correctly against the output.
6+
7+
Requires:
8+
- /tmp/s2_source.zarr to exist (real S2 product)
9+
- titiler-xarray, httpx installed
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import pathlib
15+
import tempfile
16+
17+
import pytest
18+
import xarray as xr
19+
20+
pytest.importorskip("titiler.xarray", reason="titiler-xarray not installed")
21+
22+
from fastapi import FastAPI
23+
from starlette.testclient import TestClient
24+
from titiler.xarray.factory import TilerFactory
25+
26+
s2_source_path = pathlib.Path("/tmp/s2_source.zarr")
27+
28+
pytestmark = [
29+
pytest.mark.integration,
30+
pytest.mark.skipif(
31+
not s2_source_path.exists(),
32+
reason="S2 source data not available at /tmp/s2_source.zarr",
33+
),
34+
]
35+
36+
37+
@pytest.fixture(scope="module")
38+
def converted_s2_path() -> pathlib.Path:
39+
"""Run the real S2 converter and return the output path.
40+
41+
This fixture is module-scoped so the conversion only runs once for all tests.
42+
"""
43+
from eopf_geozarr.s2_optimization.s2_converter import convert_s2
44+
45+
tmpdir = tempfile.mkdtemp(prefix="titiler_test_")
46+
output_path = str(pathlib.Path(tmpdir) / "s2_optimized.zarr")
47+
48+
dt_input = xr.open_datatree(
49+
str(s2_source_path),
50+
engine="zarr",
51+
chunks="auto",
52+
)
53+
54+
convert_s2(
55+
dt_input,
56+
output_path=output_path,
57+
validate_output=False,
58+
enable_sharding=False,
59+
spatial_chunk=512,
60+
)
61+
62+
return pathlib.Path(output_path)
63+
64+
65+
@pytest.fixture(scope="module")
66+
def titiler_client() -> TestClient:
67+
"""Create a titiler TestClient."""
68+
tiler = TilerFactory(router_prefix="/xarray")
69+
app = FastAPI()
70+
app.include_router(tiler.router, prefix="/xarray")
71+
return TestClient(app)
72+
73+
74+
@pytest.fixture(scope="module")
75+
def reflectance_groups(converted_s2_path: pathlib.Path) -> list[str]:
76+
"""Discover reflectance resolution groups (e.g. r10m, r20m, r60m) in the output."""
77+
refl_path = converted_s2_path / "measurements" / "reflectance"
78+
if not refl_path.exists():
79+
pytest.skip("No measurements/reflectance in converted output")
80+
groups = sorted(d.name for d in refl_path.iterdir() if d.is_dir() and d.name.startswith("r"))
81+
assert len(groups) > 0, "No resolution groups found under measurements/reflectance"
82+
return groups
83+
84+
85+
def _open_group(path: pathlib.Path, group: str) -> xr.Dataset:
86+
"""Open a zarr group as xarray Dataset."""
87+
return xr.open_dataset(str(path), engine="zarr", group=group, zarr_format=3, consolidated=False)
88+
89+
90+
def _band_vars(ds: xr.Dataset) -> list[str]:
91+
"""Get band variable names, excluding spatial_ref."""
92+
return [v for v in ds.data_vars if v != "spatial_ref"]
93+
94+
95+
class TestTitilerInfo:
96+
"""Test that titiler /info endpoint works for each resolution group."""
97+
98+
def test_info_all_groups(
99+
self,
100+
converted_s2_path: pathlib.Path,
101+
titiler_client: TestClient,
102+
reflectance_groups: list[str],
103+
) -> None:
104+
"""Verify /info returns valid metadata for every resolution group."""
105+
for group_name in reflectance_groups:
106+
zarr_group = f"measurements/reflectance/{group_name}"
107+
108+
ds = _open_group(converted_s2_path, zarr_group)
109+
bands = _band_vars(ds)
110+
ds.close()
111+
assert len(bands) > 0, f"No band variables in {zarr_group}"
112+
113+
variable = bands[0]
114+
resp = titiler_client.get(
115+
"/xarray/info",
116+
params={
117+
"url": str(converted_s2_path),
118+
"variable": variable,
119+
"group": zarr_group,
120+
},
121+
)
122+
assert resp.status_code == 200, (
123+
f"Info failed for {zarr_group}/{variable}: {resp.text[:300]}"
124+
)
125+
info = resp.json()
126+
assert "bounds" in info, f"No bounds in info for {zarr_group}"
127+
assert len(info["bounds"]) == 4
128+
assert info["bounds"][0] < info["bounds"][2], "Invalid x bounds"
129+
assert info["bounds"][1] < info["bounds"][3], "Invalid y bounds"
130+
131+
def test_info_all_bands(
132+
self,
133+
converted_s2_path: pathlib.Path,
134+
titiler_client: TestClient,
135+
) -> None:
136+
"""Verify /info works for every band variable in the r10m group."""
137+
zarr_group = "measurements/reflectance/r10m"
138+
ds = _open_group(converted_s2_path, zarr_group)
139+
bands = _band_vars(ds)
140+
ds.close()
141+
142+
for variable in bands:
143+
resp = titiler_client.get(
144+
"/xarray/info",
145+
params={
146+
"url": str(converted_s2_path),
147+
"variable": variable,
148+
"group": zarr_group,
149+
},
150+
)
151+
assert resp.status_code == 200, (
152+
f"Info failed for {zarr_group}/{variable}: {resp.text[:300]}"
153+
)
154+
155+
156+
class TestTitilerTiles:
157+
"""Test that titiler can generate map tiles from the converted data."""
158+
159+
def test_tile_generation(
160+
self,
161+
converted_s2_path: pathlib.Path,
162+
titiler_client: TestClient,
163+
reflectance_groups: list[str],
164+
) -> None:
165+
"""Verify tiles can be generated for each resolution group."""
166+
for group_name in reflectance_groups:
167+
zarr_group = f"measurements/reflectance/{group_name}"
168+
ds = _open_group(converted_s2_path, zarr_group)
169+
bands = _band_vars(ds)
170+
ds.close()
171+
if not bands:
172+
continue
173+
174+
resp = titiler_client.get(
175+
"/xarray/tiles/WebMercatorQuad/0/0/0.png",
176+
params={
177+
"url": str(converted_s2_path),
178+
"variable": bands[0],
179+
"group": zarr_group,
180+
"rescale": "0,10000",
181+
},
182+
)
183+
assert resp.status_code == 200, (
184+
f"Tile failed for {zarr_group}/{bands[0]}: {resp.text[:300]}"
185+
)
186+
assert resp.headers["content-type"] == "image/png"
187+
assert len(resp.content) > 0
188+
189+
def test_tilejson(
190+
self,
191+
converted_s2_path: pathlib.Path,
192+
titiler_client: TestClient,
193+
) -> None:
194+
"""Verify TileJSON metadata endpoint works."""
195+
zarr_group = "measurements/reflectance/r10m"
196+
ds = _open_group(converted_s2_path, zarr_group)
197+
bands = _band_vars(ds)
198+
ds.close()
199+
200+
resp = titiler_client.get(
201+
"/xarray/WebMercatorQuad/tilejson.json",
202+
params={
203+
"url": str(converted_s2_path),
204+
"variable": bands[0],
205+
"group": zarr_group,
206+
"rescale": "0,10000",
207+
},
208+
)
209+
assert resp.status_code == 200
210+
tj = resp.json()
211+
assert "tiles" in tj
212+
assert "bounds" in tj
213+
assert len(tj["bounds"]) == 4
214+
215+
216+
class TestTitilerPointQuery:
217+
"""Test that titiler point queries return valid pixel values."""
218+
219+
def test_point_query(
220+
self,
221+
converted_s2_path: pathlib.Path,
222+
titiler_client: TestClient,
223+
) -> None:
224+
"""Query a point within the data extent and verify values are returned."""
225+
from pyproj import CRS, Transformer
226+
227+
zarr_group = "measurements/reflectance/r10m"
228+
ds = _open_group(converted_s2_path, zarr_group)
229+
bands = _band_vars(ds)
230+
231+
x_center = float(ds.x.values[len(ds.x) // 2])
232+
y_center = float(ds.y.values[len(ds.y) // 2])
233+
234+
# Get CRS from spatial_ref attributes
235+
crs_wkt = ds["spatial_ref"].attrs["crs_wkt"]
236+
ds.close()
237+
238+
crs = CRS.from_wkt(crs_wkt)
239+
transformer = Transformer.from_crs(crs, "EPSG:4326", always_xy=True)
240+
lon, lat = transformer.transform(x_center, y_center)
241+
242+
resp = titiler_client.get(
243+
f"/xarray/point/{lon},{lat}",
244+
params={
245+
"url": str(converted_s2_path),
246+
"variable": bands[0],
247+
"group": zarr_group,
248+
},
249+
)
250+
assert resp.status_code == 200, f"Point query failed at ({lon}, {lat}): {resp.text[:300]}"
251+
result = resp.json()
252+
assert "values" in result
253+
assert len(result["values"]) > 0
254+
255+
256+
class TestTitilerBbox:
257+
"""Test that titiler bbox (part) endpoint returns cropped images."""
258+
259+
def test_bbox_crop(
260+
self,
261+
converted_s2_path: pathlib.Path,
262+
titiler_client: TestClient,
263+
) -> None:
264+
"""Request a bbox crop of the data and verify an image is returned."""
265+
from pyproj import CRS, Transformer
266+
267+
zarr_group = "measurements/reflectance/r10m"
268+
ds = _open_group(converted_s2_path, zarr_group)
269+
bands = _band_vars(ds)
270+
271+
# Get native CRS bounds and convert to WGS84 for the bbox endpoint
272+
x_min, x_max = float(ds.x.min()), float(ds.x.max())
273+
y_min, y_max = float(ds.y.min()), float(ds.y.max())
274+
crs_wkt = ds["spatial_ref"].attrs["crs_wkt"]
275+
ds.close()
276+
277+
crs = CRS.from_wkt(crs_wkt)
278+
transformer = Transformer.from_crs(crs, "EPSG:4326", always_xy=True)
279+
lon_min, lat_min = transformer.transform(x_min, y_min)
280+
lon_max, lat_max = transformer.transform(x_max, y_max)
281+
282+
# Use the center 10% of the geographic extent
283+
lon_range = lon_max - lon_min
284+
lat_range = lat_max - lat_min
285+
lon_center = (lon_min + lon_max) / 2
286+
lat_center = (lat_min + lat_max) / 2
287+
small_bounds = [
288+
lon_center - lon_range * 0.05,
289+
lat_center - lat_range * 0.05,
290+
lon_center + lon_range * 0.05,
291+
lat_center + lat_range * 0.05,
292+
]
293+
bbox_str = ",".join(f"{v:.6f}" for v in small_bounds)
294+
295+
resp = titiler_client.get(
296+
f"/xarray/bbox/{bbox_str}.png",
297+
params={
298+
"url": str(converted_s2_path),
299+
"variable": bands[0],
300+
"group": zarr_group,
301+
"rescale": "0,10000",
302+
},
303+
)
304+
assert resp.status_code == 200, f"Bbox crop failed: {resp.text[:300]}"
305+
assert resp.headers["content-type"] == "image/png"
306+
assert len(resp.content) > 100
307+
308+
309+
class TestTitilerMultiscaleConsistency:
310+
"""Verify that resolution groups report consistent geographic bounds."""
311+
312+
def test_bounds_consistent_across_resolutions(
313+
self,
314+
converted_s2_path: pathlib.Path,
315+
titiler_client: TestClient,
316+
reflectance_groups: list[str],
317+
) -> None:
318+
"""All resolution groups should report approximately the same geographic bounds.
319+
320+
The native groups (r10m, r20m, r60m) and derived groups (r120m, r360m, r720m)
321+
should all cover the same spatial extent.
322+
"""
323+
bounds_per_group: dict[str, list[float]] = {}
324+
for group_name in reflectance_groups:
325+
zarr_group = f"measurements/reflectance/{group_name}"
326+
ds = _open_group(converted_s2_path, zarr_group)
327+
bands = _band_vars(ds)
328+
ds.close()
329+
if not bands:
330+
continue
331+
332+
resp = titiler_client.get(
333+
"/xarray/info",
334+
params={
335+
"url": str(converted_s2_path),
336+
"variable": bands[0],
337+
"group": zarr_group,
338+
},
339+
)
340+
if resp.status_code == 200:
341+
bounds_per_group[group_name] = resp.json()["bounds"]
342+
343+
assert len(bounds_per_group) >= 2, "Need at least 2 groups to compare bounds"
344+
345+
# Use r10m as reference
346+
ref_group = next(g for g in reflectance_groups if g in bounds_per_group)
347+
ref_bounds = bounds_per_group[ref_group]
348+
extent = max(
349+
abs(ref_bounds[2] - ref_bounds[0]),
350+
abs(ref_bounds[3] - ref_bounds[1]),
351+
)
352+
# 2% tolerance — derived levels may have slightly different extents due to rounding
353+
tolerance = extent * 0.02
354+
355+
for group_name, bounds in bounds_per_group.items():
356+
if group_name == ref_group:
357+
continue
358+
for i in range(4):
359+
assert abs(bounds[i] - ref_bounds[i]) < tolerance, (
360+
f"Bounds mismatch {group_name} vs {ref_group}: "
361+
f"index {i}: {bounds[i]} vs {ref_bounds[i]} (tolerance {tolerance})"
362+
)

0 commit comments

Comments
 (0)