spatialdata-js-util 0.1.0__py3-none-any.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.
- spatialdata_js_util/__init__.py +107 -0
- spatialdata_js_util/cli.py +803 -0
- spatialdata_js_util/codecs/__init__.py +67 -0
- spatialdata_js_util/codecs/backends.py +250 -0
- spatialdata_js_util/codecs/chunks.py +40 -0
- spatialdata_js_util/codecs/encoding.py +154 -0
- spatialdata_js_util/codecs/htj2k_wasm.py +266 -0
- spatialdata_js_util/codecs/names.py +26 -0
- spatialdata_js_util/codecs/probe/multicomponent.j2c +0 -0
- spatialdata_js_util/codecs/probe/multicomponent.json +3 -0
- spatialdata_js_util/codecs/probe/multicomponent.npy +0 -0
- spatialdata_js_util/codecs/vendor/__init__.py +1 -0
- spatialdata_js_util/codecs/vendor/encode-plane.mjs +164 -0
- spatialdata_js_util/codecs/vendor/openjph/index.mjs +131 -0
- spatialdata_js_util/codecs/vendor/openjph/wasm/libopenjph.mjs +2 -0
- spatialdata_js_util/codecs/vendor/openjph/wasm/libopenjph.wasm +0 -0
- spatialdata_js_util/codecs/vendor/openjph/wasm/libopenjph_simd.mjs +2 -0
- spatialdata_js_util/codecs/vendor/openjph/wasm/libopenjph_simd.wasm +0 -0
- spatialdata_js_util/codecs/zarr_codec.py +149 -0
- spatialdata_js_util/errors.py +5 -0
- spatialdata_js_util/images.py +849 -0
- spatialdata_js_util/index_permutations.py +311 -0
- spatialdata_js_util/points.py +445 -0
- spatialdata_js_util/points_zarr.py +799 -0
- spatialdata_js_util/provenance.py +17 -0
- spatialdata_js_util/pyramids.py +318 -0
- spatialdata_js_util/runners.py +458 -0
- spatialdata_js_util/store.py +305 -0
- spatialdata_js_util/tables.py +310 -0
- spatialdata_js_util/tui/__init__.py +0 -0
- spatialdata_js_util/tui/app.py +112 -0
- spatialdata_js_util/tui/models.py +45 -0
- spatialdata_js_util/tui/screens.py +1255 -0
- spatialdata_js_util/tui/widgets.py +50 -0
- spatialdata_js_util/verify.py +338 -0
- spatialdata_js_util-0.1.0.dist-info/METADATA +412 -0
- spatialdata_js_util-0.1.0.dist-info/RECORD +40 -0
- spatialdata_js_util-0.1.0.dist-info/WHEEL +5 -0
- spatialdata_js_util-0.1.0.dist-info/entry_points.txt +7 -0
- spatialdata_js_util-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import shutil
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Sequence
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
from .points import (
|
|
11
|
+
ENCODING_PLAN_ATTR,
|
|
12
|
+
MORTON_CODE_2D_COLUMN,
|
|
13
|
+
MORTON_COARSE_COLUMN,
|
|
14
|
+
ColumnEncodingPlan,
|
|
15
|
+
EncodingPolicy,
|
|
16
|
+
write_morton_points_parquet,
|
|
17
|
+
)
|
|
18
|
+
from .store import (
|
|
19
|
+
list_points_keys,
|
|
20
|
+
points_parquet_path,
|
|
21
|
+
read_points_dataframe,
|
|
22
|
+
read_points_element_attrs,
|
|
23
|
+
register_points_elements_in_consolidated_metadata,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class IndexCondition:
|
|
29
|
+
id: str
|
|
30
|
+
element_suffix: str
|
|
31
|
+
sort_order: tuple[str, ...] | None
|
|
32
|
+
tiling_kind: str | None
|
|
33
|
+
#: Row-group size for this condition, overriding the call's default.
|
|
34
|
+
#: **This is the feature-selectivity knob.** A reader skips row groups, never
|
|
35
|
+
#: pages — parquet-wasm exposes no column-chunk offsets and cannot decode a
|
|
36
|
+
#: relocated subset of a row group (`docs/parquet-wasm-limitations.md`) — so
|
|
37
|
+
#: how nearly single-feature a row group is decides what a feature selection
|
|
38
|
+
#: can avoid fetching. At 50k rows and 541 features it can avoid nothing.
|
|
39
|
+
row_group_size: int | None = None
|
|
40
|
+
#: Quadtree levels of Morton coarsening for the leading sort key. See
|
|
41
|
+
#: {@link spatialdata_js_util.points.morton_sort_points}.
|
|
42
|
+
morton_coarsen_levels: int | None = None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
DEFAULT_CONDITIONS: tuple[IndexCondition, ...] = (
|
|
46
|
+
IndexCondition("canonical", "", None, None),
|
|
47
|
+
IndexCondition("morton", "_morton", (MORTON_CODE_2D_COLUMN,), "morton-points"),
|
|
48
|
+
IndexCondition(
|
|
49
|
+
"morton-then-feature",
|
|
50
|
+
"_morton_then_feature",
|
|
51
|
+
(MORTON_CODE_2D_COLUMN, "feature_name_codes"),
|
|
52
|
+
"morton-points",
|
|
53
|
+
),
|
|
54
|
+
IndexCondition(
|
|
55
|
+
"feature-then-morton",
|
|
56
|
+
"_feature_then_morton",
|
|
57
|
+
("feature_name_codes", MORTON_CODE_2D_COLUMN),
|
|
58
|
+
"experimental",
|
|
59
|
+
),
|
|
60
|
+
)
|
|
61
|
+
"""The four landed conditions. `morton-then-feature` is measurably degenerate —
|
|
62
|
+
Morton is 16 bits per axis, so at 12.17M points only ~0.14% of rows share a code
|
|
63
|
+
and the secondary key is almost never consulted. It is kept because the store's
|
|
64
|
+
element keys are a published contract and because it is the worked example of a
|
|
65
|
+
*harmless* secondary key (`points-morton-tiled-viewport-loading.md`)."""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _coarsened_condition(levels: int, row_group_size: int) -> IndexCondition:
|
|
69
|
+
return IndexCondition(
|
|
70
|
+
id=f"morton-k{levels}-then-feature-rg{row_group_size}",
|
|
71
|
+
element_suffix=f"_morton_k{levels}_then_feature_rg{row_group_size}",
|
|
72
|
+
sort_order=(MORTON_COARSE_COLUMN, "feature_name_codes", MORTON_CODE_2D_COLUMN),
|
|
73
|
+
tiling_kind="experimental",
|
|
74
|
+
row_group_size=row_group_size,
|
|
75
|
+
morton_coarsen_levels=levels,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
FEATURE_SELECTIVITY_CONDITIONS: tuple[IndexCondition, ...] = (
|
|
80
|
+
IndexCondition(
|
|
81
|
+
"feature-then-morton-rg5000",
|
|
82
|
+
"_feature_then_morton_rg5000",
|
|
83
|
+
("feature_name_codes", MORTON_CODE_2D_COLUMN),
|
|
84
|
+
"experimental",
|
|
85
|
+
row_group_size=5_000,
|
|
86
|
+
),
|
|
87
|
+
_coarsened_condition(levels=4, row_group_size=5_000),
|
|
88
|
+
_coarsened_condition(levels=6, row_group_size=5_000),
|
|
89
|
+
_coarsened_condition(levels=6, row_group_size=25_000),
|
|
90
|
+
)
|
|
91
|
+
"""The sweep decision 11's cost model needs in order to have a crossover to find.
|
|
92
|
+
|
|
93
|
+
Two axes, deliberately crossed rather than chosen: how coarse the spatial bucket
|
|
94
|
+
is (`levels`) and how many rows a row group holds. Coarsening trades spatial
|
|
95
|
+
selectivity for feature selectivity — at `levels=6` a bucket is 4096 leaf cells
|
|
96
|
+
wide — and the row-group size decides whether that feature contiguity is fine
|
|
97
|
+
enough for the reader to act on. Both ends matter and neither is guessable, which
|
|
98
|
+
is why these are written and measured rather than reasoned about."""
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _resolve_feature_code_column(feature_key: str | None) -> str:
|
|
102
|
+
if feature_key:
|
|
103
|
+
return f"{feature_key}_codes"
|
|
104
|
+
return "feature_name_codes"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _condition_sort_order(
|
|
108
|
+
condition: IndexCondition, feature_key: str | None
|
|
109
|
+
) -> list[str] | None:
|
|
110
|
+
if condition.sort_order is None:
|
|
111
|
+
return None
|
|
112
|
+
feature_code_column = _resolve_feature_code_column(feature_key)
|
|
113
|
+
return [
|
|
114
|
+
feature_code_column if column == "feature_name_codes" else column
|
|
115
|
+
for column in condition.sort_order
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _copy_store_shell(source: Path, dest: Path, *, overwrite: bool) -> None:
|
|
120
|
+
if dest.exists():
|
|
121
|
+
if not overwrite:
|
|
122
|
+
raise FileExistsError(f"Destination already exists: {dest}")
|
|
123
|
+
shutil.rmtree(dest)
|
|
124
|
+
|
|
125
|
+
def ignore_points(directory: str, names: list[str]) -> set[str]:
|
|
126
|
+
if Path(directory) == source:
|
|
127
|
+
return {"points"} if "points" in names else set()
|
|
128
|
+
return set()
|
|
129
|
+
|
|
130
|
+
shutil.copytree(source, dest, ignore=ignore_points)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _write_element_zarr_json(source_element_dir: Path, dest_element_dir: Path) -> None:
|
|
134
|
+
source_json = source_element_dir / "zarr.json"
|
|
135
|
+
dest_element_dir.mkdir(parents=True, exist_ok=True)
|
|
136
|
+
shutil.copy2(source_json, dest_element_dir / "zarr.json")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _write_points_collection_zarr_json(source_path: Path, dest_path: Path) -> None:
|
|
140
|
+
"""Recreate the `points/` group metadata that `_copy_store_shell` skipped.
|
|
141
|
+
|
|
142
|
+
Without it the collection exists as a directory with no node metadata, so its
|
|
143
|
+
elements are listed in consolidated metadata under a parent that is not — an
|
|
144
|
+
orphan that stops the whole store from opening once anything rebuilds that
|
|
145
|
+
metadata from disk.
|
|
146
|
+
"""
|
|
147
|
+
dest_json = dest_path / "points" / "zarr.json"
|
|
148
|
+
if dest_json.is_file():
|
|
149
|
+
return
|
|
150
|
+
dest_json.parent.mkdir(parents=True, exist_ok=True)
|
|
151
|
+
source_json = source_path / "points" / "zarr.json"
|
|
152
|
+
if source_json.is_file():
|
|
153
|
+
shutil.copy2(source_json, dest_json)
|
|
154
|
+
return
|
|
155
|
+
dest_json.write_text(
|
|
156
|
+
json.dumps({"attributes": {}, "zarr_format": 3, "node_type": "group"}, indent=2) + "\n"
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _copy_canonical_parquet(source_parquet: Path, dest_parquet: Path) -> None:
|
|
161
|
+
dest_parquet.parent.mkdir(parents=True, exist_ok=True)
|
|
162
|
+
if source_parquet.is_dir():
|
|
163
|
+
if dest_parquet.exists():
|
|
164
|
+
shutil.rmtree(dest_parquet)
|
|
165
|
+
shutil.copytree(source_parquet, dest_parquet)
|
|
166
|
+
else:
|
|
167
|
+
shutil.copy2(source_parquet, dest_parquet)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def write_index_permutations(
|
|
171
|
+
source_zarr: str | Path,
|
|
172
|
+
dest_zarr: str | Path,
|
|
173
|
+
*,
|
|
174
|
+
points_key: str | None = None,
|
|
175
|
+
max_rows: int | None = None,
|
|
176
|
+
conditions: Sequence[IndexCondition] | None = None,
|
|
177
|
+
overwrite: bool = False,
|
|
178
|
+
row_group_size: int = 50_000,
|
|
179
|
+
compression: str = "zstd",
|
|
180
|
+
encodings: EncodingPolicy = "auto",
|
|
181
|
+
write_page_index: bool = True,
|
|
182
|
+
) -> dict[str, Any]:
|
|
183
|
+
source_path = Path(source_zarr)
|
|
184
|
+
dest_path = Path(dest_zarr)
|
|
185
|
+
keys = list_points_keys(source_path)
|
|
186
|
+
if not keys:
|
|
187
|
+
raise FileNotFoundError(f"No Points elements found under {source_path / 'points'}")
|
|
188
|
+
|
|
189
|
+
resolved_key = points_key or (keys[0] if len(keys) == 1 else None)
|
|
190
|
+
if resolved_key is None:
|
|
191
|
+
raise ValueError(
|
|
192
|
+
"Multiple Points elements found; pass points_key. "
|
|
193
|
+
f"Available keys: {', '.join(keys)}"
|
|
194
|
+
)
|
|
195
|
+
if resolved_key not in keys:
|
|
196
|
+
raise ValueError(f"Unknown points key {resolved_key!r}. Available: {', '.join(keys)}")
|
|
197
|
+
|
|
198
|
+
attrs = read_points_element_attrs(source_path, resolved_key)
|
|
199
|
+
feature_key = attrs.get("feature_key")
|
|
200
|
+
source_element_dir = source_path / "points" / resolved_key
|
|
201
|
+
source_parquet = points_parquet_path(source_path, resolved_key)
|
|
202
|
+
|
|
203
|
+
_copy_store_shell(source_path, dest_path, overwrite=overwrite)
|
|
204
|
+
_write_points_collection_zarr_json(source_path, dest_path)
|
|
205
|
+
|
|
206
|
+
df = read_points_dataframe(source_parquet)
|
|
207
|
+
if max_rows is not None and len(df) > max_rows:
|
|
208
|
+
df = df.sample(n=max_rows, random_state=0).reset_index(drop=True)
|
|
209
|
+
|
|
210
|
+
selected = tuple(conditions or DEFAULT_CONDITIONS)
|
|
211
|
+
manifest_conditions: list[dict[str, Any]] = []
|
|
212
|
+
|
|
213
|
+
for condition in selected:
|
|
214
|
+
element_key = (
|
|
215
|
+
resolved_key if condition.id == "canonical" else f"{resolved_key}{condition.element_suffix}"
|
|
216
|
+
)
|
|
217
|
+
element_dir = dest_path / "points" / element_key
|
|
218
|
+
output_parquet = element_dir / "points.parquet"
|
|
219
|
+
_write_element_zarr_json(source_element_dir, element_dir)
|
|
220
|
+
|
|
221
|
+
if condition.sort_order is None:
|
|
222
|
+
if max_rows is not None:
|
|
223
|
+
output_parquet.parent.mkdir(parents=True, exist_ok=True)
|
|
224
|
+
if output_parquet.exists():
|
|
225
|
+
if output_parquet.is_dir():
|
|
226
|
+
shutil.rmtree(output_parquet)
|
|
227
|
+
else:
|
|
228
|
+
output_parquet.unlink()
|
|
229
|
+
df.to_parquet(output_parquet, index=False)
|
|
230
|
+
else:
|
|
231
|
+
_copy_canonical_parquet(source_parquet, output_parquet)
|
|
232
|
+
condition_row_group_size = None
|
|
233
|
+
encoding_plan = None
|
|
234
|
+
else:
|
|
235
|
+
sort_order = _condition_sort_order(condition, feature_key)
|
|
236
|
+
# `is None`, not `or`: an explicit 0 is a caller error and belongs in
|
|
237
|
+
# the writer's "row_group_size must be positive", not silently replaced
|
|
238
|
+
# by the default.
|
|
239
|
+
condition_row_group_size = (
|
|
240
|
+
row_group_size
|
|
241
|
+
if condition.row_group_size is None
|
|
242
|
+
else condition.row_group_size
|
|
243
|
+
)
|
|
244
|
+
written = write_morton_points_parquet(
|
|
245
|
+
df,
|
|
246
|
+
output_parquet,
|
|
247
|
+
feature_key=feature_key,
|
|
248
|
+
sort_order=sort_order,
|
|
249
|
+
row_group_size=condition_row_group_size,
|
|
250
|
+
compression=compression,
|
|
251
|
+
encodings=encodings,
|
|
252
|
+
write_page_index=write_page_index,
|
|
253
|
+
morton_coarsen_levels=condition.morton_coarsen_levels,
|
|
254
|
+
)
|
|
255
|
+
plan = written.attrs.get(ENCODING_PLAN_ATTR)
|
|
256
|
+
encoding_plan = plan.as_manifest() if isinstance(plan, ColumnEncodingPlan) else None
|
|
257
|
+
|
|
258
|
+
manifest_conditions.append(
|
|
259
|
+
{
|
|
260
|
+
"id": condition.id,
|
|
261
|
+
"element_path": f"points/{element_key}",
|
|
262
|
+
"sort_order": list(condition.sort_order) if condition.sort_order else None,
|
|
263
|
+
"tiling_kind": condition.tiling_kind,
|
|
264
|
+
"row_group_size": condition_row_group_size,
|
|
265
|
+
"morton_coarsen_levels": condition.morton_coarsen_levels,
|
|
266
|
+
# The policy NAME as well as the resolved plan: under
|
|
267
|
+
# `pyarrow-default` there is no plan to record, and without the name a
|
|
268
|
+
# reader cannot tell an old-encoding baseline from a tuned artifact.
|
|
269
|
+
"encoding_policy": encodings if condition.sort_order else None,
|
|
270
|
+
"encodings": encoding_plan,
|
|
271
|
+
"page_index": write_page_index if condition.sort_order else None,
|
|
272
|
+
}
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
manifest = {
|
|
276
|
+
"version": "0.1",
|
|
277
|
+
"store_path": str(dest_path),
|
|
278
|
+
"source_store": str(source_path),
|
|
279
|
+
"source_element": f"points/{resolved_key}",
|
|
280
|
+
"feature_key": feature_key,
|
|
281
|
+
"n_points": int(len(df)),
|
|
282
|
+
"conditions": manifest_conditions,
|
|
283
|
+
"benchmark_scenarios": [
|
|
284
|
+
{
|
|
285
|
+
"id": "center-tile",
|
|
286
|
+
"bounds": {
|
|
287
|
+
"minX": float(df["x"].quantile(0.25)),
|
|
288
|
+
"maxX": float(df["x"].quantile(0.75)),
|
|
289
|
+
"minY": float(df["y"].quantile(0.25)),
|
|
290
|
+
"maxY": float(df["y"].quantile(0.75)),
|
|
291
|
+
},
|
|
292
|
+
}
|
|
293
|
+
],
|
|
294
|
+
}
|
|
295
|
+
element_keys = [
|
|
296
|
+
(
|
|
297
|
+
resolved_key
|
|
298
|
+
if condition.id == "canonical"
|
|
299
|
+
else f"{resolved_key}{condition.element_suffix}"
|
|
300
|
+
)
|
|
301
|
+
for condition in selected
|
|
302
|
+
]
|
|
303
|
+
register_points_elements_in_consolidated_metadata(
|
|
304
|
+
dest_path,
|
|
305
|
+
element_keys,
|
|
306
|
+
template_key=resolved_key,
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
manifest_path = dest_path / "index-manifest.json"
|
|
310
|
+
manifest_path.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n")
|
|
311
|
+
return manifest
|