Skip to content

feat: pyproj CRS extension — generic reproject(x, y, src_crs, dst_crs) UDF#1

Open
Mmoncadaisla wants to merge 4 commits into
mainfrom
feat/proj-udf-extension
Open

feat: pyproj CRS extension — generic reproject(x, y, src_crs, dst_crs) UDF#1
Mmoncadaisla wants to merge 4 commits into
mainfrom
feat/proj-udf-extension

Conversation

@Mmoncadaisla

@Mmoncadaisla Mmoncadaisla commented Jul 9, 2026

Copy link
Copy Markdown
Owner

Fork-local copy of the PR proposed upstream as xqlsystems#225 — kept here for review and iteration on my own fork.

Summary

  • New optional extension xarray_sql/proj.py (pip install xarray-sql[proj]): an ST_Transform-style scalar UDF reproject(x, y, src_crs, dst_crs) returning a {x, y} struct. CRS pair lives in the query (any pyproj CRS spelling, may vary per row) instead of being hard-coded at registration time.
  • All PROJ work runs on a dedicated pool of Python-owned threads: constructing a pyproj.Transformer on DataFusion's tokio runtime threads segfaults inside PROJ, while identical concurrent work on Python threads is stable (pyproj 3.7.2 / PROJ 9.5.1). Per-thread transformer caches; pyproj releases the GIL, so partitions transform in parallel.
  • XarrayContext auto-registers reproject() when pyproj is installed (same pattern as the cftime() UDF); proj.register(ctx, name=...) for plain SessionContexts.
  • NULL / non-finite coordinates and PROJ's out-of-domain inf → NaN; invalid CRS fails the query loudly.
  • Benchmarks 07/09 use the extension (local hard-coded UDFs removed); docs updated; tests/test_proj.py added and pyproj added to the test extra.

Design context — why the CRS is a query argument: DataFusion's type system can't yet attach metadata like a CRS to a column type (extension types: apache/datafusion#12644; spatial support overall: apache/datafusion#7859), so an "implicit CRS" would have to hide in UDF registration state — the hard-coding this PR removes. When extension types land and GeoArrow columns carry their CRS (e.g. datafusion-contrib/datafusion-geo), a geometry-typed ST_Transform(geom, dst) can arrive alongside reproject() rather than replace it.

See the upstream PR for the full write-up and testing caveats.

🤖 Generated with Claude Code

Miguel Moncada and others added 3 commits July 9, 2026 12:48
…) UDF

Adds xarray_sql/proj.py, an optional pyproj extension that registers an
ST_Transform-style scalar UDF. The CRS pair is part of the query (any
spelling pyproj.CRS accepts, and it may vary per row via ordinary SQL
expressions) instead of being baked in at UDF registration time, which
is how the geospatial benchmarks previously hard-coded it.

All PROJ work runs on a dedicated pool of Python-owned worker threads:
constructing a Transformer on a DataFusion runtime thread segfaults
inside PROJ, while identical concurrent work on Python threads is
stable (pyproj 3.7 / PROJ 9.5). Each pool thread caches one transformer
per CRS pair, and pyproj releases the GIL during transforms, so the UDF
is parallel across partitions — the previous single-chunk/serial-UDF
workaround in the benchmarks is no longer needed.

XarrayContext auto-registers reproject() when pyproj is installed
(pip install xarray-sql[proj]); proj.register() is the explicit hook
for plain SessionContexts or custom names. Benchmarks 07 and 09 now use
the extension instead of their local hard-coded UDFs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The y-chunking exists to force several DataFusion partitions so the
reproject() UDF demonstrably runs in parallel; say so explicitly instead
of hiding it behind a computed chunk size.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The kernel materialized the two broadcast CRS string columns with
to_pylist() — two Python object allocations per row before PROJ did any
work. At benchmark scale that dominated everything: a 606M-pixel
aggregate-only reprojection took 496s while the same scan without the
UDF took 4.5s.

Establish CRS uniqueness with pyarrow.compute.unique (vectorized C)
instead. A batch with one CRS pair — the overwhelmingly common case —
becomes a single vectorized PROJ call with no per-row Python. The
per-row grouping path remains for genuinely varying CRS (e.g. a CASE
expression picking the UTM zone).

Same 606M-pixel run after: 16.0s (37.9M px/s), a 31x speedup, with
bit-identical results and flat (~2GB) streaming memory.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Mmoncadaisla

Copy link
Copy Markdown
Owner Author

Scale test: all 9.13 billion pixels of a real national raster

Ran the UDF over every pixel coordinate of a production 675 MB GeoTIFF (ERI Argentina v3, 123,459 × 73,963 ≈ 9.13e9 cells, WGS84 → UTM 20S) as an aggregate-only streaming query (nothing materialized). On an M-series MacBook:

rows wall throughput peak RSS
scan + aggregate, no UDF 606 M 4.5 s 134 M rows/s 2.1 GB
reproject(), original kernel 606 M 495.7 s 1.2 M px/s 1.5 GB
reproject(), after 925c7a6 606 M 16.0 s 37.9 M px/s 2.1 GB
reproject(), full raster 9.13 B 230.4 s 39.6 M px/s 2.26 GB

Findings:

  • The original kernel spent ~97% of its time materializing the two broadcast CRS string columns with to_pylist() — two Python allocations per row before PROJ did any work. Commit 925c7a6 establishes CRS uniqueness with pyarrow.compute.unique and turns a constant-CRS batch into one vectorized PROJ call: 31× faster, bit-identical results. The per-row grouping path survives for genuinely varying CRS.
  • The pipeline is streaming end to end: a 9.13-billion-row query with a Python UDF holds ~2.3 GB RSS.
  • Validation: row count exact, and SQL MIN/MAX easting/northing match pyproj run along the grid boundary to <1 m (boundary, not corners — the UTM graticule bows tens of km over 20° of longitude).

Script: scale_test.py (kept outside the repo for now; could become a perf_test if wanted).

🤖 Generated with Claude Code

Aligns with the base + add-ons direction: geo is the umbrella extra
for CRS reprojection today and polygon/vector support later, rather
than being named after the first feature it happens to ship. The
module stays proj.py — it does CRS reprojection specifically.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant