1+ #!/usr/bin/env python3
12# /// script
23# requires-python = ">=3.11"
34# dependencies = [
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
1519pixel's new coordinate depends only on its own old coordinate. That is exactly
1620the shape of a SQL *scalar UDF*, and it is precisely how the geospatial SQL
1721world already does it — PostGIS ``ST_Transform`` and DuckDB-spatial
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+
2536The UDF (built on ``pyproj``, vectorized over each Arrow batch) mirrors the
2637``cftime()`` UDF already shipped in xarray-sql (see ``xarray_sql/cftime.py``);
2738it 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
4748from __future__ import annotations
4849
50+ import os
51+
4952import numpy as np
5053import pyarrow as pa
5154import pyproj
5457
5558import 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
6068def 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
115151def 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
165194if __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 )
0 commit comments