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/df.py
ADDED
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
import itertools
|
|
2
|
+
from collections.abc import Callable, Hashable, Iterable, Iterator, Mapping
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
import pandas as pd
|
|
7
|
+
import pyarrow as pa
|
|
8
|
+
import xarray as xr
|
|
9
|
+
|
|
10
|
+
from . import cftime as cft
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
Block = dict[Hashable, slice]
|
|
14
|
+
Chunks = dict[str, int] | None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# Borrowed from Xarray
|
|
18
|
+
def _get_chunk_slicer(
|
|
19
|
+
dim: Hashable, chunk_index: Mapping, chunk_bounds: Mapping
|
|
20
|
+
):
|
|
21
|
+
if dim in chunk_index:
|
|
22
|
+
which_chunk = chunk_index[dim]
|
|
23
|
+
return slice(
|
|
24
|
+
chunk_bounds[dim][which_chunk], chunk_bounds[dim][which_chunk + 1]
|
|
25
|
+
)
|
|
26
|
+
return slice(None)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def compute_chunks(
|
|
30
|
+
ds: xr.Dataset, chunks: dict[str, int]
|
|
31
|
+
) -> dict[Hashable, tuple[int, ...]]:
|
|
32
|
+
"""Per-dim chunk-size tuples matching ``ds.chunk(chunks).chunks``.
|
|
33
|
+
|
|
34
|
+
Pure arithmetic replacement for the dask rechunk round-trip; dask's
|
|
35
|
+
``.chunk()`` eagerly builds a task graph, which dominates
|
|
36
|
+
``block_slices()`` cost on large datasets.
|
|
37
|
+
"""
|
|
38
|
+
existing = dict(ds.chunks) if ds.chunks else {}
|
|
39
|
+
result: dict[Hashable, tuple[int, ...]] = {}
|
|
40
|
+
for dim in ds.dims:
|
|
41
|
+
size = ds.sizes[dim]
|
|
42
|
+
if dim in chunks:
|
|
43
|
+
cs = chunks[dim]
|
|
44
|
+
if cs <= 0 or cs >= size:
|
|
45
|
+
result[dim] = (size,)
|
|
46
|
+
else:
|
|
47
|
+
n_full, rem = divmod(size, cs)
|
|
48
|
+
result[dim] = (cs,) * n_full + ((rem,) if rem else ())
|
|
49
|
+
elif dim in existing:
|
|
50
|
+
result[dim] = tuple(existing[dim])
|
|
51
|
+
else:
|
|
52
|
+
result[dim] = (size,)
|
|
53
|
+
return result
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def resolve_chunks(
|
|
57
|
+
ds: xr.Dataset, chunks: Chunks
|
|
58
|
+
) -> Mapping[Hashable, tuple[int, ...]]:
|
|
59
|
+
"""Normalise the user's ``chunks`` argument to per-dim size tuples.
|
|
60
|
+
|
|
61
|
+
Filters out keys for dims this dataset doesn't have (sub-datasets in a
|
|
62
|
+
heterogeneous group need not contain every dimension named in the
|
|
63
|
+
spec), then either rechunks arithmetically via ``compute_chunks`` or
|
|
64
|
+
falls back to the dataset's existing dask chunks.
|
|
65
|
+
|
|
66
|
+
Returns an empty mapping for scalar datasets; callers should treat that
|
|
67
|
+
as "one block covering everything".
|
|
68
|
+
"""
|
|
69
|
+
if chunks is not None:
|
|
70
|
+
chunks = {dim: size for dim, size in chunks.items() if dim in ds.sizes}
|
|
71
|
+
if chunks:
|
|
72
|
+
return compute_chunks(ds, chunks)
|
|
73
|
+
return {d: tuple(c) for d, c in ds.chunks.items()}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _block_slices_from_resolved(
|
|
77
|
+
ds: xr.Dataset, resolved: Mapping[Hashable, tuple[int, ...]]
|
|
78
|
+
) -> Iterator[Block]:
|
|
79
|
+
"""Emit blocks given pre-resolved per-dim chunk tuples."""
|
|
80
|
+
if not resolved:
|
|
81
|
+
# No chunkable dimensions. A dimensionless dataset (e.g. scalar
|
|
82
|
+
# metadata variables) is a single block; a dataset that has
|
|
83
|
+
# dimensions but no chunking is a user error.
|
|
84
|
+
assert not ds.sizes, (
|
|
85
|
+
"Dataset `ds` must be chunked or `chunks` must be provided."
|
|
86
|
+
)
|
|
87
|
+
yield {}
|
|
88
|
+
return
|
|
89
|
+
|
|
90
|
+
chunk_bounds = {
|
|
91
|
+
dim: np.cumsum((0,) + tuple(c)) for dim, c in resolved.items()
|
|
92
|
+
}
|
|
93
|
+
ichunk = {dim: range(len(tuple(c))) for dim, c in resolved.items()}
|
|
94
|
+
ick, icv = zip(*ichunk.items()) # Makes same order of keys and val.
|
|
95
|
+
chunk_idxs = (dict(zip(ick, i)) for i in itertools.product(*icv))
|
|
96
|
+
yield from (
|
|
97
|
+
{
|
|
98
|
+
dim: _get_chunk_slicer(dim, chunk_index, chunk_bounds)
|
|
99
|
+
for dim in ds.dims
|
|
100
|
+
}
|
|
101
|
+
for chunk_index in chunk_idxs
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# Adapted from Xarray `map_blocks` implementation.
|
|
106
|
+
def block_slices(ds: xr.Dataset, chunks: Chunks = None) -> Iterator[Block]:
|
|
107
|
+
"""Compute block slices for a chunked Dataset."""
|
|
108
|
+
yield from _block_slices_from_resolved(ds, resolve_chunks(ds, chunks))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def explode(ds: xr.Dataset, chunks: Chunks = None) -> Iterator[xr.Dataset]:
|
|
112
|
+
"""Explodes a dataset into its chunks."""
|
|
113
|
+
yield from (ds.isel(b) for b in block_slices(ds, chunks=chunks))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _block_len(block: Block) -> int:
|
|
117
|
+
return int(np.prod([v.stop - v.start for v in block.values()]))
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def from_map_batched(
|
|
121
|
+
func: Callable[..., pd.DataFrame],
|
|
122
|
+
*iterables: tuple[Any, ...],
|
|
123
|
+
args: tuple | None = None,
|
|
124
|
+
schema: pa.Schema = None,
|
|
125
|
+
**kwargs: dict[str, Any],
|
|
126
|
+
) -> pa.RecordBatchReader:
|
|
127
|
+
"""Create a PyArrow RecordBatchReader by mapping a function over iterables.
|
|
128
|
+
|
|
129
|
+
This is equivalent to dask's from_map but returns a PyArrow
|
|
130
|
+
RecordBatchReader that can be used with DataFusion. It iterates over
|
|
131
|
+
RecordBatches which are created via the `func` one-at-a-time.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
func: Function to apply to each element of the iterables. Currently, the
|
|
135
|
+
function must return a Pandas DataFrame.
|
|
136
|
+
*iterables: Iterable objects to map the function over.
|
|
137
|
+
schema: Optional schema needed for the RecordBatchReader.
|
|
138
|
+
args: Additional positional arguments to pass to func.
|
|
139
|
+
**kwargs: Additional keyword arguments to pass to func.
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
A PyArrow RecordBatchReader containing the stream of RecordBatches.
|
|
143
|
+
"""
|
|
144
|
+
if args is None:
|
|
145
|
+
args = ()
|
|
146
|
+
|
|
147
|
+
def map_batches():
|
|
148
|
+
for items in zip(*iterables):
|
|
149
|
+
df = func(*items, *args, **kwargs)
|
|
150
|
+
yield pa.RecordBatch.from_pandas(df, schema=schema)
|
|
151
|
+
|
|
152
|
+
return pa.RecordBatchReader.from_batches(schema, map_batches())
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def from_map(
|
|
156
|
+
func: Callable,
|
|
157
|
+
*iterables: tuple[Any, ...],
|
|
158
|
+
args: tuple | None = None,
|
|
159
|
+
**kwargs: dict[str, Any],
|
|
160
|
+
) -> pa.Table:
|
|
161
|
+
"""Create a PyArrow Table by mapping a function over iterables.
|
|
162
|
+
|
|
163
|
+
This is equivalent to dask's from_map but returns a PyArrow Table
|
|
164
|
+
that can be used with DataFusion instead of a Dask DataFrame.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
func: Function to apply to each element of the iterables.
|
|
168
|
+
*iterables: Iterable objects to map the function over.
|
|
169
|
+
args: Additional positional arguments to pass to func.
|
|
170
|
+
**kwargs: Additional keyword arguments to pass to func.
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
A PyArrow Table containing the concatenated results.
|
|
174
|
+
"""
|
|
175
|
+
if args is None:
|
|
176
|
+
args = ()
|
|
177
|
+
|
|
178
|
+
# Apply the function to each combination of iterable elements
|
|
179
|
+
results = []
|
|
180
|
+
for items in zip(*iterables) if len(iterables) > 1 else iterables[0]:
|
|
181
|
+
if isinstance(items, tuple):
|
|
182
|
+
result = func(*items, *args, **kwargs)
|
|
183
|
+
else:
|
|
184
|
+
result = func(items, *args, **kwargs)
|
|
185
|
+
|
|
186
|
+
# Convert result to PyArrow Table
|
|
187
|
+
if isinstance(result, pd.DataFrame):
|
|
188
|
+
pa_table = pa.Table.from_pandas(result)
|
|
189
|
+
elif isinstance(result, pa.Table):
|
|
190
|
+
pa_table = result
|
|
191
|
+
else:
|
|
192
|
+
# Try to convert to pandas first, then to PyArrow
|
|
193
|
+
try:
|
|
194
|
+
df = pd.DataFrame(result)
|
|
195
|
+
pa_table = pa.Table.from_pandas(df)
|
|
196
|
+
except Exception as e:
|
|
197
|
+
raise ValueError(
|
|
198
|
+
f"Cannot convert function result to PyArrow Table: {e}"
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
results.append(pa_table)
|
|
202
|
+
|
|
203
|
+
# Concatenate all results
|
|
204
|
+
if not results:
|
|
205
|
+
raise ValueError("No results to concatenate")
|
|
206
|
+
|
|
207
|
+
return pa.concat_tables(results)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def pivot(ds: xr.Dataset) -> pd.DataFrame:
|
|
211
|
+
"""Converts an xarray Dataset to a pandas DataFrame."""
|
|
212
|
+
return ds.to_dataframe().reset_index() # type: ignore[no-any-return]
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def dataset_to_record_batch(
|
|
216
|
+
ds: xr.Dataset, schema: pa.Schema
|
|
217
|
+
) -> pa.RecordBatch:
|
|
218
|
+
"""Convert an xarray Dataset partition to an Arrow RecordBatch.
|
|
219
|
+
|
|
220
|
+
Builds the RecordBatch directly from numpy arrays, bypassing the pandas
|
|
221
|
+
round-trip (to_dataframe → reset_index → from_pandas) used by pivot().
|
|
222
|
+
For large partitions this reduces peak memory from ~5× to ~2× the
|
|
223
|
+
partition size.
|
|
224
|
+
|
|
225
|
+
Dimension coordinates are broadcast to the full partition shape and
|
|
226
|
+
ravelled. np.broadcast_to() is zero-copy; the ravel() forces one copy
|
|
227
|
+
per coordinate (unavoidable, since broadcast arrays are non-contiguous).
|
|
228
|
+
Data variable arrays are ravelled in-place — a zero-copy view when the
|
|
229
|
+
underlying array is already C-contiguous (the common case for numpy-backed
|
|
230
|
+
xarray datasets).
|
|
231
|
+
|
|
232
|
+
Args:
|
|
233
|
+
ds: A partition-sized xarray Dataset (already sliced via isel).
|
|
234
|
+
schema: The Arrow schema for the output, as produced by _parse_schema.
|
|
235
|
+
Column order in the output matches schema field order.
|
|
236
|
+
|
|
237
|
+
Returns:
|
|
238
|
+
A RecordBatch with one column per dimension coordinate and data
|
|
239
|
+
variable, in schema order.
|
|
240
|
+
"""
|
|
241
|
+
# Use the data variable's dimension order as canonical so coordinate
|
|
242
|
+
# broadcasts and data variable ravels use the same layout. All data
|
|
243
|
+
# variables are validated to share the same dims tuple.
|
|
244
|
+
if ds.data_vars:
|
|
245
|
+
first_var = next(iter(ds.data_vars.values()))
|
|
246
|
+
dim_names = list(first_var.dims)
|
|
247
|
+
shape = first_var.shape
|
|
248
|
+
else:
|
|
249
|
+
dim_names = list(ds.sizes.keys())
|
|
250
|
+
shape = tuple(ds.sizes[d] for d in dim_names)
|
|
251
|
+
|
|
252
|
+
arrays = []
|
|
253
|
+
for field in schema:
|
|
254
|
+
name = field.name
|
|
255
|
+
if name in ds.coords and name in ds.dims:
|
|
256
|
+
# Broadcast 1-D coordinate to the full N-D partition shape, then ravel.
|
|
257
|
+
axis = dim_names.index(name)
|
|
258
|
+
coord = ds.coords[name].values
|
|
259
|
+
if cft.is_cftime(coord):
|
|
260
|
+
coord = cft.convert_for_field(coord, field)
|
|
261
|
+
reshape = [1] * len(shape)
|
|
262
|
+
reshape[axis] = coord.shape[0]
|
|
263
|
+
arr = np.broadcast_to(coord.reshape(reshape), shape).ravel()
|
|
264
|
+
arrays.append(pa.array(arr, type=field.type))
|
|
265
|
+
else:
|
|
266
|
+
# Data variable: ravel to 1-D (zero-copy for C-contiguous arrays).
|
|
267
|
+
raw = ds[name].values.ravel()
|
|
268
|
+
if cft.is_cftime(ds[name].values):
|
|
269
|
+
raw = cft.convert_for_field(ds[name].values, field)
|
|
270
|
+
|
|
271
|
+
# from_pandas=True maps NaN → Arrow null inside the C++ copy kernel,
|
|
272
|
+
# so SQL aggregates (MAX, MIN, AVG) skip missing values correctly.
|
|
273
|
+
arrays.append(pa.array(raw, type=field.type, from_pandas=True))
|
|
274
|
+
|
|
275
|
+
return pa.RecordBatch.from_arrays(arrays, schema=schema)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
#: Default number of rows per emitted Arrow RecordBatch.
|
|
279
|
+
#: 64 K rows balances DataFusion pipeline depth against per-batch overhead.
|
|
280
|
+
DEFAULT_BATCH_SIZE: int = 65_536
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def iter_record_batches(
|
|
284
|
+
ds: xr.Dataset,
|
|
285
|
+
schema: pa.Schema,
|
|
286
|
+
batch_size: int = DEFAULT_BATCH_SIZE,
|
|
287
|
+
) -> Iterator[pa.RecordBatch]:
|
|
288
|
+
"""Yield RecordBatches of at most *batch_size* rows from a partition Dataset.
|
|
289
|
+
|
|
290
|
+
Unlike `dataset_to_record_batch`, which materialises the entire
|
|
291
|
+
partition as one batch, this generator emits smaller batches so that
|
|
292
|
+
DataFusion can begin filtering and aggregating before the full partition
|
|
293
|
+
is loaded. Peak memory per batch is O(batch_size) for coordinate columns
|
|
294
|
+
and O(partition_size) for data-variable columns (which must be loaded in
|
|
295
|
+
full from storage).
|
|
296
|
+
|
|
297
|
+
Coordinate values are computed per batch via strided index arithmetic —
|
|
298
|
+
no broadcast array spanning the whole partition is ever allocated. Data
|
|
299
|
+
variable flat arrays are loaded once (triggering any remote I/O) and then
|
|
300
|
+
sliced as zero-copy views for each batch.
|
|
301
|
+
|
|
302
|
+
Args:
|
|
303
|
+
ds: A partition-sized xarray Dataset (already sliced via isel).
|
|
304
|
+
schema: The Arrow schema for the output, as produced by _parse_schema.
|
|
305
|
+
batch_size: Maximum number of rows per yielded RecordBatch.
|
|
306
|
+
|
|
307
|
+
Yields:
|
|
308
|
+
RecordBatches in schema column order, covering all rows of the
|
|
309
|
+
partition exactly once.
|
|
310
|
+
"""
|
|
311
|
+
if ds.data_vars:
|
|
312
|
+
first_var = next(iter(ds.data_vars.values()))
|
|
313
|
+
dim_names = list(first_var.dims)
|
|
314
|
+
shape = first_var.shape
|
|
315
|
+
else:
|
|
316
|
+
dim_names = list(ds.sizes.keys())
|
|
317
|
+
shape = tuple(ds.sizes[d] for d in dim_names)
|
|
318
|
+
|
|
319
|
+
total_rows = int(np.prod(shape))
|
|
320
|
+
|
|
321
|
+
# Preload small 1-D coordinate arrays (negligible memory).
|
|
322
|
+
# Convert cftime objects to numeric values matching the schema type.
|
|
323
|
+
coord_values = {}
|
|
324
|
+
for name in dim_names:
|
|
325
|
+
vals = ds.coords[name].values
|
|
326
|
+
if cft.is_cftime(vals):
|
|
327
|
+
coord_values[name] = cft.convert_for_field(vals, schema.field(name))
|
|
328
|
+
else:
|
|
329
|
+
coord_values[name] = vals
|
|
330
|
+
|
|
331
|
+
# C-order stride for each dimension: stride[k] = prod(shape[k+1:]).
|
|
332
|
+
# Flat row index i → coordinate index for dim k: (i // stride[k]) % shape[k].
|
|
333
|
+
strides = [int(np.prod(shape[k + 1 :])) for k in range(len(shape))]
|
|
334
|
+
|
|
335
|
+
# Load data-variable arrays fully (triggers Dask/Zarr compute once).
|
|
336
|
+
# ravel() is a zero-copy view for C-contiguous arrays.
|
|
337
|
+
data_arrays = {}
|
|
338
|
+
for field in schema:
|
|
339
|
+
if field.name not in ds.dims:
|
|
340
|
+
raw = ds[field.name].values
|
|
341
|
+
if cft.is_cftime(raw):
|
|
342
|
+
data_arrays[field.name] = cft.convert_for_field(raw, field)
|
|
343
|
+
else:
|
|
344
|
+
data_arrays[field.name] = raw.ravel()
|
|
345
|
+
|
|
346
|
+
for row_start in range(0, total_rows, batch_size):
|
|
347
|
+
row_end = min(row_start + batch_size, total_rows)
|
|
348
|
+
row_idx = np.arange(row_start, row_end)
|
|
349
|
+
|
|
350
|
+
arrays = []
|
|
351
|
+
for field in schema:
|
|
352
|
+
name = field.name
|
|
353
|
+
if name in ds.coords and name in ds.dims:
|
|
354
|
+
k = dim_names.index(name)
|
|
355
|
+
coord_idx = (row_idx // strides[k]) % shape[k]
|
|
356
|
+
arrays.append(
|
|
357
|
+
pa.array(coord_values[name][coord_idx], type=field.type)
|
|
358
|
+
)
|
|
359
|
+
else:
|
|
360
|
+
arrays.append(
|
|
361
|
+
pa.array(
|
|
362
|
+
data_arrays[name][row_start:row_end],
|
|
363
|
+
type=field.type,
|
|
364
|
+
from_pandas=True,
|
|
365
|
+
)
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
yield pa.RecordBatch.from_arrays(arrays, schema=schema)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _parse_schema(ds: xr.Dataset) -> pa.Schema:
|
|
372
|
+
"""Extracts a `pa.Schema` from the Dataset, treating dims and data_vars as columns.
|
|
373
|
+
|
|
374
|
+
Uses the xarray index type to detect cftime coordinates without
|
|
375
|
+
materializing their data — important for Dask/Zarr-backed datasets
|
|
376
|
+
where .values would trigger eager computation.
|
|
377
|
+
|
|
378
|
+
cftime coordinates are mapped to one of two Arrow types:
|
|
379
|
+
|
|
380
|
+
* **Gregorian-like calendars** (standard, noleap, all_leap, etc.):
|
|
381
|
+
``pa.timestamp('us')`` so string-based SQL filters work naturally.
|
|
382
|
+
* **Non-Gregorian calendars** (360_day, julian):
|
|
383
|
+
``pa.int64()`` with ``xarray:units`` / ``xarray:calendar`` metadata
|
|
384
|
+
on the field, preserving lossless CF-convention encoding.
|
|
385
|
+
"""
|
|
386
|
+
columns = []
|
|
387
|
+
|
|
388
|
+
for coord_name, coord_var in ds.coords.items():
|
|
389
|
+
# Only include dimension coordinates
|
|
390
|
+
if coord_name in ds.dims:
|
|
391
|
+
if cft.is_cftime_index(ds, coord_name):
|
|
392
|
+
units, calendar = cft.encoding(ds, coord_name)
|
|
393
|
+
columns.append(cft.arrow_field(coord_name, units, calendar))
|
|
394
|
+
else:
|
|
395
|
+
pa_type = pa.from_numpy_dtype(coord_var.dtype)
|
|
396
|
+
columns.append(pa.field(coord_name, pa_type))
|
|
397
|
+
|
|
398
|
+
for var_name, var in ds.data_vars.items():
|
|
399
|
+
# Data variables are virtually never cftime, but check dtype as a
|
|
400
|
+
# cheap guard. Only fall back to _is_cftime (which materializes
|
|
401
|
+
# element 0) when dtype is object.
|
|
402
|
+
if var.dtype == np.dtype("O") and cft.is_cftime(var.values):
|
|
403
|
+
# Rare: a data variable holding cftime objects. Use same encoding
|
|
404
|
+
# as the first cftime dimension coordinate, or default.
|
|
405
|
+
cal = var.values.ravel()[0].calendar
|
|
406
|
+
columns.append(cft.arrow_field(var_name, cft.DEFAULT_UNITS, cal))
|
|
407
|
+
else:
|
|
408
|
+
pa_type = pa.from_numpy_dtype(var.dtype)
|
|
409
|
+
columns.append(pa.field(var_name, pa_type))
|
|
410
|
+
|
|
411
|
+
return pa.schema(columns)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
# Type alias for partition metadata: maps dimension name to (min, max, dtype_str) values
|
|
415
|
+
PartitionBounds = dict[str, tuple[Any, Any, str]]
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _block_metadata(
|
|
419
|
+
coord_arrays: dict,
|
|
420
|
+
block: Block,
|
|
421
|
+
dims: Iterable[Hashable] | None = None,
|
|
422
|
+
) -> PartitionBounds:
|
|
423
|
+
"""Compute min/max coordinate values for a single partition block.
|
|
424
|
+
|
|
425
|
+
Args:
|
|
426
|
+
coord_arrays: Pre-materialised coordinate arrays keyed by dimension name
|
|
427
|
+
string. Hoist this outside any loop to avoid repeated remote I/O
|
|
428
|
+
for Zarr-backed datasets.
|
|
429
|
+
block: A single block slice dict from block_slices().
|
|
430
|
+
dims: Optional restriction to a subset of dims to compute. Used by
|
|
431
|
+
``read_xarray_table`` to skip unchunked dims whose bounds are
|
|
432
|
+
constant across all partitions and have been precomputed once.
|
|
433
|
+
Defaults to all dims present in ``block``.
|
|
434
|
+
|
|
435
|
+
Returns:
|
|
436
|
+
Dict mapping dimension name to (min_value, max_value, dtype_str).
|
|
437
|
+
Dimensions with an empty slice are omitted; the Rust pruning logic
|
|
438
|
+
treats missing dimensions conservatively (never prunes on them).
|
|
439
|
+
"""
|
|
440
|
+
items = ((d, block[d]) for d in dims) if dims is not None else block.items()
|
|
441
|
+
ranges: PartitionBounds = {}
|
|
442
|
+
for dim, slc in items:
|
|
443
|
+
coord_values = coord_arrays[str(dim)][slc]
|
|
444
|
+
if len(coord_values) == 0:
|
|
445
|
+
continue
|
|
446
|
+
# String/object dtypes are not representable as ScalarBound
|
|
447
|
+
# (Int64/Float64/TimestampNanos) and numpy min/max ufuncs do not
|
|
448
|
+
# support them. Skip so pruning treats the dimension conservatively.
|
|
449
|
+
if coord_values.dtype.kind in ("U", "S", "O"):
|
|
450
|
+
continue
|
|
451
|
+
|
|
452
|
+
if cft.is_cftime(coord_values):
|
|
453
|
+
ranges[str(dim)] = cft.partition_bounds(coord_values)
|
|
454
|
+
continue
|
|
455
|
+
|
|
456
|
+
# Use actual min/max rather than first/last so that non-monotonic
|
|
457
|
+
# coordinate axes (e.g. descending latitude 90→-90) are handled
|
|
458
|
+
# correctly. np.min/max work for both numeric and datetime64 arrays.
|
|
459
|
+
min_val = coord_values.min()
|
|
460
|
+
max_val = coord_values.max()
|
|
461
|
+
|
|
462
|
+
if isinstance(min_val, (np.datetime64, pd.Timestamp)):
|
|
463
|
+
min_val = int(pd.Timestamp(min_val).value)
|
|
464
|
+
max_val = int(pd.Timestamp(max_val).value)
|
|
465
|
+
ranges[str(dim)] = (min_val, max_val, "timestamp_ns")
|
|
466
|
+
elif hasattr(min_val, "item"):
|
|
467
|
+
min_val = min_val.item()
|
|
468
|
+
max_val = max_val.item()
|
|
469
|
+
dtype = "float64" if isinstance(min_val, float) else "int64"
|
|
470
|
+
ranges[str(dim)] = (min_val, max_val, dtype)
|
|
471
|
+
else:
|
|
472
|
+
dtype = "float64" if isinstance(min_val, float) else "int64"
|
|
473
|
+
ranges[str(dim)] = (min_val, max_val, dtype)
|
|
474
|
+
return ranges
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def partition_metadata(
|
|
478
|
+
ds: xr.Dataset, blocks: list[Block]
|
|
479
|
+
) -> list[PartitionBounds]:
|
|
480
|
+
"""Compute min/max coordinate values for each partition.
|
|
481
|
+
|
|
482
|
+
This metadata enables filter pushdown: SQL queries with WHERE clauses
|
|
483
|
+
on dimension columns can prune partitions that can't contain matching rows.
|
|
484
|
+
|
|
485
|
+
Args:
|
|
486
|
+
ds: The xarray Dataset containing coordinate values.
|
|
487
|
+
blocks: List of block slices from block_slices().
|
|
488
|
+
|
|
489
|
+
Returns:
|
|
490
|
+
List of dicts mapping dimension name to
|
|
491
|
+
(min_value, max_value, dtype_str) tuples.
|
|
492
|
+
|
|
493
|
+
- For datetime64, values are nanoseconds since Unix epoch
|
|
494
|
+
(int64), dtype_str is "timestamp_ns"
|
|
495
|
+
- For numeric types, values are Python int or float,
|
|
496
|
+
dtype_str is "int64" or "float64"
|
|
497
|
+
|
|
498
|
+
Note:
|
|
499
|
+
If a partition has an empty slice for a dimension, that dimension is
|
|
500
|
+
omitted from the partition's metadata. The Rust pruning logic treats
|
|
501
|
+
missing dimensions conservatively (never prunes on them).
|
|
502
|
+
"""
|
|
503
|
+
# Hoist coordinate array reads outside the partition loop.
|
|
504
|
+
# ds.coords[dim].values materializes the full array on every call; doing it
|
|
505
|
+
# N_partitions × N_dims times is wasteful and, for remote Zarr-backed datasets
|
|
506
|
+
# (e.g. ARCO-ERA5 on GCS), may trigger repeated network I/O.
|
|
507
|
+
coord_arrays = {str(dim): ds.coords[dim].values for dim in ds.dims}
|
|
508
|
+
return [_block_metadata(coord_arrays, block) for block in blocks]
|