webxtile 0.0.1__tar.gz
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.
- webxtile-0.0.1/PKG-INFO +17 -0
- webxtile-0.0.1/pyproject.toml +27 -0
- webxtile-0.0.1/setup.cfg +4 -0
- webxtile-0.0.1/tests/test_js.py +294 -0
- webxtile-0.0.1/tests/test_roundtrip.py +553 -0
- webxtile-0.0.1/webxtile/__init__.py +872 -0
- webxtile-0.0.1/webxtile.egg-info/PKG-INFO +17 -0
- webxtile-0.0.1/webxtile.egg-info/SOURCES.txt +10 -0
- webxtile-0.0.1/webxtile.egg-info/dependency_links.txt +1 -0
- webxtile-0.0.1/webxtile.egg-info/entry_points.txt +2 -0
- webxtile-0.0.1/webxtile.egg-info/requires.txt +12 -0
- webxtile-0.0.1/webxtile.egg-info/top_level.txt +1 -0
webxtile-0.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: webxtile
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: 3D geographic grid octree storage with msgpack tiles; full xarray/CF roundtrip
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.9
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: xarray>=0.18
|
|
9
|
+
Requires-Dist: numpy
|
|
10
|
+
Requires-Dist: msgpack>=1.0
|
|
11
|
+
Requires-Dist: msgpack-numpy>=0.4.7
|
|
12
|
+
Requires-Dist: scipy
|
|
13
|
+
Provides-Extra: projection
|
|
14
|
+
Requires-Dist: pyproj; extra == "projection"
|
|
15
|
+
Provides-Extra: dev
|
|
16
|
+
Requires-Dist: pytest; extra == "dev"
|
|
17
|
+
Requires-Dist: numpy; extra == "dev"
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "webxtile"
|
|
7
|
+
version = "0.0.1"
|
|
8
|
+
description = "3D geographic grid octree storage with msgpack tiles; full xarray/CF roundtrip"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
dependencies = [
|
|
13
|
+
"xarray>=0.18",
|
|
14
|
+
"numpy",
|
|
15
|
+
"msgpack>=1.0",
|
|
16
|
+
"msgpack-numpy>=0.4.7",
|
|
17
|
+
"scipy",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.optional-dependencies]
|
|
21
|
+
projection = ["pyproj"]
|
|
22
|
+
dev = ["pytest", "numpy"]
|
|
23
|
+
|
|
24
|
+
# ── xarray engine registration ────────────────────────────────────────────────
|
|
25
|
+
# Allows: xr.open_dataset("tiles/", engine="webxtile")
|
|
26
|
+
[project.entry-points."xarray.backends"]
|
|
27
|
+
webxtile = "webxtile:WebxtileBackend"
|
webxtile-0.0.1/setup.cfg
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"""Cross-language roundtrip tests: Python writes → JavaScript reads.
|
|
2
|
+
|
|
3
|
+
For each scenario the test:
|
|
4
|
+
1. Writes a webxtile directory with Python's write_webxtile().
|
|
5
|
+
2. Invokes the Node.js helper (js/tests/read_tiles.mjs) to read the same
|
|
6
|
+
directory, optionally with a bbox / level filter.
|
|
7
|
+
3. Compares the coordinates and data values reported by JavaScript against
|
|
8
|
+
those returned by Python's read_webxtile().
|
|
9
|
+
|
|
10
|
+
Both sides round floats to 4 decimal places before comparison, which is
|
|
11
|
+
appropriate for float32 data (≈7 significant decimal digits).
|
|
12
|
+
"""
|
|
13
|
+
import json
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
import tempfile
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
import pytest
|
|
21
|
+
import xarray as xr
|
|
22
|
+
|
|
23
|
+
from webxtile import write_webxtile, read_webxtile
|
|
24
|
+
|
|
25
|
+
# ─── Paths ───────────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
# Repository root → deps/webxtile/js/tests/read_tiles.mjs
|
|
28
|
+
_THIS_DIR = Path(__file__).parent
|
|
29
|
+
_JS_SCRIPT = _THIS_DIR.parent.parent / "js" / "tests" / "read_tiles.mjs"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ─── Dataset factories ────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
def make_2d_dataset(nx=64, ny=48):
|
|
35
|
+
x = np.linspace(0.0, 100.0, nx)
|
|
36
|
+
y = np.linspace(0.0, 50.0, ny)
|
|
37
|
+
xx, yy = np.meshgrid(x, y, indexing="ij")
|
|
38
|
+
temperature = (xx + yy * 0.5).astype(np.float32)
|
|
39
|
+
precipitation = (np.sin(xx / 20.0) * np.cos(yy / 10.0)).astype(np.float32)
|
|
40
|
+
return xr.Dataset(
|
|
41
|
+
{
|
|
42
|
+
"temperature": (["x", "y"], temperature),
|
|
43
|
+
"precipitation": (["x", "y"], precipitation),
|
|
44
|
+
},
|
|
45
|
+
coords={"x": x, "y": y},
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def make_3d_dataset(nx=32, ny=24, nz=16):
|
|
50
|
+
x = np.linspace(0.0, 100.0, nx)
|
|
51
|
+
y = np.linspace(0.0, 50.0, ny)
|
|
52
|
+
z = np.linspace(0.0, 20.0, nz)
|
|
53
|
+
xx, yy, zz = np.meshgrid(x, y, z, indexing="ij")
|
|
54
|
+
values = (xx + yy * 0.5 + zz * 0.1).astype(np.float32)
|
|
55
|
+
return xr.Dataset(
|
|
56
|
+
{"temperature": (["x", "y", "z"], values)},
|
|
57
|
+
coords={"x": x, "y": y, "z": z},
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
def _run_js(tiles_dir, bbox=None, level=None):
|
|
64
|
+
"""Call the Node.js helper and return the parsed JSON output."""
|
|
65
|
+
bbox_arg = json.dumps(bbox) if bbox is not None else "null"
|
|
66
|
+
level_arg = str(level) if level is not None else "null"
|
|
67
|
+
result = subprocess.run(
|
|
68
|
+
["node", str(_JS_SCRIPT), str(tiles_dir), bbox_arg, level_arg],
|
|
69
|
+
capture_output=True,
|
|
70
|
+
text=True,
|
|
71
|
+
check=False,
|
|
72
|
+
cwd=_JS_SCRIPT.parent.parent, # js/ dir so node_modules are found
|
|
73
|
+
)
|
|
74
|
+
if result.returncode != 0:
|
|
75
|
+
raise RuntimeError(
|
|
76
|
+
f"Node.js script failed (exit {result.returncode}):\n"
|
|
77
|
+
f"stdout: {result.stdout[:2000]}\n"
|
|
78
|
+
f"stderr: {result.stderr[:2000]}"
|
|
79
|
+
)
|
|
80
|
+
return json.loads(result.stdout)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _py_grid(ds, spatial_dims):
|
|
84
|
+
"""Return coords and rounded variable arrays from a Python Dataset.
|
|
85
|
+
|
|
86
|
+
Returns a dict mirroring the JS output:
|
|
87
|
+
{"coords": {dim: [...]}, "variables": {var: [[...][...]]}}
|
|
88
|
+
"""
|
|
89
|
+
coords = {d: [round(float(v), 6) for v in ds[d].values] for d in spatial_dims}
|
|
90
|
+
variables = {}
|
|
91
|
+
for vname in ds.data_vars:
|
|
92
|
+
arr = ds[vname].values.astype(np.float32)
|
|
93
|
+
if arr.ndim == 2:
|
|
94
|
+
variables[vname] = [
|
|
95
|
+
[round(float(v), 4) for v in row] for row in arr
|
|
96
|
+
]
|
|
97
|
+
else:
|
|
98
|
+
# 3-D: flatten to a list of lists of lists
|
|
99
|
+
variables[vname] = [
|
|
100
|
+
[[round(float(v), 4) for v in plane] for plane in mat]
|
|
101
|
+
for mat in arr
|
|
102
|
+
]
|
|
103
|
+
return {"coords": coords, "variables": variables}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _compare(js_out, py_out, label=""):
|
|
107
|
+
"""Assert that JS and Python produce the same coordinates and variable values."""
|
|
108
|
+
for dim, py_vals in py_out["coords"].items():
|
|
109
|
+
js_vals = js_out["coords"].get(dim, [])
|
|
110
|
+
np.testing.assert_allclose(
|
|
111
|
+
js_vals, py_vals, atol=1e-4,
|
|
112
|
+
err_msg=f"{label}: coord '{dim}' mismatch",
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
for vname, py_grid in py_out["variables"].items():
|
|
116
|
+
js_grid = js_out["variables"].get(vname)
|
|
117
|
+
assert js_grid is not None, f"{label}: JS missing variable '{vname}'"
|
|
118
|
+
py_arr = np.array(py_grid, dtype=np.float32)
|
|
119
|
+
js_arr = np.array(js_grid, dtype=np.float32)
|
|
120
|
+
# Allow small tolerance for float32 round-trip through JSON
|
|
121
|
+
# (NaN positions should match)
|
|
122
|
+
py_valid = ~np.isnan(py_arr)
|
|
123
|
+
js_valid = ~np.isnan(js_arr)
|
|
124
|
+
np.testing.assert_array_equal(
|
|
125
|
+
py_valid, js_valid,
|
|
126
|
+
err_msg=f"{label}: '{vname}' NaN mask differs between Python and JS",
|
|
127
|
+
)
|
|
128
|
+
np.testing.assert_allclose(
|
|
129
|
+
js_arr[js_valid], py_arr[py_valid], atol=5e-3,
|
|
130
|
+
err_msg=f"{label}: '{vname}' values differ between Python and JS",
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# ─── 2-D cross-language tests ─────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
def test_js_full_resolution_2d():
|
|
137
|
+
"""JS full-resolution read must match Python full-resolution read."""
|
|
138
|
+
ds = make_2d_dataset()
|
|
139
|
+
with tempfile.TemporaryDirectory() as d:
|
|
140
|
+
write_webxtile(ds, d, spatial_dims=["x", "y"], max_leaf=16)
|
|
141
|
+
|
|
142
|
+
py_ds = read_webxtile(d)
|
|
143
|
+
js_out = _run_js(d)
|
|
144
|
+
|
|
145
|
+
_compare(js_out, _py_grid(py_ds, ["x", "y"]), label="full-res 2D")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def test_js_bbox_centre_2d():
|
|
149
|
+
"""JS bbox read must match Python bbox read for the central sub-region."""
|
|
150
|
+
ds = make_2d_dataset()
|
|
151
|
+
with tempfile.TemporaryDirectory() as d:
|
|
152
|
+
write_webxtile(ds, d, spatial_dims=["x", "y"], max_leaf=16)
|
|
153
|
+
|
|
154
|
+
bbox = [25.0, 12.5, 75.0, 37.5]
|
|
155
|
+
py_ds = read_webxtile(d, bbox=bbox)
|
|
156
|
+
js_out = _run_js(d, bbox=bbox)
|
|
157
|
+
|
|
158
|
+
_compare(js_out, _py_grid(py_ds, ["x", "y"]), label="bbox-centre 2D")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def test_js_bbox_left_strip_2d():
|
|
162
|
+
"""JS and Python must agree on a narrow left strip of the domain."""
|
|
163
|
+
ds = make_2d_dataset()
|
|
164
|
+
with tempfile.TemporaryDirectory() as d:
|
|
165
|
+
write_webxtile(ds, d, spatial_dims=["x", "y"], max_leaf=16)
|
|
166
|
+
|
|
167
|
+
bbox = [0.0, 0.0, 30.0, 50.0]
|
|
168
|
+
py_ds = read_webxtile(d, bbox=bbox)
|
|
169
|
+
js_out = _run_js(d, bbox=bbox)
|
|
170
|
+
|
|
171
|
+
_compare(js_out, _py_grid(py_ds, ["x", "y"]), label="bbox-left-strip 2D")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def test_js_bbox_top_right_2d():
|
|
175
|
+
"""JS and Python must agree on the top-right quadrant."""
|
|
176
|
+
ds = make_2d_dataset()
|
|
177
|
+
with tempfile.TemporaryDirectory() as d:
|
|
178
|
+
write_webxtile(ds, d, spatial_dims=["x", "y"], max_leaf=16)
|
|
179
|
+
|
|
180
|
+
bbox = [50.0, 25.0, 100.0, 50.0]
|
|
181
|
+
py_ds = read_webxtile(d, bbox=bbox)
|
|
182
|
+
js_out = _run_js(d, bbox=bbox)
|
|
183
|
+
|
|
184
|
+
_compare(js_out, _py_grid(py_ds, ["x", "y"]), label="bbox-top-right 2D")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def test_js_level_0_2d():
|
|
188
|
+
"""JS and Python must return the same coarse root-tile overview."""
|
|
189
|
+
ds = make_2d_dataset()
|
|
190
|
+
with tempfile.TemporaryDirectory() as d:
|
|
191
|
+
write_webxtile(ds, d, spatial_dims=["x", "y"], max_leaf=8)
|
|
192
|
+
|
|
193
|
+
py_ds = read_webxtile(d, level=0)
|
|
194
|
+
js_out = _run_js(d, level=0)
|
|
195
|
+
|
|
196
|
+
_compare(js_out, _py_grid(py_ds, ["x", "y"]), label="level-0 2D")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def test_js_level_1_2d():
|
|
200
|
+
"""JS and Python must match at level 1 (one octree level below root)."""
|
|
201
|
+
ds = make_2d_dataset()
|
|
202
|
+
with tempfile.TemporaryDirectory() as d:
|
|
203
|
+
write_webxtile(ds, d, spatial_dims=["x", "y"], max_leaf=8)
|
|
204
|
+
|
|
205
|
+
py_ds = read_webxtile(d, level=1)
|
|
206
|
+
js_out = _run_js(d, level=1)
|
|
207
|
+
|
|
208
|
+
_compare(js_out, _py_grid(py_ds, ["x", "y"]), label="level-1 2D")
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def test_js_level_2_2d():
|
|
212
|
+
"""JS and Python must match at level 2."""
|
|
213
|
+
ds = make_2d_dataset()
|
|
214
|
+
with tempfile.TemporaryDirectory() as d:
|
|
215
|
+
write_webxtile(ds, d, spatial_dims=["x", "y"], max_leaf=8)
|
|
216
|
+
|
|
217
|
+
py_ds = read_webxtile(d, level=2)
|
|
218
|
+
js_out = _run_js(d, level=2)
|
|
219
|
+
|
|
220
|
+
_compare(js_out, _py_grid(py_ds, ["x", "y"]), label="level-2 2D")
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def test_js_bbox_and_level_combined_2d():
|
|
224
|
+
"""Combining bbox + level=1 must give matching results in JS and Python."""
|
|
225
|
+
ds = make_2d_dataset()
|
|
226
|
+
with tempfile.TemporaryDirectory() as d:
|
|
227
|
+
write_webxtile(ds, d, spatial_dims=["x", "y"], max_leaf=8)
|
|
228
|
+
|
|
229
|
+
bbox = [10.0, 5.0, 90.0, 45.0]
|
|
230
|
+
py_ds = read_webxtile(d, bbox=bbox, level=1)
|
|
231
|
+
js_out = _run_js(d, bbox=bbox, level=1)
|
|
232
|
+
|
|
233
|
+
_compare(js_out, _py_grid(py_ds, ["x", "y"]), label="bbox+level-1 2D")
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
# ─── 3-D cross-language tests ─────────────────────────────────────────────────
|
|
237
|
+
|
|
238
|
+
def _compare_3d_coords(js_out, py_ds, label=""):
|
|
239
|
+
"""For 3-D datasets, just compare the sorted unique coordinate values."""
|
|
240
|
+
for dim in ["x", "y", "z"]:
|
|
241
|
+
py_vals = sorted(float(v) for v in py_ds[dim].values)
|
|
242
|
+
js_vals = js_out["coords"].get(dim, [])
|
|
243
|
+
np.testing.assert_allclose(
|
|
244
|
+
sorted(js_vals), py_vals, atol=1e-4,
|
|
245
|
+
err_msg=f"{label}: 3D coord '{dim}' mismatch",
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def test_js_full_resolution_3d():
|
|
250
|
+
"""JS full-resolution 3-D read must return the same coordinates as Python."""
|
|
251
|
+
ds = make_3d_dataset()
|
|
252
|
+
with tempfile.TemporaryDirectory() as d:
|
|
253
|
+
write_webxtile(ds, d, spatial_dims=["x", "y", "z"], max_leaf=8)
|
|
254
|
+
|
|
255
|
+
py_ds = read_webxtile(d)
|
|
256
|
+
js_out = _run_js(d)
|
|
257
|
+
|
|
258
|
+
_compare_3d_coords(js_out, py_ds, label="full-res 3D")
|
|
259
|
+
|
|
260
|
+
# Also check that scatter count matches
|
|
261
|
+
expected_count = len(py_ds["x"]) * len(py_ds["y"]) * len(py_ds["z"])
|
|
262
|
+
assert js_out["count"] == expected_count, (
|
|
263
|
+
f"3D scatter count mismatch: JS={js_out['count']}, "
|
|
264
|
+
f"Python={expected_count}"
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def test_js_level_0_3d():
|
|
269
|
+
"""JS and Python must return the same number of points at level 0 (3-D)."""
|
|
270
|
+
ds = make_3d_dataset()
|
|
271
|
+
with tempfile.TemporaryDirectory() as d:
|
|
272
|
+
write_webxtile(ds, d, spatial_dims=["x", "y", "z"], max_leaf=4)
|
|
273
|
+
|
|
274
|
+
py_ds = read_webxtile(d, level=0)
|
|
275
|
+
js_out = _run_js(d, level=0)
|
|
276
|
+
|
|
277
|
+
py_count = len(py_ds["x"]) * len(py_ds["y"]) * len(py_ds["z"])
|
|
278
|
+
assert js_out["count"] == py_count, (
|
|
279
|
+
f"3D level-0 count: JS={js_out['count']}, Python={py_count}"
|
|
280
|
+
)
|
|
281
|
+
_compare_3d_coords(js_out, py_ds, label="level-0 3D")
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def test_js_bbox_3d():
|
|
285
|
+
"""JS and Python bbox reads must return the same coordinates for a 3-D sub-volume."""
|
|
286
|
+
ds = make_3d_dataset()
|
|
287
|
+
with tempfile.TemporaryDirectory() as d:
|
|
288
|
+
write_webxtile(ds, d, spatial_dims=["x", "y", "z"], max_leaf=8)
|
|
289
|
+
|
|
290
|
+
bbox = [20.0, 10.0, 5.0, 80.0, 40.0, 15.0]
|
|
291
|
+
py_ds = read_webxtile(d, bbox=bbox)
|
|
292
|
+
js_out = _run_js(d, bbox=bbox)
|
|
293
|
+
|
|
294
|
+
_compare_3d_coords(js_out, py_ds, label="bbox 3D")
|