Skip to content

Commit bcba93a

Browse files
Miguel Moncadaclaude
authored andcommitted
feat: GeoArrow point-geometry columns at registration (geometry=)
register(..., geometry=(x_dim, y_dim)) appends a geometry point column derived from the coordinate dims, synthesized per batch at scan time — the pivot's coordinate columns already carry the values, so the column costs an annotation plus (for WKB) a vectorized 21-byte encode of the rows actually scanned. Two encodings, chosen per destination: - "wkb" (default): geoarrow.wkb extension metadata + CRS. DuckDB >=1.2 with spatial loaded ingests the column as GEOMETRY('OGC:CRS84'), so ST_Within(geometry, ...) works with no ST_Point construction in SQL. - "point": GeoArrow-native separated coordinates; the struct children ARE the coordinate arrays (no copy, no parse) for consumers that execute on native layouts — verified with GeoPandas 1.x GeoDataFrame.from_arrow, CRS carried through. Documented sharp edge, measured: engines do not push ST_* functions into the scan, so a geometry-only predicate defeats chunk pruning and encodes every row (~29x slower than the paired form on a 10M-row grid: 101ms bbox vs 2.9s ST_Within-only vs 118ms bbox+ST_Within). The geospatial docs now state the idiom: bbox conjuncts for pruning, geometry for exactness. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 610843f commit bcba93a

4 files changed

Lines changed: 313 additions & 3 deletions

File tree

docs/geospatial.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -472,3 +472,29 @@ scale. The point of this suite is not to crown a winner but to show that the lin
472472
between the two is exactly where the operation is dense versus where it is
473473
relational, and that for a surprising share of geoscience, the operation is
474474
relational.
475+
476+
## GeoArrow point-geometry columns
477+
478+
`register(..., geometry=("x", "y"))` derives a `geometry` point column
479+
from two coordinate dims. With the default `"wkb"` encoding DuckDB
480+
(spatial loaded) ingests it as a native `GEOMETRY` with the CRS
481+
attached, so geometry predicates need no `ST_Point(x, y)` construction:
482+
483+
```sql
484+
SELECT avg(risk) FROM eri
485+
WHERE y BETWEEN -29 AND -28 AND x BETWEEN -58 AND -57 -- prunes chunks
486+
AND ST_Within(geometry, ST_GeomFromText('POLYGON (...)')) -- refines
487+
```
488+
489+
**Always pair geometry predicates with bbox conjuncts on the coordinate
490+
columns.** Engines do not push functions like `ST_Within` into the
491+
scan, so a geometry-only predicate scans (and encodes) every chunk —
492+
measured ~29x slower than the paired form on a 10M-row grid, where the
493+
bbox prunes first and the exact polygon test is nearly free.
494+
495+
`geometry_encoding="point"` emits GeoArrow-native separated coordinates
496+
instead (the struct children *are* the coordinate arrays): zero-parse
497+
for GeoPandas 1.x (`GeoDataFrame.from_arrow`), lonboard, geoarrow-rs
498+
and SedonaDB. DuckDB does not consume this encoding — pick per
499+
destination. The CRS tag defaults to `OGC:CRS84`; pass
500+
`geometry_crs=...` for anything else.

tests/test_geometry.py

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
"""GeoArrow point-geometry columns derived at registration."""
2+
3+
import json
4+
5+
import numpy as np
6+
import pandas as pd
7+
import pyarrow as pa
8+
import pytest
9+
import xarray as xr
10+
11+
import xarray_sql as xql
12+
from xarray_sql.backends.pyarrow import XarrayPushdownDataset
13+
14+
15+
@pytest.fixture
16+
def grid() -> xr.Dataset:
17+
return xr.Dataset(
18+
{"risk": (["y", "x"], np.arange(8.0 * 6).reshape(8, 6))},
19+
coords={
20+
"y": np.linspace(-28.0, -29.4, 8), # descending, like rasters
21+
"x": np.linspace(-58.0, -57.0, 6),
22+
},
23+
)
24+
25+
26+
def test_geometry_field_annotation(grid):
27+
dataset = xql.arrow_dataset(grid, {"y": 4}, geometry=("x", "y"))
28+
field = dataset.schema.field("geometry")
29+
assert field.type == pa.binary()
30+
assert field.metadata[b"ARROW:extension:name"] == b"geoarrow.wkb"
31+
meta = json.loads(field.metadata[b"ARROW:extension:metadata"])
32+
assert meta == {"crs": "OGC:CRS84"}
33+
34+
35+
def test_wkb_points_decode_exactly(grid):
36+
dataset = xql.arrow_dataset(grid, {"y": 4}, geometry=("x", "y"))
37+
table = dataset.to_table(columns=["geometry", "x", "y"])
38+
blob = table["geometry"][0].as_py()
39+
assert len(blob) == 21 and blob[0] == 1
40+
x = np.frombuffer(blob, "<f8", count=1, offset=5)[0]
41+
y = np.frombuffer(blob, "<f8", count=1, offset=13)[0]
42+
assert (x, y) == (table["x"][0].as_py(), table["y"][0].as_py())
43+
44+
45+
def test_native_points_are_the_coordinate_arrays(grid):
46+
dataset = xql.arrow_dataset(
47+
grid, {"y": 4}, geometry=("x", "y"), geometry_encoding="point"
48+
)
49+
table = dataset.to_table(columns=["x", "y", "geometry"])
50+
geom = table["geometry"].combine_chunks()
51+
np.testing.assert_array_equal(
52+
geom.field("x").to_numpy(), table["x"].to_numpy()
53+
)
54+
np.testing.assert_array_equal(
55+
geom.field("y").to_numpy(), table["y"].to_numpy()
56+
)
57+
58+
59+
def test_geometry_without_projecting_coords(grid):
60+
# geometry can be requested alone; the dims it derives from are
61+
# scanned but projected away.
62+
dataset = xql.arrow_dataset(grid, {"y": 4}, geometry=("x", "y"))
63+
table = dataset.to_table(columns=["geometry", "risk"])
64+
assert table.column_names == ["geometry", "risk"]
65+
assert table.num_rows == 48
66+
67+
68+
def test_duckdb_st_within_on_geometry_column(grid):
69+
duckdb = pytest.importorskip("duckdb")
70+
71+
reads: list = []
72+
dataset = XarrayPushdownDataset(
73+
grid,
74+
{"y": 4},
75+
geometry=("x", "y"),
76+
_iteration_callback=lambda b, n: reads.append(b),
77+
)
78+
con = duckdb.connect()
79+
try:
80+
con.execute("INSTALL spatial; LOAD spatial;")
81+
except duckdb.Error:
82+
pytest.skip("duckdb spatial extension unavailable")
83+
con.register("t", dataset)
84+
85+
described = dict(
86+
(row[0], row[1]) for row in con.execute("DESCRIBE t").fetchall()
87+
)
88+
assert described["geometry"].startswith("GEOMETRY")
89+
90+
# bbox conjuncts prune chunks; the polygon refines row-exactly on
91+
# the ingested GEOMETRY column — no ST_Point construction needed.
92+
reads.clear()
93+
got = con.execute(
94+
"SELECT count(*), round(avg(risk), 3) FROM t "
95+
"WHERE y BETWEEN -28.7 AND -28.0 "
96+
"AND ST_Within(geometry, ST_GeomFromText("
97+
"'POLYGON ((-58.1 -28.75, -56.9 -28.75, -56.9 -27.9, "
98+
"-58.1 -27.9, -58.1 -28.75))'))"
99+
).fetchone()
100+
assert len(reads) == 1 # y-range kept only the first chunk
101+
inside = (grid.y >= -28.7) & (grid.y <= -28.0)
102+
expected = grid.risk.values[inside.values, :]
103+
assert got == (expected.size, round(float(expected.mean()), 3))
104+
105+
106+
def test_geopandas_consumes_native_points(grid):
107+
gpd = pytest.importorskip("geopandas")
108+
109+
dataset = xql.arrow_dataset(
110+
grid, {"y": 4}, geometry=("x", "y"), geometry_encoding="point"
111+
)
112+
gdf = gpd.GeoDataFrame.from_arrow(dataset.to_table())
113+
assert gdf.geometry.iloc[0].x == float(grid.x[0])
114+
assert str(gdf.crs).endswith("CRS84")
115+
116+
117+
def test_geometry_name_collision_raises():
118+
clash = xr.Dataset(
119+
{"geometry": (["x"], np.arange(3.0))},
120+
coords={"x": np.arange(3.0)},
121+
)
122+
with pytest.raises(ValueError, match="shadow"):
123+
xql.arrow_dataset(clash, {"x": 3}, geometry=("x", "x"))

xarray_sql/backends/pyarrow.py

Lines changed: 81 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
iter_record_batches,
4949
resolve_chunks,
5050
)
51+
from ..geometry import GEOMETRY_COLUMN, build_geometry, geometry_field
5152
from ..reader import XarrayRecordBatchReader
5253

5354
DEFAULT_PREFETCH = 4
@@ -271,6 +272,9 @@ def __init__(
271272
batch_size: int = DEFAULT_BATCH_SIZE,
272273
prefetch: int = DEFAULT_PREFETCH,
273274
coalesce_rows: int | None = None,
275+
geometry: tuple[str, str] | None = None,
276+
geometry_encoding: str = "wkb",
277+
geometry_crs: str | None = "OGC:CRS84",
274278
coord_arrays: dict[str, np.ndarray] | None = None,
275279
_iteration_callback: (
276280
Callable[[Block, list[str] | None], None] | None
@@ -287,6 +291,25 @@ def __init__(
287291
)
288292
self._ds = ds
289293
self._schema = _parse_schema(ds)
294+
self._geometry = tuple(geometry) if geometry else None
295+
self._geometry_encoding = geometry_encoding
296+
if self._geometry:
297+
if GEOMETRY_COLUMN in self._schema.names:
298+
raise ValueError(
299+
f"geometry= would shadow an existing column named "
300+
f"{GEOMETRY_COLUMN!r}."
301+
)
302+
missing = [
303+
d for d in self._geometry if d not in self._schema.names
304+
]
305+
if missing:
306+
raise ValueError(
307+
f"geometry dims {missing} are not columns of the "
308+
f"table; available: {self._schema.names}"
309+
)
310+
self._schema = self._schema.append(
311+
geometry_field(geometry_encoding, geometry_crs)
312+
)
290313
self._resolved = resolve_chunks(ds, chunks)
291314
if not self._resolved and ds.sizes:
292315
raise ValueError(
@@ -372,13 +395,50 @@ def _scanner_for_blocks(
372395
proj = list(self._schema.names) if columns is None else list(columns)
373396
scan_names = self._scan_columns(proj, filter)
374397
scan_schema = pa.schema([self._schema.field(n) for n in scan_names])
375-
batches = self._batch_generator(
376-
scan_schema, blocks, batch_size or self._batch_size
377-
)
398+
size = batch_size or self._batch_size
399+
if self._geometry and GEOMETRY_COLUMN in scan_names:
400+
batches = self._with_geometry(scan_schema, blocks, size)
401+
else:
402+
batches = self._batch_generator(scan_schema, blocks, size)
378403
return pads.Scanner.from_batches(
379404
batches, schema=scan_schema, columns=proj, filter=filter
380405
)
381406

407+
def _with_geometry(
408+
self,
409+
scan_schema: pa.Schema,
410+
blocks: Iterator[Block] | list[Block],
411+
batch_size: int,
412+
) -> Iterator[pa.RecordBatch]:
413+
"""Emit ``scan_schema`` batches, synthesizing the geometry column.
414+
415+
The pivot never materializes geometry: batches are produced with
416+
the coordinate dims the geometry derives from, and the geometry
417+
column is built per batch from those columns (for the native
418+
encoding the point struct's children *are* the coordinate
419+
arrays — a schema annotation, not a copy).
420+
"""
421+
assert self._geometry is not None
422+
x_dim, y_dim = self._geometry
423+
base_names = [n for n in scan_schema.names if n != GEOMETRY_COLUMN]
424+
for d in (x_dim, y_dim):
425+
if d not in base_names:
426+
base_names.append(d)
427+
base_schema = pa.schema([self._schema.field(n) for n in base_names])
428+
for batch in self._batch_generator(base_schema, blocks, batch_size):
429+
geom = build_geometry(
430+
self._geometry_encoding,
431+
batch.column(base_names.index(x_dim)),
432+
batch.column(base_names.index(y_dim)),
433+
)
434+
arrays = [
435+
geom
436+
if name == GEOMETRY_COLUMN
437+
else batch.column(base_names.index(name))
438+
for name in scan_schema.names
439+
]
440+
yield pa.RecordBatch.from_arrays(arrays, schema=scan_schema)
441+
382442
def get_fragments(
383443
self, filter: pc.Expression | None = None
384444
) -> list["_XarrayFragment"]:
@@ -857,6 +917,9 @@ def arrow_dataset(
857917
batch_size: int = DEFAULT_BATCH_SIZE,
858918
prefetch: int = DEFAULT_PREFETCH,
859919
coalesce_rows: int | None = None,
920+
geometry: tuple[str, str] | None = None,
921+
geometry_encoding: str = "wkb",
922+
geometry_crs: str | None = "OGC:CRS84",
860923
) -> XarrayPushdownDataset:
861924
"""A pushdown-capable ``pyarrow.dataset.Dataset`` view of ``ds``.
862925
@@ -886,6 +949,18 @@ def arrow_dataset(
886949
fetches its member chunks through the store's own concurrent
887950
batching. Memory scales with ``prefetch`` x the *merged*
888951
block size, so size accordingly (e.g. ``8_000_000``).
952+
geometry: ``(x_dim, y_dim)`` coordinate dims to derive a
953+
``geometry`` point column from (see
954+
:mod:`xarray_sql.geometry`). With the default ``"wkb"``
955+
encoding, DuckDB (spatial loaded) sees a native ``GEOMETRY``
956+
column, so ``ST_Within(geometry, ...)`` works directly.
957+
geometry_encoding: ``"wkb"`` (default; DuckDB-consumable) or
958+
``"point"`` (GeoArrow native separated coordinates — the
959+
struct children are the coordinate arrays; for GeoPandas,
960+
lonboard, geoarrow-rs consumers).
961+
geometry_crs: CRS tag carried in the extension metadata.
962+
Defaults to ``OGC:CRS84`` (plain longitude/latitude); pass
963+
``None`` to omit, or an authority code / PROJJSON string.
889964
890965
Returns:
891966
An :class:`XarrayPushdownDataset`.
@@ -896,4 +971,7 @@ def arrow_dataset(
896971
batch_size=batch_size,
897972
prefetch=prefetch,
898973
coalesce_rows=coalesce_rows,
974+
geometry=geometry,
975+
geometry_encoding=geometry_encoding,
976+
geometry_crs=geometry_crs,
899977
)

xarray_sql/geometry.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
"""GeoArrow point-geometry columns derived from coordinate dimensions.
2+
3+
A regular grid's pivot already materializes per-row x/y coordinate
4+
columns; a point-geometry column is those same values under a GeoArrow
5+
extension annotation. Two encodings:
6+
7+
* ``"wkb"`` (default) — 21-byte WKB points under the ``geoarrow.wkb``
8+
extension name. DuckDB (>= 1.2, spatial loaded) ingests the column as
9+
a native ``GEOMETRY`` with the CRS attached, so ``ST_Within(geometry,
10+
...)`` works with no ``ST_Point(x, y)`` construction in user SQL.
11+
* ``"point"`` — GeoArrow native points with *separated* coordinates
12+
(``struct<x: double, y: double>`` under ``geoarrow.point``): the
13+
child arrays are the coordinate columns themselves, no per-row
14+
parsing for consumers that execute on native layouts (GeoPandas 1.x,
15+
geoarrow-rs, lonboard, SedonaDB). DuckDB does not consume this
16+
encoding.
17+
18+
The CRS rides in the extension metadata (GeoArrow 0.2 allows
19+
authority:code strings alongside PROJJSON). ``OGC:CRS84`` is the
20+
correct tag for plain longitude/latitude grids.
21+
"""
22+
23+
from __future__ import annotations
24+
25+
import json
26+
27+
import numpy as np
28+
import pyarrow as pa
29+
30+
GEOMETRY_COLUMN = "geometry"
31+
32+
_ENCODINGS = ("wkb", "point")
33+
34+
35+
def geometry_field(encoding: str, crs: str | None) -> pa.Field:
36+
"""The schema field for the derived geometry column."""
37+
if encoding not in _ENCODINGS:
38+
raise ValueError(
39+
f"geometry_encoding must be one of {_ENCODINGS}, got {encoding!r}"
40+
)
41+
metadata = {
42+
b"ARROW:extension:name": f"geoarrow.{encoding}".encode(),
43+
}
44+
if crs is not None:
45+
metadata[b"ARROW:extension:metadata"] = json.dumps(
46+
{"crs": crs}
47+
).encode()
48+
storage = (
49+
pa.binary()
50+
if encoding == "wkb"
51+
else pa.struct([("x", pa.float64()), ("y", pa.float64())])
52+
)
53+
return pa.field(GEOMETRY_COLUMN, storage, metadata=metadata)
54+
55+
56+
def build_geometry(
57+
encoding: str, x: pa.Array, y: pa.Array
58+
) -> pa.Array:
59+
"""Point geometries for one batch's x/y coordinate columns."""
60+
if encoding == "point":
61+
return pa.StructArray.from_arrays(
62+
[x.cast(pa.float64()), y.cast(pa.float64())], ["x", "y"]
63+
)
64+
return _wkb_points(
65+
np.ascontiguousarray(x.to_numpy(zero_copy_only=False), "<f8"),
66+
np.ascontiguousarray(y.to_numpy(zero_copy_only=False), "<f8"),
67+
)
68+
69+
70+
def _wkb_points(x: np.ndarray, y: np.ndarray) -> pa.Array:
71+
"""Vectorized 21-byte little-endian WKB point encoding."""
72+
n = len(x)
73+
buf = np.empty((n, 21), dtype=np.uint8)
74+
buf[:, 0] = 1 # little-endian byte order mark
75+
buf[:, 1:5] = np.array([1, 0, 0, 0], dtype=np.uint8) # type: Point
76+
buf[:, 5:13] = x.view(np.uint8).reshape(n, 8)
77+
buf[:, 13:21] = y.view(np.uint8).reshape(n, 8)
78+
offsets = pa.py_buffer(
79+
np.arange(0, (n + 1) * 21, 21, dtype=np.int32).tobytes()
80+
)
81+
return pa.Array.from_buffers(
82+
pa.binary(), n, [None, offsets, pa.py_buffer(buf.tobytes())]
83+
)

0 commit comments

Comments
 (0)