Skip to content

Commit 289bedb

Browse files
committed
Merge remote-tracking branch 'origin/main' into feat/lazy-sql-to-xarray
# Conflicts: # xarray_sql/sql.py
2 parents ade544a + 3ba54ea commit 289bedb

8 files changed

Lines changed: 472 additions & 66 deletions

File tree

.github/workflows/publish.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,8 +130,11 @@ jobs:
130130
uses: PyO3/maturin-action@v1
131131
with:
132132
rust-toolchain: stable
133-
manylinux: auto
134133
rustup-components: rust-std rustfmt
134+
# No `manylinux:` — sdist is platform-independent and runs on the
135+
# host runner, which has python3 available. Running this in a
136+
# manylinux container fails because maturin verifies the sdist by
137+
# building a wheel from it, and the container has no interpreter.
135138
args: --release --sdist --out dist
136139

137140
- name: Assert sdist build does not generate wheels

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "xarray_sql"
3-
version = "0.2.2"
3+
version = "0.2.3"
44
authors = ["Alex Merose"]
55
edition = "2021"
66
exclude = [

README.md

Lines changed: 89 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -19,52 +19,97 @@ This is an experiment to provide a SQL interface for array datasets.
1919
import xarray as xr
2020
import xarray_sql as xql
2121

22-
ds = xr.tutorial.open_dataset('air_temperature')
2322

24-
# The same as a dask-sql Context; i.e. an Apache DataFusion Context.
23+
# Open a year of ARCO-ERA5 — all 273 variables. Selecting a year up front
24+
# keeps Dask's partition setup cheap before any chunks are read from GCS.
25+
ds = (
26+
xr.open_zarr('gs://gcp-public-data-arco-era5/ar/full_37-1h-0p25deg-chunk-1.zarr-v3',
27+
chunks=dict(time=1),
28+
storage_options={'token': 'anon'}) # Anonymous read from the public GCS bucket — no auth required.
29+
.sel(time='2020')
30+
)
31+
2532
ctx = xql.XarrayContext()
26-
ctx.from_dataset('air', ds, chunks=dict(time=24)) # the dataset needs to be chunked!
27-
# data is only materialized when we make a query.
28-
29-
result = ctx.sql('''
30-
SELECT
31-
"lat", "lon", AVG("air") as air_avg
32-
FROM
33-
"air"
34-
GROUP BY
35-
"lat", "lon"
36-
''')
37-
# DataFrame()
38-
# +------+-------+--------------------+
39-
# | lat | lon | air_avg |
40-
# +------+-------+--------------------+
41-
# | 75.0 | 205.0 | 259.88662671232834 |
42-
# | 75.0 | 207.5 | 259.48268150684896 |
43-
# | 75.0 | 230.0 | 258.9192123287667 |
44-
# | 75.0 | 275.0 | 257.07574315068456 |
45-
# | 75.0 | 322.5 | 250.11792123287654 |
46-
# | 75.0 | 325.0 | 250.81590068493134 |
47-
# | 72.5 | 205.0 | 262.74933904109537 |
48-
# | 72.5 | 207.5 | 262.5384315068488 |
49-
# | 72.5 | 230.0 | 260.82879452054743 |
50-
# | 72.5 | 275.0 | 257.3063321917804 |
51-
# +------+-------+--------------------+
52-
# Data truncated.
53-
54-
# The full query is only made when we call `collect()`, or, in this case,
55-
# `to_pandas()`.
56-
df = result.to_pandas()
57-
df.head()
58-
# lat lon air_avg
59-
# 0 75.0 232.5 258.836188
60-
# 1 75.0 247.5 257.716171
61-
# 2 75.0 262.5 257.347959
62-
# 3 75.0 277.5 257.671308
63-
# 4 72.5 232.5 260.654401
33+
ctx.from_dataset('era5', ds, table_names={
34+
('time', 'latitude', 'longitude'): 'surface',
35+
('time', 'level', 'latitude', 'longitude'): 'atmosphere',
36+
})
37+
# Registration: ~0.5s for a full year of hourly ERA5, all variables.
38+
39+
40+
# Heads up: ARCO-ERA5 has 262 surface + 11 atmospheric variables. The library
41+
# pushes column projection down to Zarr, so SELECT only fetches what you ask
42+
# for — but `SELECT * FROM era5.surface` would try to pull every variable
43+
# across the year (terabytes from GCS).
44+
# ---> Always SELECT specific columns. <---
45+
46+
# Average 2m-temperature over NYC on the morning of 2020-01-01. The library
47+
# pushes WHERE clauses on dimension columns down to partition pruning.
48+
ctx.sql('''
49+
SELECT AVG("2m_temperature") - 273.15 AS avg_c
50+
FROM era5.surface
51+
WHERE time BETWEEN TIMESTAMP '2020-01-01'
52+
AND TIMESTAMP '2020-01-01 05:00:00'
53+
AND latitude BETWEEN 39 AND 40
54+
AND longitude BETWEEN 286 AND 287 -- ERA5 uses 0-360 longitudes
55+
''').to_pandas()
56+
# avg_c
57+
# 0 8.640069
58+
59+
# Average temperature per pressure level, globally.
60+
ctx.sql('''
61+
SELECT level, AVG(temperature) - 273.15 AS avg_c
62+
FROM era5.atmosphere
63+
WHERE time BETWEEN TIMESTAMP '2020-01-01'
64+
AND TIMESTAMP '2020-01-01 05:00:00'
65+
GROUP BY level
66+
ORDER BY level DESC
67+
''').to_pandas()
68+
# level avg_c
69+
# 0 1000 6.621012 ← surface
70+
# 1 975 5.185638
71+
# 2 950 4.028429
72+
# 3 925 3.082812
73+
# 4 900 2.210917
74+
# 5 875 1.395018
75+
# 6 850 0.634267
76+
# 7 825 -0.210372
77+
# 8 800 -1.181075
78+
# 9 775 -2.306465
79+
# 10 750 -3.535534
80+
# 11 700 -6.241685
81+
# 12 650 -9.236364
82+
# 13 600 -12.580938
83+
# 14 550 -16.335386
84+
# 15 500 -20.643604
85+
# 16 450 -25.573401
86+
# 17 400 -31.156920
87+
# 18 350 -37.400552
88+
# 19 300 -43.852607
89+
# 20 250 -49.322132
90+
# 21 225 -51.569113
91+
# 22 200 -53.693248
92+
# 23 175 -55.890484
93+
# 24 150 -58.382290
94+
# 25 125 -61.091916
95+
# 26 100 -63.624885 ← tropopause
96+
# 27 70 -63.182300
97+
# 28 50 -60.124845
98+
# 29 30 -55.986327
99+
# 30 20 -52.433089
100+
# 31 10 -44.140750
101+
# 32 7 -38.707350
102+
# 33 5 -32.621999
103+
# 34 3 -21.509175
104+
# 35 2 -13.355764
105+
# 36 1 -9.020513 ← top of atmosphere
64106
```
65107

66-
Succinctly, we "pivot" Xarray Datasets (with consistent dimensions) to treat them like tables so we can run
67-
SQL queries against them.
108+
_(A runnable version of this example lives at
109+
[`perf_tests/era5_temp_profile.py`](perf_tests/era5_temp_profile.py).)_
110+
111+
Succinctly, we "pivot" Xarray Datasets to treat them like tables so we can run
112+
SQL queries against them.
68113

69114
## Round-tripping back to Xarray
70115

@@ -190,14 +235,14 @@ _2025 update_: Something like this is being built across a few projects! The one
190235
_2026 update_: A colleague and I are experimenting with native Zarr RDBMS engines. Check out:
191236

192237
- [Zarr-Datafusion](https://lib.rs/crates/zarr-datafusion)
193-
- [DuckDB-Zarr](https://github.com/hobbes-bot/duckdb-zarr)
238+
- [DuckDB-Zarr](https://github.com/alxmrs/duckdb-zarr)
194239

195240
## Roadmap
196241

197242
- [x] ~Lazy evaluation via the pyarrow Dataset interface [#93](https://github.com/alxmrs/xarray-sql/issues/93).~ _Implemented in [#100](https://github.com/alxmrs/xarray-sql/pull/100)_
198243
- [x] Support proper parallelism via proper partition handling on the rust/datafusion side. [#106](https://github.com/alxmrs/xarray-sql/issues/106)
199244
- [x] Support core datafusion optimizations to scan less data, like [104](https://github.com/alxmrs/xarray-sql/issues/104), ...
200-
- [ ] Translate a single Zarr to a collection of tables [#85](https://github.com/alxmrs/xarray-sql/issues/85).
245+
- [x] Translate a single Zarr to a collection of tables [#85](https://github.com/alxmrs/xarray-sql/issues/85).
201246
- [ ] Distributed beyond a single node through the DataFusion integration with Ray Datasets [#68](https://github.com/alxmrs/xarray-sql/issues/68) or Apache Ballista [#98](https://github.com/alxmrs/xarray-sql/issues/98).
202247
- [ ] Demo: calculate Sea Surface Temperature from 1940 - Present in SQL [#36](https://github.com/alxmrs/xarray-sql/issues/36).
203248
- [ ] Provide an option to integrate DataFusion directly to Zarr via Rust [#4](https://github.com/alxmrs/xarray-sql/issues/4).

docs/examples.md

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,73 @@ result = ctx.sql('''
2121
df = result.to_pandas()
2222
df.head()
2323
```
24+
25+
## Mixed-dimension datasets: ARCO-ERA5
26+
27+
When a Dataset has variables with differing dimensions (e.g. surface fields on
28+
`(time, latitude, longitude)` and atmospheric fields on
29+
`(time, level, latitude, longitude)`), `from_dataset` splits them into one
30+
table per dimension group, registered together under a SQL schema named after
31+
the first argument. [ARCO-ERA5][arco-era5] is a good example: 262 of its
32+
variables are surface fields and 11 are atmospheric.
33+
34+
Open a year of ARCO-ERA5 and let SQL `WHERE` clauses do the filtering — the
35+
library prunes time partitions and pushes dimension-column filters down. Use
36+
the `table_names` kwarg to give each dimension group a friendly name:
37+
38+
```python
39+
import xarray as xr
40+
import xarray_sql as xql
41+
42+
# Open ARCO-ERA5 directly from GCS (anonymous read).
43+
url = 'gs://gcp-public-data-arco-era5/ar/full_37-1h-0p25deg-chunk-1.zarr-v3'
44+
full = xr.open_zarr(url, chunks=None, storage_options={'token': 'anon'})
45+
46+
# A full year of hourly ERA5 — all 273 variables. No spatial slicing on the
47+
# xarray side; SQL WHERE clauses below express the filters. `chunks={'time': 1}`
48+
# aligns Dask chunks to native Zarr chunks of shape (1, 37, 721, 1440) so
49+
# chunk reads from GCS happen concurrently.
50+
#
51+
# Heads up: 262 of those variables are surface and 11 are atmospheric. The
52+
# library pushes column projection down, so SELECT only fetches what you ask
53+
# for — but `SELECT * FROM era5.surface` would try to pull every variable
54+
# across the year (terabytes from GCS). Always SELECT specific columns.
55+
ds = full.sel(time='2020').chunk({'time': 1})
56+
57+
ctx = xql.XarrayContext()
58+
ctx.from_dataset('era5', ds, table_names={
59+
('time', 'latitude', 'longitude'): 'surface',
60+
('time', 'level', 'latitude', 'longitude'): 'atmosphere',
61+
})
62+
# Registers two tables under a SQL schema named 'era5': 'surface' and 'atmosphere'.
63+
64+
# Average 2m-temperature over the NYC area on the morning of 2020-01-01.
65+
ctx.sql('''
66+
SELECT AVG("2m_temperature") - 273.15 AS avg_c
67+
FROM era5.surface
68+
WHERE time BETWEEN TIMESTAMP '2020-01-01'
69+
AND TIMESTAMP '2020-01-01 05:00:00'
70+
AND latitude BETWEEN 39 AND 40
71+
AND longitude BETWEEN 286 AND 287
72+
''').to_pandas()
73+
74+
# Average temperature per pressure level, globally — the standard
75+
# atmospheric temperature profile. Scans ~230M rows.
76+
ctx.sql('''
77+
SELECT level, AVG(temperature) - 273.15 AS avg_c
78+
FROM era5.atmosphere
79+
WHERE time BETWEEN TIMESTAMP '2020-01-01'
80+
AND TIMESTAMP '2020-01-01 05:00:00'
81+
GROUP BY level
82+
ORDER BY level DESC -- surface (1000 hPa) first
83+
''').to_pandas()
84+
```
85+
86+
If you omit `table_names`, each table is named by joining its dimension names
87+
with underscores, e.g. `era5.time_latitude_longitude` and
88+
`era5.time_level_latitude_longitude`.
89+
90+
A runnable version of this example lives at
91+
[`perf_tests/era5_temp_profile.py`](../perf_tests/era5_temp_profile.py).
92+
93+
[arco-era5]: https://github.com/google-research/arco-era5

perf_tests/era5_temp_profile.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
#!/usr/bin/env python3
2+
"""Surface and global-atmospheric temperatures on 2020-01-01, in SQL.
3+
4+
Two queries against ARCO-ERA5 on the morning of January 1, 2020:
5+
6+
* **Surface (local).** Average 2m-temperature over a small grid covering the
7+
New York City area for the first six hours.
8+
* **Atmosphere (global).** Average temperature per pressure level, computed
9+
over the entire planet for the same six hours — a classic atmospheric
10+
temperature profile (surface around 1000 hPa is warmest, tropopause near
11+
100 hPa is coldest).
12+
13+
Both queries express their filters entirely in SQL: ``xr.open_zarr`` is given
14+
a single calendar year and no spatial slicing. The library's table provider
15+
prunes time partitions for ``WHERE time …`` filters, and pushes ``WHERE
16+
latitude/longitude …`` down to dimension columns.
17+
18+
ARCO-ERA5's atmospheric variables are stored in native Zarr chunks of shape
19+
``(1, 37, 721, 1440)`` — about 150 MB per hour. We align Dask chunks to that
20+
shape with ``chunks={'time': 1}`` so chunks fetch from GCS concurrently. The
21+
global atmospheric query scans ~230M rows after pruning.
22+
23+
The Zarr is read anonymously from the public GCS bucket — no auth required.
24+
"""
25+
26+
import time
27+
28+
import xarray as xr
29+
30+
import xarray_sql as xql
31+
32+
33+
URL = "gs://gcp-public-data-arco-era5/ar/full_37-1h-0p25deg-chunk-1.zarr-v3"
34+
35+
36+
def main() -> None:
37+
full = xr.open_zarr(URL, chunks=None, storage_options={"token": "anon"})
38+
39+
# Open a full calendar year — all 273 variables. No spatial slicing on
40+
# the xarray side; SQL WHERE clauses below express the filters.
41+
#
42+
# Heads up: the library pushes column projection down to Zarr, so SELECT
43+
# only fetches what you ask for — but `SELECT * FROM era5.surface` would
44+
# try to read every variable across the year (terabytes from GCS).
45+
# Always SELECT specific columns.
46+
ds = full.sel(time="2020").chunk({"time": 1})
47+
print(
48+
"ARCO-ERA5 opened: year 2020, "
49+
f"{ds.sizes['time']:,} hourly time steps, "
50+
f"{len(ds.data_vars)} variables (no spatial pre-slicing)."
51+
)
52+
53+
ctx = xql.XarrayContext()
54+
t0 = time.perf_counter()
55+
ctx.from_dataset(
56+
"era5",
57+
ds,
58+
table_names={
59+
("time", "latitude", "longitude"): "surface",
60+
("time", "level", "latitude", "longitude"): "atmosphere",
61+
},
62+
)
63+
print(f"Registration: {time.perf_counter() - t0:.2f}s")
64+
ctx.sql("SELECT 1").to_pandas() # warm the planner
65+
66+
print("\nAverage 2m-temperature over NYC, 2020-01-01 00:00-05:00 UTC (°C):")
67+
t0 = time.perf_counter()
68+
surface = ctx.sql(
69+
"""
70+
SELECT AVG("2m_temperature") - 273.15 AS avg_c
71+
FROM era5.surface
72+
WHERE time BETWEEN TIMESTAMP '2020-01-01'
73+
AND TIMESTAMP '2020-01-01 05:00:00'
74+
AND latitude BETWEEN 39 AND 40
75+
AND longitude BETWEEN 286 AND 287 -- ERA5 uses 0-360 longitudes
76+
"""
77+
).to_pandas()
78+
print(surface)
79+
print(f" ({time.perf_counter() - t0:.2f}s)")
80+
81+
print(
82+
"\nAverage temperature per pressure level, globally, "
83+
"2020-01-01 00:00-05:00 UTC (°C):"
84+
)
85+
t0 = time.perf_counter()
86+
profile = ctx.sql(
87+
"""
88+
SELECT level, AVG(temperature) - 273.15 AS avg_c
89+
FROM era5.atmosphere
90+
WHERE time BETWEEN TIMESTAMP '2020-01-01'
91+
AND TIMESTAMP '2020-01-01 05:00:00'
92+
GROUP BY level
93+
ORDER BY level DESC -- surface (1000 hPa) first, top of atmosphere last
94+
"""
95+
).to_pandas()
96+
print(profile.to_string(index=False))
97+
print(f" ({time.perf_counter() - t0:.2f}s, ~230M rows scanned)")
98+
99+
100+
if __name__ == "__main__":
101+
main()

0 commit comments

Comments
 (0)