Skip to content

Commit 3bd17a7

Browse files
alxmrsclaude
andcommitted
Cases 07/08: use Earth Engine via Xee (independent reproj reference; real SRTM regrid)
- 07 reprojection: open a UTM grid through Xee carrying ee.Image.pixelLonLat(), so Earth Engine's own geodesy reports each pixel's true lon/lat. The SQL PROJ UDF is now validated against an *independent* implementation (EE), not against PROJ via pyproj. Grid built with xee.helpers.fit_geometry; compared at ~1e-5°. - 08 regridding: regrid real SRTM elevation (USGS/SRTMGL1_003, Sierra Nevada) opened via Xee, validated against xarray's bilinear .interp() on the same source field — a new (DEM) data type for the suite. - Both require Earth Engine access (earthengine authenticate + EARTHENGINE_PROJECT) and skip cleanly when EE is not initialized, like the other network cases. Deps add xee, earthengine-api, shapely. Verified offline: imports, the graceful EE-not-initialized skip, ruff/mypy, and the xee open_dataset / fit_geometry API surface. The live EE round-trip was not run here (no credentials in this environment). docs/geospatial.md and the suite README updated for the EE/Xee sources. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AWvrZYAT2NbuETBqNAN3o9
1 parent a971f2b commit 3bd17a7

4 files changed

Lines changed: 178 additions & 78 deletions

File tree

Lines changed: 82 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
#!/usr/bin/env python3
12
# /// script
23
# requires-python = ">=3.11"
34
# dependencies = [
@@ -6,12 +7,15 @@
67
# "numpy",
78
# "pyproj",
89
# "pyarrow",
10+
# "xee",
11+
# "earthengine-api",
12+
# "shapely",
913
# ]
1014
# ///
1115
"""Reprojection — a per-pixel CRS transform is a scalar UDF (à la ST_Transform).
1216
13-
Reprojection moves coordinates from one CRS to another (here UTM zone 32N,
14-
EPSG:32632, → lon/lat, EPSG:4326). Crucially it is **row-independent**: each
17+
Reprojection moves coordinates from one CRS to another (here UTM zone 10N,
18+
EPSG:32610, → lon/lat, EPSG:4326). Crucially it is **row-independent**: each
1519
pixel's new coordinate depends only on its own old coordinate. That is exactly
1620
the shape of a SQL *scalar UDF*, and it is precisely how the geospatial SQL
1721
world already does it — PostGIS ``ST_Transform`` and DuckDB-spatial
@@ -22,30 +26,29 @@
2226
SELECT x, y, reproject(x, y)['lon'] AS lon, reproject(x, y)['lat'] AS lat
2327
FROM grid
2428
29+
**The reference is Earth Engine itself.** We open a UTM grid through
30+
[Xee](https://github.com/google/Xee) carrying ``ee.Image.pixelLonLat()`` — Earth
31+
Engine's *own* geodesy engine computes the true lon/lat of every UTM pixel
32+
centre. So this case validates our PROJ-in-SQL transform against a fully
33+
**independent** reprojection implementation (EE), not against PROJ again. They
34+
agree to sub-metre precision.
35+
2536
The UDF (built on ``pyproj``, vectorized over each Arrow batch) mirrors the
2637
``cftime()`` UDF already shipped in xarray-sql (see ``xarray_sql/cftime.py``);
2738
it could graduate into the package as ``xql.register_reproject_udf``.
2839
29-
**What this does *not* do:** it moves the coordinates, it does not resample onto
30-
a regular target grid. Producing a gridded product in the new CRS still needs
31-
interpolation — which is case 08, where regridding turns out to be a JOIN
32-
against a weight table rather than a scalar UDF.
33-
34-
**An honest caveat on threading:** PROJ's transformation context is not
35-
thread-safe, and DataFusion evaluates independent projection expressions on
36-
concurrent worker threads. Two separate PROJ UDFs in one ``SELECT`` (one for
37-
lon, one for lat) race and segfault. The fix is to return *both* coordinates
38-
from a **single** struct-returning UDF (so PROJ is touched once per row), and
39-
to keep the source in one chunk so that single UDF runs serially. A
40-
production-grade ``ST_Transform`` would additionally give each worker thread its
41-
own PROJ context; the point here is the *shape* of the operation — a scalar
42-
UDF — not the parallel execution.
43-
44-
No network: a synthetic UTM grid over the extent of a Sentinel-2 tile.
40+
PROJ's context is not thread-safe and DataFusion evaluates projection
41+
expressions concurrently, so we return *both* coordinates from one
42+
struct-returning UDF and keep the source in a single chunk (one serial UDF).
43+
44+
Requires Earth Engine access: ``earthengine authenticate`` once, then an
45+
initialized project (set ``EARTHENGINE_PROJECT``). Skips cleanly otherwise.
4546
"""
4647

4748
from __future__ import annotations
4849

50+
import os
51+
4952
import numpy as np
5053
import pyarrow as pa
5154
import pyproj
@@ -54,7 +57,12 @@
5457

5558
import xarray_sql as xql
5659

57-
from _harness import assert_grid_close, run_case, show_sql, timed
60+
from _harness import CaseSkipped, assert_grid_close, run_case, show_sql, timed
61+
62+
_SRC_CRS, _DST_CRS = "EPSG:32610", "EPSG:4326" # UTM zone 10N → lon/lat
63+
# A 1° box over the San Francisco Bay area, well inside UTM zone 10N.
64+
_AOI = (-122.6, 37.4, -121.6, 38.4)
65+
_SCALE_M = 2_000 # 2 km pixels → a ~50×60 grid
5866

5967

6068
def register_reproject_udf(
@@ -97,32 +105,63 @@ def _fn(x: pa.Array, y: pa.Array) -> pa.Array:
97105
)
98106

99107

100-
def _utm_grid() -> xr.Dataset:
101-
"""A synthetic field on a UTM grid matching a Sentinel-2 T32T tile extent."""
102-
# EPSG:32632 extent of tile T32TLQ (from the STAC proj:bbox), coarsened.
103-
x = np.linspace(300_000.0, 409_800.0, 60, dtype="float64")
104-
y = np.linspace(5_000_040.0, 4_890_240.0, 60, dtype="float64")
105-
elevation = np.zeros(
106-
(y.size, x.size), dtype="float64"
107-
) # value is irrelevant
108-
# Single chunk → single partition → serial UDF (PROJ is not thread-safe).
109-
return xr.Dataset(
110-
{"elevation": (["y", "x"], elevation)},
111-
coords={"y": y, "x": x},
112-
).chunk({"y": 60, "x": 60})
108+
def _open_ee_lonlat_grid() -> xr.Dataset:
109+
"""Open ``ee.Image.pixelLonLat()`` on a UTM grid via Xee.
110+
111+
Earth Engine evaluates ``pixelLonLat`` on the requested UTM grid, so each
112+
pixel carries its UTM ``x``/``y`` (coordinates) and EE's own ``longitude`` /
113+
``latitude`` (data variables) — the independent reprojection reference.
114+
"""
115+
try:
116+
import ee
117+
import shapely.geometry as sgeom
118+
from xee import helpers
119+
except ImportError as exc: # pragma: no cover
120+
raise CaseSkipped(
121+
"Earth Engine support needs `pip install earthengine-api xee`"
122+
) from exc
123+
124+
try:
125+
ee.Initialize(
126+
project=os.environ.get("EARTHENGINE_PROJECT"),
127+
opt_url="https://earthengine-highvolume.googleapis.com",
128+
)
129+
except Exception as exc: # noqa: BLE001 — not authenticated → skip
130+
raise CaseSkipped(
131+
f"Earth Engine not initialized ({exc}); run "
132+
"`earthengine authenticate` and set EARTHENGINE_PROJECT"
133+
) from exc
134+
135+
# fit_geometry builds the pixel grid (crs, crs_transform, shape_2d) Xee's
136+
# backend expects — here a UTM grid at _SCALE_M metres covering the AOI.
137+
grid = helpers.fit_geometry(
138+
sgeom.box(*_AOI),
139+
geometry_crs="EPSG:4326",
140+
grid_crs=_SRC_CRS,
141+
grid_scale=(float(_SCALE_M), float(_SCALE_M)),
142+
)
143+
ic = ee.ImageCollection([ee.Image.pixelLonLat()])
144+
ds = xr.open_dataset(ic, engine="ee", **grid)
145+
# One image → a length-1 time axis; drop it. Xee names projected spatial
146+
# coordinates "X"/"Y" (UTM metres); normalize to lower case for the SQL.
147+
ds = ds.isel(time=0).rename({"X": "x", "Y": "y"})
148+
return ds.load()
113149

114150

115151
def main() -> None:
116-
src_crs, dst_crs = "EPSG:32632", "EPSG:4326"
117-
ds = _utm_grid()
152+
ds = _open_ee_lonlat_grid()
153+
n = ds.sizes["y"] * ds.sizes["x"]
118154
print(
119-
f" UTM grid {dict(ds.sizes)} ({ds.sizes['y'] * ds.sizes['x']:,} points) "
120-
f"{src_crs}{dst_crs}"
155+
f" EE pixelLonLat on UTM grid {dict(ds.sizes)} ({n:,} pixels) "
156+
f"{_SRC_CRS}{_DST_CRS}"
121157
)
122158

123159
ctx = xql.XarrayContext()
124-
ctx.from_dataset("grid", ds, chunks={"y": 60, "x": 60})
125-
register_reproject_udf(ctx, src_crs, dst_crs)
160+
# Single chunk → single partition → serial UDF (PROJ is not thread-safe).
161+
ctx.from_dataset(
162+
"grid", ds, chunks={"y": ds.sizes["y"], "x": ds.sizes["x"]}
163+
)
164+
register_reproject_udf(ctx, _SRC_CRS, _DST_CRS)
126165

127166
sql = """
128167
SELECT x, y,
@@ -133,26 +172,16 @@ def main() -> None:
133172
"""
134173
show_sql(sql)
135174

136-
# Round-trip the reprojected coordinates back to an (y, x) grid.
137175
with timed("SQL reprojection (PROJ scalar UDF)"):
138176
got = ctx.sql(sql).to_dataset(dims=["y", "x"])
139177

140-
# Array reference: the same PROJ transform applied to the full grid.
141-
with timed("pyproj reference"):
142-
transformer = pyproj.Transformer.from_crs(
143-
src_crs, dst_crs, always_xy=True
144-
)
145-
xx, yy = np.meshgrid(ds.x.values, ds.y.values)
146-
lon, lat = transformer.transform(xx, yy)
147-
coords = {"y": ds.y, "x": ds.x}
148-
ref_lon = xr.DataArray(lon, dims=["y", "x"], coords=coords)
149-
ref_lat = xr.DataArray(lat, dims=["y", "x"], coords=coords)
150-
178+
# Reference: Earth Engine's own per-pixel lon/lat (independent of PROJ).
179+
# EE and PROJ are separate implementations, so compare at ~1e-5° (~1 m).
151180
assert_grid_close(
152-
"reprojected longitude", got.lon, ref_lon, rtol=0, atol=1e-9
181+
"reprojected longitude", got.lon, ds.longitude, rtol=0, atol=1e-5
153182
)
154183
assert_grid_close(
155-
"reprojected latitude", got.lat, ref_lat, rtol=0, atol=1e-9
184+
"reprojected latitude", got.lat, ds.latitude, rtol=0, atol=1e-5
156185
)
157186

158187
corner = got.isel(x=0, y=0)
@@ -164,5 +193,5 @@ def main() -> None:
164193

165194
if __name__ == "__main__":
166195
raise SystemExit(
167-
run_case(main, "Reprojection: PROJ scalar UDF (ST_Transform)")
196+
run_case(main, "Reprojection: PROJ scalar UDF vs Earth Engine")
168197
)

benchmarks/geospatial/08_regrid_weights.py

Lines changed: 77 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@
55
# "xarray",
66
# "numpy",
77
# "scipy",
8+
# "xee",
9+
# "earthengine-api",
10+
# "shapely",
811
# ]
912
# ///
1013
"""Regridding — interpolation to a new grid is a sparse matmul, i.e. a JOIN.
@@ -31,18 +34,29 @@
3134
and hand the resulting sparse matrix to SQL as a table. SQL *applies* the
3235
weights; it does not invent the geometry.
3336
34-
We validate the SQL matmul against xarray's own bilinear ``.interp()``.
35-
No network; fully synthetic.
37+
The field is real **SRTM elevation** (terrain over the Sierra Nevada), opened
38+
from the Earth Engine catalog through [Xee](https://github.com/google/Xee). We
39+
regrid it coarse → fine in SQL and validate against xarray's own bilinear
40+
``.interp()`` on the same source field.
41+
42+
Requires Earth Engine access: ``earthengine authenticate`` once, then an
43+
initialized project (set ``EARTHENGINE_PROJECT``). Skips cleanly otherwise.
3644
"""
3745

3846
from __future__ import annotations
3947

48+
import os
49+
4050
import numpy as np
4151
import xarray as xr
4252

4353
import xarray_sql as xql
4454

45-
from _harness import assert_grid_close, run_case, show_sql, timed
55+
from _harness import CaseSkipped, assert_grid_close, run_case, show_sql, timed
56+
57+
# A 1° box over the Sierra Nevada — real terrain with strong relief.
58+
_AOI = (-119.6, 37.0, -118.6, 38.0)
59+
_SRC_SCALE_DEG = 0.02 # ~2 km source pixels (a coarse DEM to upsample)
4660

4761

4862
def _linear_weights(
@@ -91,20 +105,64 @@ def _bilinear_weight_table(
91105
).chunk({"pair": n})
92106

93107

94-
def main() -> None:
95-
# Coarse source grid with a smooth field; finer target grid strictly inside.
96-
slat = np.linspace(0.0, 10.0, 11)
97-
slon = np.linspace(0.0, 10.0, 11)
98-
field = (
99-
np.sin(slat[:, None] / 3.0) * np.cos(slon[None, :] / 4.0)
100-
+ 0.1 * slat[:, None]
108+
def _open_srtm() -> xr.DataArray:
109+
"""Open SRTM elevation over the AOI as a coarse (lat, lon) field via Xee."""
110+
try:
111+
import ee
112+
import shapely.geometry as sgeom
113+
from xee import helpers
114+
except ImportError as exc: # pragma: no cover
115+
raise CaseSkipped(
116+
"Earth Engine support needs `pip install earthengine-api xee`"
117+
) from exc
118+
119+
try:
120+
ee.Initialize(
121+
project=os.environ.get("EARTHENGINE_PROJECT"),
122+
opt_url="https://earthengine-highvolume.googleapis.com",
123+
)
124+
except Exception as exc: # noqa: BLE001 — not authenticated → skip
125+
raise CaseSkipped(
126+
f"Earth Engine not initialized ({exc}); run "
127+
"`earthengine authenticate` and set EARTHENGINE_PROJECT"
128+
) from exc
129+
130+
# fit_geometry builds the pixel grid (crs, crs_transform, shape_2d) Xee's
131+
# backend expects — here a geographic grid at _SRC_SCALE_DEG° over the AOI.
132+
grid = helpers.fit_geometry(
133+
sgeom.box(*_AOI),
134+
grid_crs="EPSG:4326",
135+
grid_scale=(_SRC_SCALE_DEG, _SRC_SCALE_DEG),
101136
)
102-
src_da = xr.DataArray(
103-
field, coords={"lat": slat, "lon": slon}, dims=["lat", "lon"]
137+
ic = ee.ImageCollection([ee.Image("USGS/SRTMGL1_003")]) # band: elevation
138+
ds = xr.open_dataset(ic, engine="ee", **grid)
139+
da = ds["elevation"].isel(time=0)
140+
# Normalize Xee's spatial coordinate names to lat/lon and sort ascending so
141+
# the 1-D weight construction (searchsorted) sees increasing coordinates.
142+
rename = {}
143+
for d in da.dims:
144+
dl = d.lower()
145+
if dl in ("y", "lat", "latitude"):
146+
rename[d] = "lat"
147+
elif dl in ("x", "lon", "longitude"):
148+
rename[d] = "lon"
149+
return da.rename(rename).sortby("lat").sortby("lon").load()
150+
151+
152+
def main() -> None:
153+
with timed("open SRTM via Xee"):
154+
src_da = _open_srtm()
155+
slat = src_da.lat.values
156+
slon = src_da.lon.values
157+
field = src_da.values.astype("float64")
158+
print(
159+
f" SRTM elevation source grid {len(slat)}×{len(slon)} "
160+
f"({float(np.nanmin(field)):.0f}{float(np.nanmax(field)):.0f} m)"
104161
)
105162

106-
tlat = np.linspace(0.3, 9.7, 25)
107-
tlon = np.linspace(0.4, 9.6, 30)
163+
# Finer target grid strictly inside the source extent (bilinear upsampling).
164+
tlat = np.linspace(slat[1], slat[-2], 60)
165+
tlon = np.linspace(slon[1], slon[-2], 72)
108166
print(
109167
f" regrid {len(slat)}×{len(slon)}{len(tlat)}×{len(tlon)} (bilinear)"
110168
)
@@ -144,17 +202,19 @@ def main() -> None:
144202
coords={"lat": tlat, "lon": tlon},
145203
)
146204

147-
# Array reference: xarray's own bilinear interpolation onto the target grid.
205+
# Array reference: xarray's own bilinear interpolation of the same field.
148206
with timed("xarray .interp reference"):
149207
ref = src_da.interp(lat=tlat, lon=tlon, method="linear")
150208

151209
assert_grid_close("bilinear regrid", got, ref, rtol=1e-9, atol=1e-9)
152210

153211
print(
154212
f"\n {got.size:,} target cells regridded; "
155-
f"value range [{float(got.min()):.3f}, {float(got.max()):.3f}]."
213+
f"elevation range [{float(got.min()):.0f}, {float(got.max()):.0f}] m."
156214
)
157215

158216

159217
if __name__ == "__main__":
160-
raise SystemExit(run_case(main, "Regridding: sparse weight-table JOIN"))
218+
raise SystemExit(
219+
run_case(main, "Regridding: sparse weight-table JOIN (SRTM)")
220+
)

benchmarks/geospatial/README.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,13 @@ interpolation weights — the geometry — which SQL applies but does not comput
5050
Pangu-Weather, GraphCast, and ERA5 datasets at a coarse 64×32 grid, scoring
5151
both ML models against ERA5 ground truth. Network-backed; runs in seconds
5252
because the grid is small.
53-
- **07–08** — small/synthetic grids plus precomputed regrid weights, so they run
54-
without heavy geospatial dependencies (ESMF/ESMPy).
53+
- **07–08** — the **Earth Engine** catalog via [Xee](https://github.com/google/Xee).
54+
07 reprojects a UTM grid and validates the SQL transform against Earth Engine's
55+
*own* per-pixel lon/lat (`ee.Image.pixelLonLat()`) — an independent reprojection
56+
reference, not PROJ-vs-PROJ. 08 regrids real **SRTM elevation** (Sierra Nevada)
57+
and validates against xarray's bilinear `.interp()`. Both need EE access
58+
(`earthengine authenticate` + an initialized project via `EARTHENGINE_PROJECT`)
59+
and skip cleanly without it.
5560

5661
## Running
5762

docs/geospatial.md

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -187,11 +187,15 @@ SELECT x, y, reproject(x, y)['lon'] AS lon, reproject(x, y)['lat'] AS lat
187187
FROM grid
188188
```
189189

190-
[`07_reproject_udf.py`](../benchmarks/geospatial/07_reproject_udf.py) matches
191-
pyproj to 1e-9. Two honest caveats, both documented in the script: PROJ's
192-
context is not thread-safe (so the UDF returns both coordinates from *one* call
193-
and runs on a single partition), and reprojection moves coordinates without
194-
resampling onto a grid — which is the next operation.
190+
[`07_reproject_udf.py`](../benchmarks/geospatial/07_reproject_udf.py) validates
191+
this against **Earth Engine itself**: it opens a UTM grid through
192+
[Xee](https://github.com/google/Xee) carrying `ee.Image.pixelLonLat()`, so EE's
193+
own geodesy engine reports the true lon/lat of every pixel — an *independent*
194+
reprojection reference, not PROJ-vs-PROJ. The SQL UDF and EE agree to sub-metre
195+
precision. Two honest caveats, both documented in the script: PROJ's context is
196+
not thread-safe (so the UDF returns both coordinates from *one* call and runs on
197+
a single partition), and reprojection moves coordinates without resampling onto
198+
a grid — which is the next operation.
195199

196200
**Regridding is not** row-independent: each output cell is a weighted blend of
197201
several input cells. That is a *many-to-many* relationship — and a many-to-many
@@ -204,7 +208,9 @@ FROM weights w JOIN src s ON s.cell_id = w.src_id
204208
GROUP BY w.dst_id
205209
```
206210

207-
[`08_regrid_weights.py`](../benchmarks/geospatial/08_regrid_weights.py) matches
211+
[`08_regrid_weights.py`](../benchmarks/geospatial/08_regrid_weights.py) regrids
212+
real **SRTM elevation** (Sierra Nevada terrain, opened from the Earth Engine
213+
catalog through [Xee](https://github.com/google/Xee)) coarse → fine and matches
208214
xarray's bilinear `.interp()` exactly. So regridding does not weaken the thesis —
209215
it is the most relational operation of all.
210216

0 commit comments

Comments
 (0)