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 ADDED
@@ -0,0 +1,12 @@
1
+ from . import cftime
2
+ from .df import from_map
3
+ from .reader import read_xarray, read_xarray_table
4
+ from .sql import XarrayContext
5
+
6
+ __all__ = [
7
+ "cftime",
8
+ "XarrayContext",
9
+ "read_xarray_table",
10
+ "read_xarray",
11
+ "from_map", # deprecated
12
+ ]
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