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/__init__.py
ADDED
xarray_sql/_native.pyd
ADDED
|
Binary file
|
xarray_sql/cftime.py
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""Bridge between cftime calendars and Arrow/DataFusion types.
|
|
2
|
+
|
|
3
|
+
cftime (https://unidata.github.io/cftime/) provides datetime objects for
|
|
4
|
+
calendars used in climate science — noleap, 360-day, all-leap, julian, etc.
|
|
5
|
+
Arrow and DataFusion have no native concept of non-Gregorian calendars, so
|
|
6
|
+
this module handles the conversion in two tiers:
|
|
7
|
+
|
|
8
|
+
* **Gregorian-like calendars** (standard, gregorian, proleptic_gregorian,
|
|
9
|
+
noleap/365_day, all_leap/366_day): mapped to ``pa.timestamp('us')`` so
|
|
10
|
+
that string-based SQL filters like ``WHERE time > '1980-01-01'`` work
|
|
11
|
+
naturally. Microsecond resolution avoids the 1678–2262 overflow of
|
|
12
|
+
nanoseconds while preserving sub-second precision.
|
|
13
|
+
|
|
14
|
+
* **Non-Gregorian calendars** (360_day, julian): mapped to ``pa.int64()``
|
|
15
|
+
with ``xarray:units`` and ``xarray:calendar`` metadata on the Arrow field.
|
|
16
|
+
This preserves the original CF-convention encoding losslessly. A
|
|
17
|
+
``cftime()`` DataFusion UDF (registered automatically by
|
|
18
|
+
``XarrayContext.from_dataset``) provides ergonomic SQL filtering.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import numpy as np
|
|
24
|
+
import pyarrow as pa
|
|
25
|
+
import xarray as xr
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
# Calendar classification
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
#: Calendars close enough to proleptic Gregorian for ``pa.timestamp('us')``.
|
|
33
|
+
GREGORIAN_LIKE_CALENDARS: frozenset[str] = frozenset(
|
|
34
|
+
{
|
|
35
|
+
"standard",
|
|
36
|
+
"gregorian",
|
|
37
|
+
"proleptic_gregorian",
|
|
38
|
+
"noleap",
|
|
39
|
+
"365_day",
|
|
40
|
+
"all_leap",
|
|
41
|
+
"366_day",
|
|
42
|
+
}
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
#: Default CF-convention units when no encoding is available on the coordinate.
|
|
46
|
+
#: Microseconds give sub-second precision and fit int64 for ±292 k years.
|
|
47
|
+
DEFAULT_UNITS: str = "microseconds since 1970-01-01T00:00:00"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def is_gregorian_like(calendar: str) -> bool:
|
|
51
|
+
"""Return True if *calendar* is close enough to Gregorian for ``pa.timestamp``."""
|
|
52
|
+
return calendar in GREGORIAN_LIKE_CALENDARS
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
# Detection helpers (avoid materializing Dask/Zarr data where possible)
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def is_cftime(values: np.ndarray) -> bool:
|
|
61
|
+
"""Check if a numpy array contains cftime datetime objects."""
|
|
62
|
+
try:
|
|
63
|
+
import cftime
|
|
64
|
+
|
|
65
|
+
if values.dtype == np.dtype("O") and len(values) > 0:
|
|
66
|
+
sample = values.ravel()[0]
|
|
67
|
+
return isinstance(sample, cftime.datetime)
|
|
68
|
+
except ImportError:
|
|
69
|
+
pass
|
|
70
|
+
return False
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def is_cftime_index(ds: xr.Dataset, coord_name: str) -> bool:
|
|
74
|
+
"""Check if a coordinate uses a ``CFTimeIndex`` without materializing data."""
|
|
75
|
+
try:
|
|
76
|
+
idx = ds.indexes.get(coord_name)
|
|
77
|
+
if idx is not None:
|
|
78
|
+
from xarray import CFTimeIndex
|
|
79
|
+
|
|
80
|
+
return isinstance(idx, CFTimeIndex)
|
|
81
|
+
except (ImportError, AttributeError):
|
|
82
|
+
pass
|
|
83
|
+
return False
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def calendar(ds: xr.Dataset, coord_name: str) -> str | None:
|
|
87
|
+
"""Return the calendar name for a cftime coordinate, or ``None``.
|
|
88
|
+
|
|
89
|
+
Checks the xarray index first (no data materialization), then falls
|
|
90
|
+
back to inspecting element 0 of the coordinate values.
|
|
91
|
+
"""
|
|
92
|
+
try:
|
|
93
|
+
idx = ds.indexes.get(coord_name)
|
|
94
|
+
if idx is not None:
|
|
95
|
+
from xarray import CFTimeIndex
|
|
96
|
+
|
|
97
|
+
if isinstance(idx, CFTimeIndex):
|
|
98
|
+
return str(idx.calendar) # type: ignore[attr-defined]
|
|
99
|
+
except (ImportError, AttributeError):
|
|
100
|
+
pass
|
|
101
|
+
try:
|
|
102
|
+
values = ds.coords[coord_name].values
|
|
103
|
+
if is_cftime(values):
|
|
104
|
+
return str(values.ravel()[0].calendar)
|
|
105
|
+
except (AttributeError, KeyError):
|
|
106
|
+
pass
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def encoding(ds: xr.Dataset, coord_name: str) -> tuple[str, str]:
|
|
111
|
+
"""Return ``(units, calendar)`` for a cftime coordinate.
|
|
112
|
+
|
|
113
|
+
Reads xarray ``.encoding`` metadata (from the originating NetCDF file)
|
|
114
|
+
first, falling back to :data:`DEFAULT_UNITS`.
|
|
115
|
+
"""
|
|
116
|
+
cal = calendar(ds, coord_name) or "standard"
|
|
117
|
+
enc = ds.coords[coord_name].encoding
|
|
118
|
+
units = enc.get("units", DEFAULT_UNITS)
|
|
119
|
+
return units, cal
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# ---------------------------------------------------------------------------
|
|
123
|
+
# Numeric conversion
|
|
124
|
+
# ---------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def to_microseconds(values) -> np.ndarray:
|
|
128
|
+
"""Convert cftime objects to int64 microseconds since Unix epoch.
|
|
129
|
+
|
|
130
|
+
Used for Gregorian-like calendars. Vectorised via ``cftime.date2num``
|
|
131
|
+
(implemented in C).
|
|
132
|
+
"""
|
|
133
|
+
import cftime as _cftime
|
|
134
|
+
|
|
135
|
+
us = _cftime.date2num(
|
|
136
|
+
values.ravel(),
|
|
137
|
+
units=DEFAULT_UNITS,
|
|
138
|
+
calendar=values.ravel()[0].calendar,
|
|
139
|
+
)
|
|
140
|
+
return np.asarray(us, dtype=np.float64).astype(np.int64)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def to_offsets(values, units: str, cal: str) -> np.ndarray:
|
|
144
|
+
"""Convert cftime objects to int64 offsets in the given *units*/*calendar*.
|
|
145
|
+
|
|
146
|
+
Used for non-Gregorian calendars where data is stored as ``pa.int64()``.
|
|
147
|
+
"""
|
|
148
|
+
import cftime as _cftime
|
|
149
|
+
|
|
150
|
+
raw = _cftime.date2num(values.ravel(), units=units, calendar=cal)
|
|
151
|
+
return np.asarray(raw, dtype=np.float64).astype(np.int64)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def convert_for_field(values, field: pa.Field) -> np.ndarray:
|
|
155
|
+
"""Convert cftime values to the numeric type dictated by *field*.
|
|
156
|
+
|
|
157
|
+
Reads ``xarray:calendar`` and ``xarray:units`` from the field's Arrow
|
|
158
|
+
metadata to choose between the timestamp path and the integer-offset path.
|
|
159
|
+
"""
|
|
160
|
+
meta = field.metadata or {}
|
|
161
|
+
cal = meta.get(b"xarray:calendar", b"standard").decode()
|
|
162
|
+
units = meta.get(b"xarray:units", DEFAULT_UNITS.encode()).decode()
|
|
163
|
+
if is_gregorian_like(cal):
|
|
164
|
+
return to_microseconds(values)
|
|
165
|
+
return to_offsets(values, units, cal)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# ---------------------------------------------------------------------------
|
|
169
|
+
# Partition pruning helpers
|
|
170
|
+
# ---------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def partition_bounds(
|
|
174
|
+
values,
|
|
175
|
+
) -> tuple[int, int, str]:
|
|
176
|
+
"""Return ``(min, max, dtype_tag)`` for a cftime coordinate slice.
|
|
177
|
+
|
|
178
|
+
Gregorian-like calendars return nanosecond bounds tagged
|
|
179
|
+
``"timestamp_ns"`` (compatible with ``ScalarBound::TimestampNanos``
|
|
180
|
+
in the Rust pruning layer). Non-Gregorian calendars return int64
|
|
181
|
+
offsets tagged ``"int64"``.
|
|
182
|
+
"""
|
|
183
|
+
cal = values.ravel()[0].calendar
|
|
184
|
+
if is_gregorian_like(cal):
|
|
185
|
+
us = to_microseconds(values)
|
|
186
|
+
return int(us.min()) * 1_000, int(us.max()) * 1_000, "timestamp_ns"
|
|
187
|
+
offsets = to_offsets(values, DEFAULT_UNITS, cal)
|
|
188
|
+
return int(offsets.min()), int(offsets.max()), "int64"
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
# ---------------------------------------------------------------------------
|
|
192
|
+
# Arrow schema helpers
|
|
193
|
+
# ---------------------------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def arrow_field(name: str, units: str, cal: str) -> pa.Field:
|
|
197
|
+
"""Build a ``pa.Field`` for a cftime coordinate.
|
|
198
|
+
|
|
199
|
+
Gregorian-like → ``pa.timestamp('us')``; non-Gregorian → ``pa.int64()``.
|
|
200
|
+
Both carry ``xarray:calendar`` and ``xarray:units`` metadata for
|
|
201
|
+
round-trip fidelity.
|
|
202
|
+
"""
|
|
203
|
+
meta = {
|
|
204
|
+
b"xarray:calendar": cal.encode(),
|
|
205
|
+
b"xarray:units": units.encode(),
|
|
206
|
+
}
|
|
207
|
+
if is_gregorian_like(cal):
|
|
208
|
+
return pa.field(name, pa.timestamp("us"), metadata=meta)
|
|
209
|
+
return pa.field(name, pa.int64(), metadata=meta)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
# ---------------------------------------------------------------------------
|
|
213
|
+
# DataFusion UDF
|
|
214
|
+
# ---------------------------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def make_cftime_udf(units: str, calendar: str):
|
|
218
|
+
"""Create a DataFusion scalar UDF that converts date strings to int64 offsets.
|
|
219
|
+
|
|
220
|
+
This enables ergonomic SQL filtering on non-Gregorian cftime columns::
|
|
221
|
+
|
|
222
|
+
SELECT * FROM ds360 WHERE time > cftime('0500-01-01')
|
|
223
|
+
|
|
224
|
+
The UDF parses the input string as a cftime datetime in the given
|
|
225
|
+
calendar system and returns the corresponding int64 offset in the
|
|
226
|
+
specified units.
|
|
227
|
+
"""
|
|
228
|
+
import cftime as _cftime
|
|
229
|
+
from datafusion import udf
|
|
230
|
+
|
|
231
|
+
def _cftime_scalar(date_strings: pa.Array) -> pa.Array:
|
|
232
|
+
results: list[int | None] = []
|
|
233
|
+
for s in date_strings.to_pylist():
|
|
234
|
+
if s is None:
|
|
235
|
+
results.append(None)
|
|
236
|
+
continue
|
|
237
|
+
dt = _cftime.datetime.strptime(s, "%Y-%m-%d", calendar=calendar)
|
|
238
|
+
val = _cftime.date2num(dt, units=units, calendar=calendar)
|
|
239
|
+
results.append(int(val))
|
|
240
|
+
return pa.array(results, type=pa.int64())
|
|
241
|
+
|
|
242
|
+
return udf(
|
|
243
|
+
_cftime_scalar,
|
|
244
|
+
[pa.utf8()],
|
|
245
|
+
pa.int64(),
|
|
246
|
+
"immutable",
|
|
247
|
+
"cftime",
|
|
248
|
+
)
|
xarray_sql/core.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import itertools
|
|
2
|
+
from collections.abc import Iterator
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
import xarray as xr
|
|
7
|
+
|
|
8
|
+
Row = list[Any]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# deprecated
|
|
12
|
+
def get_columns(ds: xr.Dataset) -> list[str]:
|
|
13
|
+
return list(ds.sizes.keys()) + list(ds.data_vars.keys())
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# Deprecated
|
|
17
|
+
def unravel(ds: xr.Dataset) -> Iterator[Row]:
|
|
18
|
+
dim_keys, dim_vals = zip(*ds.sizes.items())
|
|
19
|
+
|
|
20
|
+
for idx in itertools.product(*(range(d) for d in dim_vals)):
|
|
21
|
+
coord_idx = dict(zip(dim_keys, idx))
|
|
22
|
+
data = ds.isel(coord_idx)
|
|
23
|
+
coord_data = [ds.coords[v][coord_idx[v]] for v in dim_keys]
|
|
24
|
+
row = [v.values for v in coord_data + list(data.data_vars.values())]
|
|
25
|
+
yield row
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# Deprecated
|
|
29
|
+
def unbounded_unravel(ds: xr.Dataset) -> np.ndarray:
|
|
30
|
+
"""Unravel with unbounded memory (as a NumPy Array)."""
|
|
31
|
+
dim_keys, dim_vals = zip(*ds.sizes.items())
|
|
32
|
+
columns = get_columns(ds)
|
|
33
|
+
|
|
34
|
+
N = np.prod([d for d in dim_vals])
|
|
35
|
+
|
|
36
|
+
out = np.recarray((N,), dtype=[(c, ds[c].dtype) for c in columns])
|
|
37
|
+
|
|
38
|
+
for name, da in ds.items():
|
|
39
|
+
out[name] = da.values.ravel()
|
|
40
|
+
|
|
41
|
+
prod_vals = (ds.coords[k].values for k in dim_keys)
|
|
42
|
+
coords = np.array(np.meshgrid(*prod_vals), dtype=int).T.reshape(
|
|
43
|
+
-1, len(dim_keys)
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
for i, d in enumerate(dim_keys):
|
|
47
|
+
out[d] = coords[:, i]
|
|
48
|
+
|
|
49
|
+
return out
|