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/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]