Skip to content

Commit d790a4c

Browse files
committed
Add opt-in integer grid-position columns for exact grid joins
Regridding and forecast alignment join a source grid to a table on the source coordinate. Joining on the floating-point coordinate value is fragile: any sub-ULP drift — e.g. a reproject/interp UDF that computes in float32 — makes the equality join silently drop rows, producing a wrong answer with no error. At realistic scale a float32 round-trip of the weight-table coordinates drops ~99.9% of destination cells. Add `from_dataset(..., index_columns=True)`: for every dimension, emit an `int32` `<dim>_idx` column carrying each row's *absolute* integer position on that axis. Joining grids on these integer keys is exact (no float-equality mismatch), a bit faster (integer hashing), and half the key bytes — and, unlike dictionary-encoded coordinates, they are plain Int32 columns, so nothing in DataFusion's join/aggregate/scalar-function paths is stressed. The indices are global, not per-partition: the reader adds each block's start offset so keys line up across chunks (a local index would restart at 0 in every partition and mis-join). Coordinate columns stay dense and available for value predicates (`WHERE lat > 45`, `date_part`) and display; the index columns are opt-in and off by default. - df.py: `_parse_schema(index_columns=)` appends the `<dim>_idx` fields (with a collision guard); `iter_record_batches` / `dataset_to_record_batch` emit them from the strided position plus a block offset. - reader.py / sql.py: thread `index_columns` through `read_xarray_table` and `from_dataset`, computing per-block offsets so indices are global. - tests: global-across-chunks, exact index-keyed regrid, and the float32-drift case where the float join drops cells but the index join stays exact. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019VuSeCio99NcME5eubcN3N
1 parent d8ffa52 commit d790a4c

4 files changed

Lines changed: 220 additions & 12 deletions

File tree

tests/test_grid_index.py

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
"""Integer grid-position columns (``<dim>_idx``) for exact grid joins.
2+
3+
Regridding and forecast alignment join a source grid to a table on the source
4+
*coordinate*. Joining on the floating-point coordinate value is fragile — any
5+
sub-ULP drift (e.g. a reproject/interp computed in float32) makes the equality
6+
join silently drop rows. The opt-in ``<dim>_idx`` columns give exact integer
7+
grid keys instead. These tests pin that the indices are global (partition
8+
independent) and that an index-keyed regrid stays exact where a float join does
9+
not.
10+
"""
11+
12+
import numpy as np
13+
import pyarrow as pa
14+
import xarray as xr
15+
16+
from xarray_sql import XarrayContext
17+
from xarray_sql.df import _ensure_default_indexes, _parse_schema
18+
19+
20+
# Deliberately not float32-exact, so a float32 round-trip actually perturbs the
21+
# bits (a float32-exact axis like 10.0/20.0 would round-trip unchanged).
22+
_X = np.array([10.1, 20.2, 30.3])
23+
_Y = np.array([1.1, 2.2, 3.3, 4.4])
24+
25+
26+
def _src() -> xr.Dataset:
27+
return xr.Dataset(
28+
{"v": (("x", "y"), np.arange(12.0).reshape(3, 4))},
29+
coords={"x": _X, "y": _Y},
30+
)
31+
32+
33+
def test_index_columns_are_int32_in_schema():
34+
schema = _parse_schema(_ensure_default_indexes(_src()), index_columns=True)
35+
assert schema.field("x_idx").type == pa.int32()
36+
assert schema.field("y_idx").type == pa.int32()
37+
# Not present unless requested.
38+
plain = _parse_schema(_ensure_default_indexes(_src()))
39+
assert "x_idx" not in plain.names
40+
41+
42+
def test_index_columns_are_global_across_chunks():
43+
"""Indices are absolute axis positions, not per-partition local ones."""
44+
ctx = XarrayContext()
45+
# chunk x into 3 single-row partitions: a local index would restart at 0
46+
# in every partition; the global index must run 0,1,2.
47+
ctx.from_dataset("g", _src(), chunks={"x": 1}, index_columns=True)
48+
df = ctx.sql("SELECT x, y, x_idx, y_idx FROM g").to_pandas()
49+
50+
assert df["x_idx"].dtype == np.int32
51+
xpos = {v: i for i, v in enumerate(_X)}
52+
ypos = {v: i for i, v in enumerate(_Y)}
53+
assert (df["x_idx"] == df["x"].map(xpos)).all()
54+
assert (df["y_idx"] == df["y"].map(ypos)).all()
55+
56+
57+
def _weights(perturb_f32: bool = False) -> xr.Dataset:
58+
"""A tiny gather 'regrid': dst cell k reads one src cell, weight 1.0."""
59+
# dst 0..4 map to source cells (x_idx, y_idx):
60+
sx = np.array([0, 2, 1, 0, 2], dtype=np.int32)
61+
sy = np.array([0, 3, 1, 3, 0], dtype=np.int32)
62+
src_x = _X[sx]
63+
src_y = _Y[sy]
64+
if perturb_f32: # coords computed in single precision, as a reproject UDF
65+
src_x = src_x.astype(np.float32).astype(np.float64)
66+
src_y = src_y.astype(np.float32).astype(np.float64)
67+
return xr.Dataset(
68+
{
69+
"dst_id": (("pair",), np.arange(5, dtype=np.int32)),
70+
"src_x_idx": (("pair",), sx),
71+
"src_y_idx": (("pair",), sy),
72+
"src_x": (("pair",), src_x),
73+
"src_y": (("pair",), src_y),
74+
"weight": (("pair",), np.ones(5)),
75+
}
76+
)
77+
78+
79+
INDEX_JOIN = """
80+
SELECT w.dst_id, SUM(s.v * w.weight) AS out
81+
FROM weights w JOIN src s
82+
ON s.x_idx = w.src_x_idx AND s.y_idx = w.src_y_idx
83+
GROUP BY w.dst_id ORDER BY w.dst_id
84+
"""
85+
FLOAT_JOIN = """
86+
SELECT w.dst_id, SUM(s.v * w.weight) AS out
87+
FROM weights w JOIN src s
88+
ON s.x = w.src_x AND s.y = w.src_y
89+
GROUP BY w.dst_id ORDER BY w.dst_id
90+
"""
91+
92+
93+
def _ctx(weights: xr.Dataset) -> XarrayContext:
94+
ctx = XarrayContext()
95+
ctx.from_dataset("src", _src(), chunks={"x": 1}, index_columns=True)
96+
ctx.from_dataset("weights", weights, chunks={"pair": 5})
97+
return ctx
98+
99+
100+
def test_index_join_regrids_exactly():
101+
"""Index-keyed regrid matches a direct numpy gather, across chunks."""
102+
ctx = _ctx(_weights())
103+
got = ctx.sql(INDEX_JOIN).to_pandas().sort_values("dst_id")
104+
105+
v = np.arange(12.0).reshape(3, 4)
106+
sx = np.array([0, 2, 1, 0, 2])
107+
sy = np.array([0, 3, 1, 3, 0])
108+
expected = v[sx, sy]
109+
np.testing.assert_allclose(got["out"].to_numpy(), expected)
110+
111+
112+
def test_index_join_survives_float32_drift_where_float_join_does_not():
113+
ctx = _ctx(_weights(perturb_f32=True))
114+
idx = ctx.sql(INDEX_JOIN).to_pandas()
115+
flt = ctx.sql(FLOAT_JOIN).to_pandas()
116+
# Index join keeps every destination cell; the float-equality join drops the
117+
# cells whose float32-roundtripped coord no longer bit-matches the source.
118+
assert len(idx) == 5
119+
assert len(flt) < 5

xarray_sql/df.py

Lines changed: 65 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -231,7 +231,10 @@ def pivot(ds: xr.Dataset) -> pd.DataFrame:
231231

232232

233233
def dataset_to_record_batch(
234-
ds: xr.Dataset, schema: pa.Schema
234+
ds: xr.Dataset,
235+
schema: pa.Schema,
236+
*,
237+
index_offsets: Mapping[str, int] | None = None,
235238
) -> pa.RecordBatch:
236239
"""Convert an xarray Dataset partition to an Arrow RecordBatch.
237240
@@ -267,10 +270,24 @@ def dataset_to_record_batch(
267270
dim_names = list(ds.sizes.keys())
268271
shape = tuple(ds.sizes[d] for d in dim_names)
269272

273+
offsets = index_offsets or {}
274+
index_fields = {
275+
index_column_name(d): (k, int(offsets.get(d, 0)))
276+
for k, d in enumerate(dim_names)
277+
}
278+
270279
arrays = []
271280
for field in schema:
272281
name = field.name
273-
if name in ds.coords and name in ds.dims:
282+
if name in index_fields:
283+
# Absolute integer grid position for this dim, broadcast+ravelled.
284+
axis, offset = index_fields[name]
285+
idx = offset + np.arange(shape[axis], dtype=np.int64)
286+
reshape = [1] * len(shape)
287+
reshape[axis] = shape[axis]
288+
arr = np.broadcast_to(idx.reshape(reshape), shape).ravel()
289+
arrays.append(pa.array(arr, type=field.type))
290+
elif name in ds.coords and name in ds.dims:
274291
# Broadcast 1-D coordinate to the full N-D partition shape, then ravel.
275292
axis = dim_names.index(name)
276293
coord = ds.coords[name].values
@@ -302,6 +319,8 @@ def iter_record_batches(
302319
ds: xr.Dataset,
303320
schema: pa.Schema,
304321
batch_size: int = DEFAULT_BATCH_SIZE,
322+
*,
323+
index_offsets: Mapping[str, int] | None = None,
305324
) -> Iterator[pa.RecordBatch]:
306325
"""Yield RecordBatches of at most *batch_size* rows from a partition Dataset.
307326
@@ -350,11 +369,21 @@ def iter_record_batches(
350369
# Flat row index i → coordinate index for dim k: (i // stride[k]) % shape[k].
351370
strides = [int(np.prod(shape[k + 1 :])) for k in range(len(shape))]
352371

372+
# Integer grid-position (`<dim>_idx`) columns, if the schema requests them:
373+
# map the column name to its dim position and absolute block offset so the
374+
# emitted index is global (partition-independent), which is what a join key
375+
# across partitions requires.
376+
offsets = index_offsets or {}
377+
index_fields = {
378+
index_column_name(d): (k, int(offsets.get(d, 0)))
379+
for k, d in enumerate(dim_names)
380+
}
381+
353382
# Load data-variable arrays fully (triggers Dask/Zarr compute once).
354383
# ravel() is a zero-copy view for C-contiguous arrays.
355384
data_arrays = {}
356385
for field in schema:
357-
if field.name not in ds.dims:
386+
if field.name not in ds.dims and field.name not in index_fields:
358387
raw = ds[field.name].values
359388
if cft.is_cftime(raw):
360389
data_arrays[field.name] = cft.convert_for_field(raw, field)
@@ -368,7 +397,11 @@ def iter_record_batches(
368397
arrays = []
369398
for field in schema:
370399
name = field.name
371-
if name in ds.coords and name in ds.dims:
400+
if name in index_fields:
401+
k, offset = index_fields[name]
402+
idx = offset + (row_idx // strides[k]) % shape[k]
403+
arrays.append(pa.array(idx, type=field.type))
404+
elif name in ds.coords and name in ds.dims:
372405
k = dim_names.index(name)
373406
coord_idx = (row_idx // strides[k]) % shape[k]
374407
arrays.append(
@@ -386,14 +419,30 @@ def iter_record_batches(
386419
yield pa.RecordBatch.from_arrays(arrays, schema=schema)
387420

388421

389-
def _parse_schema(ds: xr.Dataset) -> pa.Schema:
422+
#: Suffix for the integer grid-position column of a dimension.
423+
INDEX_COLUMN_SUFFIX = "_idx"
424+
425+
426+
def index_column_name(dim: str) -> str:
427+
"""Name of the integer grid-position column for dimension ``dim``."""
428+
return f"{dim}{INDEX_COLUMN_SUFFIX}"
429+
430+
431+
def _parse_schema(ds: xr.Dataset, *, index_columns: bool = False) -> pa.Schema:
390432
"""Extracts a `pa.Schema` from the Dataset, treating dims and data_vars as columns.
391433
392434
Only *dimension coordinates* become dimension columns, so a dimension
393435
without a coordinate would be dropped. Callers must run the Dataset through
394436
:func:`_ensure_default_indexes` first (the readers do) so every dimension
395437
has a coordinate and appears as a column.
396438
439+
When ``index_columns`` is set, an ``int32`` ``<dim>_idx`` column is appended
440+
for every dimension, carrying each row's absolute integer position along that
441+
axis. These are exact integer keys for grid joins (regridding weight tables,
442+
forecast alignment) — faster than, and free of the float-equality fragility
443+
of, joining on the floating-point coordinate values, while the coordinate
444+
columns remain available for value predicates and display.
445+
397446
Uses the xarray index type to detect cftime coordinates without
398447
materializing their data — important for Dask/Zarr-backed datasets
399448
where .values would trigger eager computation.
@@ -431,6 +480,17 @@ def _parse_schema(ds: xr.Dataset) -> pa.Schema:
431480
pa_type = pa.from_numpy_dtype(var.dtype)
432481
columns.append(pa.field(var_name, pa_type))
433482

483+
if index_columns:
484+
existing = {f.name for f in columns}
485+
for dim in ds.dims:
486+
name = index_column_name(str(dim))
487+
if name in existing:
488+
raise ValueError(
489+
f"cannot add index column {name!r}: a column with that "
490+
f"name already exists (dimension {dim!r})"
491+
)
492+
columns.append(pa.field(name, pa.int32()))
493+
434494
return pa.schema(columns)
435495

436496

xarray_sql/reader.py

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,7 @@ def read_xarray_table(
196196
chunks: Chunks = None,
197197
*,
198198
batch_size: int = DEFAULT_BATCH_SIZE,
199+
index_columns: bool = False,
199200
coord_arrays: dict[str, np.ndarray] | None = None,
200201
_iteration_callback: (
201202
Callable[[Block, list[str] | None], None] | None
@@ -257,7 +258,7 @@ def read_xarray_table(
257258
from ._native import LazyArrowStreamTable
258259

259260
ds = _ensure_default_indexes(ds)
260-
schema = _parse_schema(ds)
261+
schema = _parse_schema(ds, index_columns=index_columns)
261262

262263
# Hoist coordinate reads once; avoids N_partitions remote I/O calls for
263264
# Zarr-backed datasets (e.g. ARCO-ERA5 on GCS). When the caller supplies
@@ -281,15 +282,16 @@ def make_stream(
281282

282283
if projection_names is not None:
283284
# Restrict to the data variables mentioned in the projection.
284-
# Dimension coordinates come along automatically via coords.
285+
# Dimension coordinates come along automatically via coords;
286+
# index columns are computed from position, not loaded.
285287
data_vars_needed = [
286288
c for c in projection_names if c in data_var_names
287289
]
288290
if data_vars_needed:
289291
ds_block = ds[data_vars_needed].isel(block)
290292
else:
291-
# Only dimension coords requested — drop all data vars to avoid
292-
# loading them unnecessarily (e.g. for queries like SELECT lat, lon).
293+
# Only dimension coords / index columns requested — drop all
294+
# data vars to avoid loading them (e.g. SELECT lat, lon_idx).
293295
ds_block = ds.drop_vars(list(ds.data_vars)).isel(block)
294296
batch_schema = pa.schema(
295297
[schema.field(name) for name in projection_names]
@@ -298,9 +300,17 @@ def make_stream(
298300
ds_block = ds.isel(block)
299301
batch_schema = schema
300302

303+
# Absolute start of this block on each axis, so `<dim>_idx` columns
304+
# carry global positions that line up across partitions.
305+
index_offsets = {str(dim): (block[dim].start or 0) for dim in block}
301306
return pa.RecordBatchReader.from_batches(
302307
batch_schema,
303-
iter_record_batches(ds_block, batch_schema, batch_size),
308+
iter_record_batches(
309+
ds_block,
310+
batch_schema,
311+
batch_size,
312+
index_offsets=index_offsets,
313+
),
304314
)
305315

306316
return make_stream

xarray_sql/sql.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ def from_dataset(
2929
*,
3030
table_names: dict[tuple[str, ...], str] | None = None,
3131
chunks: Chunks = None,
32+
index_columns: bool = False,
3233
):
3334
"""Register an xarray Dataset as one or more queryable SQL tables.
3435
@@ -83,6 +84,13 @@ def from_dataset(
8384
variables with differing dimensions.
8485
chunks: Xarray-like chunks specification. If not provided, uses
8586
the Dataset's existing chunks.
87+
index_columns: When True, add an ``int32`` ``<dim>_idx`` column for
88+
every dimension, carrying each row's absolute integer position
89+
on that axis. These are exact, overflow-free integer keys for
90+
grid joins (regridding weight tables, forecast alignment) — join
91+
on them instead of the floating-point coordinates to avoid
92+
float-equality mismatches, while the coordinate columns stay
93+
available for value predicates and display.
8694
8795
Returns:
8896
self, to allow chaining.
@@ -99,7 +107,11 @@ def from_dataset(
99107
if len(groups) <= 1:
100108
self._registered_datasets[name] = input_table
101109
return self._from_dataset(
102-
name, input_table, chunks, coord_arrays=coord_arrays
110+
name,
111+
input_table,
112+
chunks,
113+
coord_arrays=coord_arrays,
114+
index_columns=index_columns,
103115
)
104116

105117
table_names = table_names or {}
@@ -117,6 +129,7 @@ def from_dataset(
117129
chunks,
118130
schema=schema,
119131
coord_arrays=coord_arrays,
132+
index_columns=index_columns,
120133
)
121134
# Track the fully-qualified name so XarrayDataFrame metadata
122135
# recovery can find this Dataset on round-trip.
@@ -131,6 +144,7 @@ def _from_dataset(
131144
chunks: Chunks = None,
132145
schema: Schema | None = None,
133146
coord_arrays: dict | None = None,
147+
index_columns: bool = False,
134148
):
135149
"""Register a Dataset as a single SQL table.
136150
@@ -142,7 +156,12 @@ def _from_dataset(
142156
)
143157
register(
144158
table_name,
145-
read_xarray_table(input_table, chunks, coord_arrays=coord_arrays),
159+
read_xarray_table(
160+
input_table,
161+
chunks,
162+
index_columns=index_columns,
163+
coord_arrays=coord_arrays,
164+
),
146165
)
147166
self._maybe_register_cftime_udf(input_table)
148167
return self

0 commit comments

Comments
 (0)