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.
Files changed (40) hide show
  1. spatialdata_js_util/__init__.py +107 -0
  2. spatialdata_js_util/cli.py +803 -0
  3. spatialdata_js_util/codecs/__init__.py +67 -0
  4. spatialdata_js_util/codecs/backends.py +250 -0
  5. spatialdata_js_util/codecs/chunks.py +40 -0
  6. spatialdata_js_util/codecs/encoding.py +154 -0
  7. spatialdata_js_util/codecs/htj2k_wasm.py +266 -0
  8. spatialdata_js_util/codecs/names.py +26 -0
  9. spatialdata_js_util/codecs/probe/multicomponent.j2c +0 -0
  10. spatialdata_js_util/codecs/probe/multicomponent.json +3 -0
  11. spatialdata_js_util/codecs/probe/multicomponent.npy +0 -0
  12. spatialdata_js_util/codecs/vendor/__init__.py +1 -0
  13. spatialdata_js_util/codecs/vendor/encode-plane.mjs +164 -0
  14. spatialdata_js_util/codecs/vendor/openjph/index.mjs +131 -0
  15. spatialdata_js_util/codecs/vendor/openjph/wasm/libopenjph.mjs +2 -0
  16. spatialdata_js_util/codecs/vendor/openjph/wasm/libopenjph.wasm +0 -0
  17. spatialdata_js_util/codecs/vendor/openjph/wasm/libopenjph_simd.mjs +2 -0
  18. spatialdata_js_util/codecs/vendor/openjph/wasm/libopenjph_simd.wasm +0 -0
  19. spatialdata_js_util/codecs/zarr_codec.py +149 -0
  20. spatialdata_js_util/errors.py +5 -0
  21. spatialdata_js_util/images.py +849 -0
  22. spatialdata_js_util/index_permutations.py +311 -0
  23. spatialdata_js_util/points.py +445 -0
  24. spatialdata_js_util/points_zarr.py +799 -0
  25. spatialdata_js_util/provenance.py +17 -0
  26. spatialdata_js_util/pyramids.py +318 -0
  27. spatialdata_js_util/runners.py +458 -0
  28. spatialdata_js_util/store.py +305 -0
  29. spatialdata_js_util/tables.py +310 -0
  30. spatialdata_js_util/tui/__init__.py +0 -0
  31. spatialdata_js_util/tui/app.py +112 -0
  32. spatialdata_js_util/tui/models.py +45 -0
  33. spatialdata_js_util/tui/screens.py +1255 -0
  34. spatialdata_js_util/tui/widgets.py +50 -0
  35. spatialdata_js_util/verify.py +338 -0
  36. spatialdata_js_util-0.1.0.dist-info/METADATA +412 -0
  37. spatialdata_js_util-0.1.0.dist-info/RECORD +40 -0
  38. spatialdata_js_util-0.1.0.dist-info/WHEEL +5 -0
  39. spatialdata_js_util-0.1.0.dist-info/entry_points.txt +7 -0
  40. 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