xarray_sql 0.3.0__cp310-abi3-win_amd64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- xarray_sql/__init__.py +12 -0
- xarray_sql/_native.pyd +0 -0
- xarray_sql/cftime.py +248 -0
- xarray_sql/core.py +49 -0
- xarray_sql/df.py +508 -0
- xarray_sql/ds.py +926 -0
- xarray_sql/reader.py +332 -0
- xarray_sql/sql.py +191 -0
- xarray_sql-0.3.0.dist-info/METADATA +354 -0
- xarray_sql-0.3.0.dist-info/RECORD +12 -0
- xarray_sql-0.3.0.dist-info/WHEEL +4 -0
- xarray_sql-0.3.0.dist-info/licenses/LICENSE +202 -0
xarray_sql/reader.py
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
"""Lazy Arrow stream reader for xarray Datasets.
|
|
2
|
+
|
|
3
|
+
This module provides XarrayRecordBatchReader, which implements the Arrow
|
|
4
|
+
PyCapsule Interface (__arrow_c_stream__) to enable zero-copy, lazy streaming
|
|
5
|
+
of xarray data to DataFusion and other Arrow consumers.
|
|
6
|
+
|
|
7
|
+
The implementation delegates to PyArrow's RecordBatchReader for the
|
|
8
|
+
actual stream implementation, wrapping xarray block iteration in a generator.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from collections.abc import Callable, Iterator
|
|
14
|
+
from typing import TYPE_CHECKING
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
import pyarrow as pa
|
|
18
|
+
import xarray as xr
|
|
19
|
+
|
|
20
|
+
from .df import (
|
|
21
|
+
Block,
|
|
22
|
+
Chunks,
|
|
23
|
+
DEFAULT_BATCH_SIZE,
|
|
24
|
+
_block_metadata,
|
|
25
|
+
_block_slices_from_resolved,
|
|
26
|
+
_parse_schema,
|
|
27
|
+
block_slices,
|
|
28
|
+
iter_record_batches,
|
|
29
|
+
resolve_chunks,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
if TYPE_CHECKING:
|
|
33
|
+
from ._native import LazyArrowStreamTable
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class XarrayRecordBatchReader:
|
|
37
|
+
"""A lazy Arrow stream reader for xarray Datasets.
|
|
38
|
+
|
|
39
|
+
Implements the Arrow PyCapsule Interface (__arrow_c_stream__) to enable
|
|
40
|
+
zero-copy, lazy streaming of xarray data to DataFusion and other Arrow
|
|
41
|
+
consumers.
|
|
42
|
+
|
|
43
|
+
The key property is that xarray blocks are only converted to Arrow
|
|
44
|
+
RecordBatches when the consumer calls get_next (e.g., during DataFusion's
|
|
45
|
+
collect()), NOT when the reader is created or registered.
|
|
46
|
+
|
|
47
|
+
Attributes:
|
|
48
|
+
schema: The Arrow schema for the stream.
|
|
49
|
+
|
|
50
|
+
Example:
|
|
51
|
+
>>> import xarray as xr
|
|
52
|
+
>>> from xarray_sql import XarrayRecordBatchReader
|
|
53
|
+
>>> ds = xr.tutorial.open_dataset('air_temperature')
|
|
54
|
+
>>> reader = XarrayRecordBatchReader(ds, chunks={'time': 240})
|
|
55
|
+
>>> # At this point, NO data has been read from xarray
|
|
56
|
+
>>> # Data is only read when consumed:
|
|
57
|
+
>>> import pyarrow as pa
|
|
58
|
+
>>> pa_reader = pa.RecordBatchReader.from_stream(reader)
|
|
59
|
+
>>> for batch in pa_reader:
|
|
60
|
+
... print(batch.num_rows) # Data read here
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def __init__(
|
|
64
|
+
self,
|
|
65
|
+
ds: xr.Dataset,
|
|
66
|
+
chunks: Chunks = None,
|
|
67
|
+
*,
|
|
68
|
+
batch_size: int = DEFAULT_BATCH_SIZE,
|
|
69
|
+
_iteration_callback: (
|
|
70
|
+
Callable[[Block, list[str] | None], None] | None
|
|
71
|
+
) = None,
|
|
72
|
+
):
|
|
73
|
+
"""Initialize the lazy reader.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
ds: An xarray Dataset. All data_vars must share the same dimensions.
|
|
77
|
+
chunks: Xarray-like chunks specification. If not provided, uses
|
|
78
|
+
the Dataset's existing chunks.
|
|
79
|
+
batch_size: Maximum rows per emitted Arrow RecordBatch. Smaller
|
|
80
|
+
values let DataFusion start processing earlier at the cost of
|
|
81
|
+
more Python→Arrow conversion calls.
|
|
82
|
+
_iteration_callback: Internal callback for testing. Called with
|
|
83
|
+
each block dict just before it's converted to Arrow. This
|
|
84
|
+
allows tests to track when iteration actually occurs.
|
|
85
|
+
"""
|
|
86
|
+
self._ds = ds
|
|
87
|
+
self._chunks = chunks
|
|
88
|
+
self._batch_size = batch_size
|
|
89
|
+
self._schema = _parse_schema(ds)
|
|
90
|
+
self._iteration_callback = _iteration_callback
|
|
91
|
+
self._consumed = False
|
|
92
|
+
|
|
93
|
+
# Validate dimensions
|
|
94
|
+
fst = next(iter(ds.values())).dims
|
|
95
|
+
if not all(da.dims == fst for da in ds.values()):
|
|
96
|
+
raise ValueError(
|
|
97
|
+
"All dimensions must be equal. Please filter data_vars in the Dataset."
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def schema(self) -> pa.Schema:
|
|
102
|
+
"""The Arrow schema for this stream."""
|
|
103
|
+
return self._schema
|
|
104
|
+
|
|
105
|
+
def _generate_batches(self) -> Iterator[pa.RecordBatch]:
|
|
106
|
+
"""Generate RecordBatches lazily from xarray blocks.
|
|
107
|
+
|
|
108
|
+
This generator is only consumed when the Arrow stream's get_next
|
|
109
|
+
is called, ensuring true lazy evaluation. Each xarray block is
|
|
110
|
+
emitted as one or more RecordBatches of at most self._batch_size rows.
|
|
111
|
+
"""
|
|
112
|
+
for block in block_slices(self._ds, self._chunks):
|
|
113
|
+
# Call the iteration callback if provided (for testing).
|
|
114
|
+
# XarrayRecordBatchReader has no projection concept, so always passes None.
|
|
115
|
+
if self._iteration_callback is not None:
|
|
116
|
+
self._iteration_callback(block, None)
|
|
117
|
+
|
|
118
|
+
yield from iter_record_batches(
|
|
119
|
+
self._ds.isel(block), self._schema, self._batch_size
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
def __arrow_c_stream__(
|
|
123
|
+
self, requested_schema: object | None = None
|
|
124
|
+
) -> object:
|
|
125
|
+
"""Export as Arrow C Stream via PyCapsule.
|
|
126
|
+
|
|
127
|
+
This method is called by Arrow consumers (like DataFusion) to get
|
|
128
|
+
a C-level stream interface. The actual data iteration only begins
|
|
129
|
+
when the consumer calls get_next on the stream.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
requested_schema: Optional schema for type casting. Currently
|
|
133
|
+
passed through to PyArrow's implementation.
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
PyCapsule containing ArrowArrayStream pointer with name
|
|
137
|
+
"arrow_array_stream".
|
|
138
|
+
|
|
139
|
+
Raises:
|
|
140
|
+
RuntimeError: If the stream has already been consumed.
|
|
141
|
+
"""
|
|
142
|
+
if self._consumed:
|
|
143
|
+
raise RuntimeError(
|
|
144
|
+
"Stream already consumed. XarrayRecordBatchReader can only "
|
|
145
|
+
"be iterated once. Create a new reader for additional iterations."
|
|
146
|
+
)
|
|
147
|
+
self._consumed = True
|
|
148
|
+
|
|
149
|
+
# Create a PyArrow RecordBatchReader from our generator
|
|
150
|
+
# The generator is NOT consumed here - only when get_next is called
|
|
151
|
+
reader = pa.RecordBatchReader.from_batches(
|
|
152
|
+
self._schema, self._generate_batches()
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
# Delegate to PyArrow's __arrow_c_stream__ implementation
|
|
156
|
+
return reader.__arrow_c_stream__(requested_schema)
|
|
157
|
+
|
|
158
|
+
def __arrow_c_schema__(
|
|
159
|
+
self, requested_schema: object | None = None
|
|
160
|
+
) -> object:
|
|
161
|
+
"""Export the schema as Arrow C Schema via PyCapsule.
|
|
162
|
+
|
|
163
|
+
This allows consumers to inspect the schema without consuming the stream.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
requested_schema: Optional schema for negotiation (unused).
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
PyCapsule containing ArrowSchema pointer.
|
|
170
|
+
"""
|
|
171
|
+
return self._schema.__arrow_c_schema__()
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def read_xarray(ds: xr.Dataset, chunks: Chunks = None) -> pa.RecordBatchReader:
|
|
175
|
+
"""Pivots an Xarray Dataset into a PyArrow Table, partitioned by chunks.
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
ds: An Xarray Dataset. All `data_vars` must share the same dimensions.
|
|
179
|
+
chunks: Xarray-like chunks. If not provided, will default to the
|
|
180
|
+
Dataset's chunks. The product of the chunk sizes becomes the
|
|
181
|
+
standard length of each dataframe partition.
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
A PyArrow RecordBatchReader, which is a table representation of the input
|
|
185
|
+
Dataset.
|
|
186
|
+
"""
|
|
187
|
+
reader = XarrayRecordBatchReader(ds, chunks=chunks)
|
|
188
|
+
return pa.RecordBatchReader.from_stream(reader)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def read_xarray_table(
|
|
192
|
+
ds: xr.Dataset,
|
|
193
|
+
chunks: Chunks = None,
|
|
194
|
+
*,
|
|
195
|
+
batch_size: int = DEFAULT_BATCH_SIZE,
|
|
196
|
+
coord_arrays: dict[str, np.ndarray] | None = None,
|
|
197
|
+
_iteration_callback: (
|
|
198
|
+
Callable[[Block, list[str] | None], None] | None
|
|
199
|
+
) = None,
|
|
200
|
+
) -> "LazyArrowStreamTable":
|
|
201
|
+
"""Create a lazy DataFusion table from an xarray Dataset.
|
|
202
|
+
|
|
203
|
+
This is the simplest way to register xarray data with DataFusion.
|
|
204
|
+
Data is only read when queries are executed, not during registration.
|
|
205
|
+
The table can be queried multiple times.
|
|
206
|
+
|
|
207
|
+
Each chunk becomes a separate partition, enabling DataFusion's parallel
|
|
208
|
+
execution across multiple cores.
|
|
209
|
+
|
|
210
|
+
Note:
|
|
211
|
+
SQL queries with WHERE clauses on dimension columns (time, lat, lon, etc.)
|
|
212
|
+
automatically prune partitions that can't contain matching rows — this is
|
|
213
|
+
called *filter pushdown*. For example:
|
|
214
|
+
|
|
215
|
+
# This query will skip loading partitions with time < '2020-02-01'
|
|
216
|
+
result = ctx.sql('SELECT * FROM air WHERE time > "2020-02-01"').collect()
|
|
217
|
+
|
|
218
|
+
Supported operators: `=`, `<`, `>`, `<=`, `>=`, `BETWEEN`, `IN`, `AND`, `OR`.
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
ds: An xarray Dataset. All data_vars must share the same dimensions.
|
|
222
|
+
chunks: Xarray-like chunks specification. If not provided, uses
|
|
223
|
+
the Dataset's existing chunks.
|
|
224
|
+
batch_size: Maximum rows per Arrow RecordBatch emitted per partition.
|
|
225
|
+
Smaller values let DataFusion start processing earlier; the default
|
|
226
|
+
(65 536) works well for most datasets.
|
|
227
|
+
coord_arrays: Pre-materialised coordinate arrays keyed by dim-name
|
|
228
|
+
string. Hand in to share a single read across multiple tables
|
|
229
|
+
built from the same parent Dataset (e.g. surface + atmosphere
|
|
230
|
+
from ARCO-ERA5); the dim coords are otherwise read once per
|
|
231
|
+
``read_xarray_table`` call, which is a network round-trip for
|
|
232
|
+
Zarr-backed datasets.
|
|
233
|
+
_iteration_callback: Internal callback for testing. Called with
|
|
234
|
+
each block dict just before it's converted to Arrow.
|
|
235
|
+
|
|
236
|
+
Returns:
|
|
237
|
+
A LazyArrowStreamTable ready for registration with DataFusion.
|
|
238
|
+
|
|
239
|
+
Example:
|
|
240
|
+
>>> from datafusion import SessionContext
|
|
241
|
+
>>> import xarray as xr
|
|
242
|
+
>>> from xarray_sql import read_xarray_table
|
|
243
|
+
>>>
|
|
244
|
+
>>> ds = xr.tutorial.open_dataset('air_temperature')
|
|
245
|
+
>>> table = read_xarray_table(ds, chunks={'time': 240})
|
|
246
|
+
>>>
|
|
247
|
+
>>> ctx = SessionContext()
|
|
248
|
+
>>> ctx.register_table('air', table)
|
|
249
|
+
>>>
|
|
250
|
+
>>> # Data is only read here, during query execution
|
|
251
|
+
>>> # Filters on 'time' will prune partitions automatically!
|
|
252
|
+
>>> result = ctx.sql('SELECT AVG(air) FROM air').collect()
|
|
253
|
+
"""
|
|
254
|
+
from ._native import LazyArrowStreamTable
|
|
255
|
+
|
|
256
|
+
schema = _parse_schema(ds)
|
|
257
|
+
|
|
258
|
+
# Hoist coordinate reads once; avoids N_partitions remote I/O calls for
|
|
259
|
+
# Zarr-backed datasets (e.g. ARCO-ERA5 on GCS). When the caller supplies
|
|
260
|
+
# pre-materialised arrays (e.g. shared across surface + atmosphere
|
|
261
|
+
# tables), reuse them and skip the extra read.
|
|
262
|
+
if coord_arrays is None:
|
|
263
|
+
coord_arrays = {str(dim): ds.coords[dim].values for dim in ds.dims}
|
|
264
|
+
|
|
265
|
+
# Determine which column names are data variables (not dimension coordinates).
|
|
266
|
+
# Used by the factory to skip loading unrequested variables.
|
|
267
|
+
data_var_names = set(ds.data_vars.keys())
|
|
268
|
+
|
|
269
|
+
def make_partition_factory(
|
|
270
|
+
block: Block,
|
|
271
|
+
) -> Callable[[list[str] | None], pa.RecordBatchReader]:
|
|
272
|
+
def make_stream(
|
|
273
|
+
projection_names: list[str] | None,
|
|
274
|
+
) -> pa.RecordBatchReader:
|
|
275
|
+
if _iteration_callback is not None:
|
|
276
|
+
_iteration_callback(block, projection_names)
|
|
277
|
+
|
|
278
|
+
if projection_names is not None:
|
|
279
|
+
# Restrict to the data variables mentioned in the projection.
|
|
280
|
+
# Dimension coordinates come along automatically via coords.
|
|
281
|
+
data_vars_needed = [
|
|
282
|
+
c for c in projection_names if c in data_var_names
|
|
283
|
+
]
|
|
284
|
+
if data_vars_needed:
|
|
285
|
+
ds_block = ds[data_vars_needed].isel(block)
|
|
286
|
+
else:
|
|
287
|
+
# Only dimension coords requested — drop all data vars to avoid
|
|
288
|
+
# loading them unnecessarily (e.g. for queries like SELECT lat, lon).
|
|
289
|
+
ds_block = ds.drop_vars(list(ds.data_vars)).isel(block)
|
|
290
|
+
batch_schema = pa.schema(
|
|
291
|
+
[schema.field(name) for name in projection_names]
|
|
292
|
+
)
|
|
293
|
+
else:
|
|
294
|
+
ds_block = ds.isel(block)
|
|
295
|
+
batch_schema = schema
|
|
296
|
+
|
|
297
|
+
return pa.RecordBatchReader.from_batches(
|
|
298
|
+
batch_schema,
|
|
299
|
+
iter_record_batches(ds_block, batch_schema, batch_size),
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
return make_stream
|
|
303
|
+
|
|
304
|
+
# Separate dims whose chunk bounds vary across partitions from those
|
|
305
|
+
# whose bounds are constant (one chunk spanning the whole axis). For the
|
|
306
|
+
# latter we compute min/max once instead of re-scanning the full coord
|
|
307
|
+
# array on every partition — dominant cost when registering hundreds of
|
|
308
|
+
# thousands of single-time-step partitions on a 4-D dataset like ERA5.
|
|
309
|
+
resolved = resolve_chunks(ds, chunks)
|
|
310
|
+
varying_dims = [d for d, tup in resolved.items() if len(tup) > 1]
|
|
311
|
+
static_dims = [d for d in ds.dims if d not in varying_dims]
|
|
312
|
+
static_block: Block = {d: slice(None) for d in static_dims}
|
|
313
|
+
static_ranges = _block_metadata(
|
|
314
|
+
coord_arrays, static_block, dims=static_dims
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
def partition_pairs():
|
|
318
|
+
"""Lazily yield (factory, metadata) for each partition.
|
|
319
|
+
|
|
320
|
+
Consuming this generator one item at a time means Python never holds
|
|
321
|
+
all N block dicts, metadata dicts, and factory closures simultaneously.
|
|
322
|
+
Peak Python memory during registration is O(1) per partition instead
|
|
323
|
+
of O(N_partitions).
|
|
324
|
+
"""
|
|
325
|
+
for block in _block_slices_from_resolved(ds, resolved):
|
|
326
|
+
dynamic = _block_metadata(coord_arrays, block, dims=varying_dims)
|
|
327
|
+
yield (
|
|
328
|
+
make_partition_factory(block),
|
|
329
|
+
{**static_ranges, **dynamic},
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
return LazyArrowStreamTable(partition_pairs(), schema)
|
xarray_sql/sql.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import xarray as xr
|
|
2
|
+
from datafusion import SessionContext
|
|
3
|
+
from datafusion.catalog import Schema
|
|
4
|
+
from collections import defaultdict
|
|
5
|
+
|
|
6
|
+
from . import cftime as cft
|
|
7
|
+
from .df import Chunks
|
|
8
|
+
from .ds import XarrayDataFrame
|
|
9
|
+
from .reader import read_xarray_table
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class XarrayContext(SessionContext):
|
|
13
|
+
"""A datafusion `SessionContext` that also supports `xarray.Dataset`s."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, *args, **kwargs):
|
|
16
|
+
super().__init__(*args, **kwargs)
|
|
17
|
+
# Track registered xarray Datasets so XarrayDataFrame can recover
|
|
18
|
+
# defaults (dimension_columns) and metadata (var/dataset attrs,
|
|
19
|
+
# non-dim coords, dim-coord dtype) that the forward pivot drops.
|
|
20
|
+
# Keys are the fully-qualified table names users will reference
|
|
21
|
+
# in SQL (e.g. ``"air"`` for a uniform-dim Dataset, or
|
|
22
|
+
# ``"era5.surface"`` for one entry from a multi-dim-group split).
|
|
23
|
+
self._registered_datasets: dict[str, xr.Dataset] = {}
|
|
24
|
+
|
|
25
|
+
def from_dataset(
|
|
26
|
+
self,
|
|
27
|
+
name: str,
|
|
28
|
+
input_table: xr.Dataset,
|
|
29
|
+
*,
|
|
30
|
+
table_names: dict[tuple[str, ...], str] | None = None,
|
|
31
|
+
chunks: Chunks = None,
|
|
32
|
+
):
|
|
33
|
+
"""Register an xarray Dataset as one or more queryable SQL tables.
|
|
34
|
+
|
|
35
|
+
When all data variables share the same dimensions, the dataset is
|
|
36
|
+
registered as a single table named ``name``. When variables have
|
|
37
|
+
differing dimensions (e.g. some on a 3D grid and others on a 4D
|
|
38
|
+
grid), the dataset is split into one table per dimension group.
|
|
39
|
+
The tables are registered under a SQL schema (namespace) named
|
|
40
|
+
``name`` and named ``<dim1>_<dim2>_...`` by default::
|
|
41
|
+
|
|
42
|
+
ctx.from_dataset('era5', ds, chunks={'time': 24})
|
|
43
|
+
# registers tables: 'era5.time_lat_lon' and
|
|
44
|
+
# 'era5.time_lat_lon_level'
|
|
45
|
+
ctx.sql('SELECT AVG(temperature_2m) FROM era5.time_lat_lon')
|
|
46
|
+
|
|
47
|
+
Use ``table_names`` to override the name for specific dimension
|
|
48
|
+
tuples::
|
|
49
|
+
|
|
50
|
+
ctx.from_dataset(
|
|
51
|
+
'era5', ds,
|
|
52
|
+
table_names={('time', 'lat', 'lon'): 'surface'},
|
|
53
|
+
)
|
|
54
|
+
ctx.sql('SELECT * FROM era5.surface')
|
|
55
|
+
|
|
56
|
+
For datasets with non-Gregorian cftime coordinates (e.g. 360_day,
|
|
57
|
+
julian), a ``cftime()`` scalar UDF is automatically registered so
|
|
58
|
+
you can write ergonomic SQL filters::
|
|
59
|
+
|
|
60
|
+
ctx.from_dataset("ds360", ds, chunks={"time": 6})
|
|
61
|
+
ctx.sql("SELECT * FROM ds360 WHERE time >= cftime('2000-07-01')")
|
|
62
|
+
|
|
63
|
+
.. note::
|
|
64
|
+
|
|
65
|
+
Only one ``cftime()`` UDF is registered per context, using the
|
|
66
|
+
units and calendar of the *first* non-Gregorian coordinate
|
|
67
|
+
encountered. If you register multiple datasets with *different*
|
|
68
|
+
non-Gregorian calendars (e.g. one 360_day and one julian), the
|
|
69
|
+
UDF from the first registration will be used for all subsequent
|
|
70
|
+
``cftime()`` calls and may produce incorrect offsets for the
|
|
71
|
+
other dataset. In that case, create a separate ``XarrayContext``
|
|
72
|
+
for each calendar.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
name: The SQL identifier under which the dataset is registered.
|
|
76
|
+
For datasets with uniform dimensions, this is the table
|
|
77
|
+
name. For datasets with mixed dimensions, this is the name
|
|
78
|
+
of a SQL schema (namespace) containing one table per
|
|
79
|
+
dimension group.
|
|
80
|
+
input_table: An xarray Dataset.
|
|
81
|
+
table_names: Optional mapping from dimension tuples to custom
|
|
82
|
+
table names within the schema, used when the dataset has
|
|
83
|
+
variables with differing dimensions.
|
|
84
|
+
chunks: Xarray-like chunks specification. If not provided, uses
|
|
85
|
+
the Dataset's existing chunks.
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
self, to allow chaining.
|
|
89
|
+
"""
|
|
90
|
+
groups = _group_vars_by_dims(input_table)
|
|
91
|
+
|
|
92
|
+
# Materialise dim coordinates once and share across every sub-table.
|
|
93
|
+
# For Zarr-backed parents (e.g. ARCO-ERA5 on GCS) this saves one
|
|
94
|
+
# network round-trip per dim per dim-group.
|
|
95
|
+
coord_arrays = {
|
|
96
|
+
str(dim): input_table.coords[dim].values for dim in input_table.dims
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if len(groups) <= 1:
|
|
100
|
+
self._registered_datasets[name] = input_table
|
|
101
|
+
return self._from_dataset(
|
|
102
|
+
name, input_table, chunks, coord_arrays=coord_arrays
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
table_names = table_names or {}
|
|
106
|
+
schema = Schema.memory_schema(self)
|
|
107
|
+
self.catalog().register_schema(name, schema)
|
|
108
|
+
|
|
109
|
+
for dims, var_names in groups.items():
|
|
110
|
+
# Scalar variables group under empty dims, where "_".join(()) is
|
|
111
|
+
# the empty string; fall back to a valid default table name.
|
|
112
|
+
sub_name = table_names.get(dims, "_".join(dims) or "scalar")
|
|
113
|
+
sub_ds = input_table[var_names]
|
|
114
|
+
self._from_dataset(
|
|
115
|
+
sub_name,
|
|
116
|
+
sub_ds,
|
|
117
|
+
chunks,
|
|
118
|
+
schema=schema,
|
|
119
|
+
coord_arrays=coord_arrays,
|
|
120
|
+
)
|
|
121
|
+
# Track the fully-qualified name so XarrayDataFrame metadata
|
|
122
|
+
# recovery can find this Dataset on round-trip.
|
|
123
|
+
self._registered_datasets[f"{name}.{sub_name}"] = sub_ds
|
|
124
|
+
|
|
125
|
+
return self
|
|
126
|
+
|
|
127
|
+
def _from_dataset(
|
|
128
|
+
self,
|
|
129
|
+
table_name: str,
|
|
130
|
+
input_table: xr.Dataset,
|
|
131
|
+
chunks: Chunks = None,
|
|
132
|
+
schema: Schema | None = None,
|
|
133
|
+
coord_arrays: dict | None = None,
|
|
134
|
+
):
|
|
135
|
+
"""Register a Dataset as a single SQL table.
|
|
136
|
+
|
|
137
|
+
Registers a top-level table by default, or a table inside ``schema``
|
|
138
|
+
(a SQL namespace) when one is given.
|
|
139
|
+
"""
|
|
140
|
+
register = (
|
|
141
|
+
self.register_table if schema is None else schema.register_table
|
|
142
|
+
)
|
|
143
|
+
register(
|
|
144
|
+
table_name,
|
|
145
|
+
read_xarray_table(input_table, chunks, coord_arrays=coord_arrays),
|
|
146
|
+
)
|
|
147
|
+
self._maybe_register_cftime_udf(input_table)
|
|
148
|
+
return self
|
|
149
|
+
|
|
150
|
+
def _maybe_register_cftime_udf(self, ds: xr.Dataset) -> None:
|
|
151
|
+
"""Auto-register a cftime() UDF for non-Gregorian cftime coordinates."""
|
|
152
|
+
for coord_name in ds.dims:
|
|
153
|
+
if cft.is_cftime_index(ds, coord_name):
|
|
154
|
+
units, cal = cft.encoding(ds, coord_name)
|
|
155
|
+
if not cft.is_gregorian_like(cal):
|
|
156
|
+
self.register_udf(cft.make_cftime_udf(units, cal))
|
|
157
|
+
break # One UDF per context is enough.
|
|
158
|
+
|
|
159
|
+
def sql(self, query: str, *args, **kwargs) -> XarrayDataFrame:
|
|
160
|
+
"""Run a SQL query, returning an :class:`XarrayDataFrame` wrapper.
|
|
161
|
+
|
|
162
|
+
Identical to ``datafusion.SessionContext.sql`` except the returned
|
|
163
|
+
object wraps the DataFusion DataFrame. The wrapper exposes
|
|
164
|
+
``.to_pandas()`` (unchanged), forwards every other DataFusion
|
|
165
|
+
method via ``__getattr__``, and adds
|
|
166
|
+
``.to_dataset(dimension_columns=[...])`` for round-tripping the
|
|
167
|
+
result back to an ``xr.Dataset``.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
query: A SQL query string.
|
|
171
|
+
*args: Forwarded to ``SessionContext.sql``.
|
|
172
|
+
**kwargs: Forwarded to ``SessionContext.sql``.
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
An :class:`XarrayDataFrame` wrapping the DataFusion DataFrame.
|
|
176
|
+
"""
|
|
177
|
+
inner = super().sql(query, *args, **kwargs)
|
|
178
|
+
return XarrayDataFrame(inner, templates=self._registered_datasets)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _group_vars_by_dims(ds: xr.Dataset) -> dict[tuple[str, ...], list[str]]:
|
|
182
|
+
"""Group variables in the dataset based on shared dims.
|
|
183
|
+
|
|
184
|
+
("time", "lat", "lon"): ["temperature_2m", "wind_speed"],
|
|
185
|
+
("time", "lat", "lon", "level"): ["pressure", "humidity"]
|
|
186
|
+
"""
|
|
187
|
+
groups = defaultdict(list)
|
|
188
|
+
for var_name, var in ds.data_vars.items():
|
|
189
|
+
dims = var.dims
|
|
190
|
+
groups[dims].append(var_name)
|
|
191
|
+
return groups
|