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,445 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Literal, Sequence
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
import pandas as pd
|
|
10
|
+
import pyarrow as pa
|
|
11
|
+
import pyarrow.compute as pc
|
|
12
|
+
import pyarrow.parquet as pq
|
|
13
|
+
|
|
14
|
+
MORTON_CODE_2D_COLUMN = "morton_code_2d"
|
|
15
|
+
MORTON_CODE_EXTREME_VALUE_INDICATOR = np.uint32(0)
|
|
16
|
+
MORTON_CODE_BITS_PER_AXIS = 16
|
|
17
|
+
MORTON_CODE_VALUE_MAX = np.uint32((2**MORTON_CODE_BITS_PER_AXIS) - 1)
|
|
18
|
+
MORTON_SENTINEL_COUNT_ATTR = "spatialdata_experimental_morton_sentinel_count"
|
|
19
|
+
#: `DataFrame.attrs` key carrying the resolved {@link ColumnEncodingPlan} back to
|
|
20
|
+
#: the caller, so a benchmark manifest can record what was actually written.
|
|
21
|
+
ENCODING_PLAN_ATTR = "spatialdata_js_util_encoding_plan"
|
|
22
|
+
|
|
23
|
+
#: Bits of Morton key consumed per level of spatial subdivision. Two for the
|
|
24
|
+
#: 2-D key here (a quadtree); a 3-D key would be three (an octree), which is why
|
|
25
|
+
#: coarsening shifts by this rather than by a literal.
|
|
26
|
+
MORTON_BITS_PER_LEVEL = 2
|
|
27
|
+
|
|
28
|
+
#: Transient sort key for coarsened-Morton conditions. Dropped before writing —
|
|
29
|
+
#: it is recoverable from `morton_code_2d >> (MORTON_BITS_PER_LEVEL * levels)`,
|
|
30
|
+
#: so storing it would
|
|
31
|
+
#: put a redundant column on the wire of every tile read.
|
|
32
|
+
MORTON_COARSE_COLUMN = "__morton_coarse__"
|
|
33
|
+
|
|
34
|
+
#: A column keeps dictionary encoding only while its distinct values stay below
|
|
35
|
+
#: this fraction of its length. Above it, RLE_DICTIONARY stores a dictionary
|
|
36
|
+
#: nearly as large as the data plus an index per row, and loses to the plain or
|
|
37
|
+
#: delta encodings — measurably so on transcript coordinates, where the default
|
|
38
|
+
#: made `x`, `y`, `z` and `morton_code_2d` *larger* compressed than raw.
|
|
39
|
+
DICTIONARY_CARDINALITY_RATIO = 0.125
|
|
40
|
+
|
|
41
|
+
EncodingPolicy = Literal["auto", "pyarrow-default"]
|
|
42
|
+
ENCODING_POLICIES: frozenset[str] = frozenset(("auto", "pyarrow-default"))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class ColumnEncodingPlan:
|
|
47
|
+
"""Per-column encoding choices, resolved against the data being written."""
|
|
48
|
+
|
|
49
|
+
#: Columns that keep dictionary encoding. Passed to pyarrow as
|
|
50
|
+
#: `use_dictionary=[...]`, which means *only* these.
|
|
51
|
+
use_dictionary: list[str] = field(default_factory=list)
|
|
52
|
+
#: Explicit encoding for the columns that drop the dictionary.
|
|
53
|
+
column_encoding: dict[str, str] = field(default_factory=dict)
|
|
54
|
+
|
|
55
|
+
def as_manifest(self) -> dict[str, Any]:
|
|
56
|
+
return {
|
|
57
|
+
"use_dictionary": sorted(self.use_dictionary),
|
|
58
|
+
"column_encoding": dict(sorted(self.column_encoding.items())),
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _is_non_decreasing(column: pa.ChunkedArray | pa.Array) -> bool:
|
|
63
|
+
if column.null_count:
|
|
64
|
+
return False
|
|
65
|
+
values = np.asarray(column.to_numpy(zero_copy_only=False))
|
|
66
|
+
if values.size < 2:
|
|
67
|
+
return True
|
|
68
|
+
return bool(np.all(values[1:] >= values[:-1]))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def plan_column_encodings(table: pa.Table) -> ColumnEncodingPlan:
|
|
72
|
+
"""Choose an encoding per column from the data, not from column names.
|
|
73
|
+
|
|
74
|
+
Dictionary encoding is the pyarrow default for every column, and it is the
|
|
75
|
+
right one only for genuinely low-cardinality data. The policy here:
|
|
76
|
+
|
|
77
|
+
* **float** — `BYTE_STREAM_SPLIT`, which groups the mantissa bytes so zstd
|
|
78
|
+
has something to find in coordinates that share an exponent.
|
|
79
|
+
* **integer, low cardinality** — keep the dictionary (`feature_name_codes`,
|
|
80
|
+
`overlaps_nucleus`, and any other small code space).
|
|
81
|
+
* **integer, high cardinality, non-decreasing** — `DELTA_BINARY_PACKED`;
|
|
82
|
+
this is the Morton column and the row index, where successive values
|
|
83
|
+
differ by very little.
|
|
84
|
+
* **integer, high cardinality, unordered** — `PLAIN`. Deltas of random
|
|
85
|
+
identifiers are no smaller than the identifiers (`transcript_id`).
|
|
86
|
+
* **everything else** — unchanged. Strings and categoricals are what the
|
|
87
|
+
dictionary is for.
|
|
88
|
+
"""
|
|
89
|
+
plan_use_dictionary: list[str] = []
|
|
90
|
+
plan_column_encoding: dict[str, str] = {}
|
|
91
|
+
|
|
92
|
+
for name, column_type in zip(table.column_names, table.schema.types):
|
|
93
|
+
column = table.column(name)
|
|
94
|
+
if pa.types.is_floating(column_type):
|
|
95
|
+
plan_column_encoding[name] = "BYTE_STREAM_SPLIT"
|
|
96
|
+
continue
|
|
97
|
+
if pa.types.is_integer(column_type):
|
|
98
|
+
distinct = pc.count_distinct(column).as_py() or 0
|
|
99
|
+
if distinct <= max(1, int(table.num_rows * DICTIONARY_CARDINALITY_RATIO)):
|
|
100
|
+
plan_use_dictionary.append(name)
|
|
101
|
+
continue
|
|
102
|
+
plan_column_encoding[name] = (
|
|
103
|
+
"DELTA_BINARY_PACKED" if _is_non_decreasing(column) else "PLAIN"
|
|
104
|
+
)
|
|
105
|
+
continue
|
|
106
|
+
plan_use_dictionary.append(name)
|
|
107
|
+
|
|
108
|
+
return ColumnEncodingPlan(
|
|
109
|
+
use_dictionary=plan_use_dictionary,
|
|
110
|
+
column_encoding=plan_column_encoding,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _declarable_sorting_columns(
|
|
115
|
+
table: pa.Table, sort_columns: Sequence[str] | None
|
|
116
|
+
) -> list[pq.SortingColumn]:
|
|
117
|
+
"""Declare the leading sort key in the footer — but only if the file honours it.
|
|
118
|
+
|
|
119
|
+
`morton_sort_points` prepends sentinel rows before the sorted body, so a
|
|
120
|
+
file's declared order and its actual order can disagree: on a Morton-primary
|
|
121
|
+
artifact the sentinels carry code 0 and the column still ascends, while on a
|
|
122
|
+
feature-primary one they carry their own feature codes and it does not.
|
|
123
|
+
A reader that trusts a wrong declaration bisects into nonsense, which is the
|
|
124
|
+
failure `docs/plans/points-morton-tiled-viewport-loading.md` records, so this
|
|
125
|
+
verifies before it declares and stays silent when it cannot.
|
|
126
|
+
"""
|
|
127
|
+
if not sort_columns:
|
|
128
|
+
return []
|
|
129
|
+
leading = sort_columns[0]
|
|
130
|
+
if leading not in table.column_names:
|
|
131
|
+
return []
|
|
132
|
+
if not _is_non_decreasing(table.column(leading)):
|
|
133
|
+
return []
|
|
134
|
+
return [pq.SortingColumn(table.column_names.index(leading), descending=False)]
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _norm_series_to_uint(series: pd.Series, v_min: float, v_max: float) -> pd.Series:
|
|
138
|
+
if v_max == v_min:
|
|
139
|
+
return pd.Series(np.zeros(len(series), dtype=np.uint32), index=series.index)
|
|
140
|
+
normalized = (series.astype("float64") - v_min) / (v_max - v_min)
|
|
141
|
+
clipped = normalized.clip(0.0, 1.0).fillna(0.0)
|
|
142
|
+
return (clipped * int(MORTON_CODE_VALUE_MAX)).astype(np.uint32)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _part1by1_16(values: np.ndarray) -> np.ndarray:
|
|
146
|
+
x = values.astype(np.uint32) & np.uint32(0x0000FFFF)
|
|
147
|
+
x = (x | np.left_shift(x, 8)) & np.uint32(0x00FF00FF)
|
|
148
|
+
x = (x | np.left_shift(x, 4)) & np.uint32(0x0F0F0F0F)
|
|
149
|
+
x = (x | np.left_shift(x, 2)) & np.uint32(0x33333333)
|
|
150
|
+
x = (x | np.left_shift(x, 1)) & np.uint32(0x55555555)
|
|
151
|
+
return x
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def morton_code_2d(x_uint: pd.Series, y_uint: pd.Series) -> np.ndarray:
|
|
155
|
+
xs = _part1by1_16(x_uint.to_numpy(np.uint32))
|
|
156
|
+
ys = _part1by1_16(y_uint.to_numpy(np.uint32))
|
|
157
|
+
return (np.left_shift(ys.astype(np.uint64), 1) | xs.astype(np.uint64)).astype(np.uint32)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _extreme_positions(df: pd.DataFrame) -> list[int]:
|
|
161
|
+
extreme_values = [
|
|
162
|
+
("x", df["x"].min()),
|
|
163
|
+
("x", df["x"].max()),
|
|
164
|
+
("y", df["y"].min()),
|
|
165
|
+
("y", df["y"].max()),
|
|
166
|
+
]
|
|
167
|
+
result: list[int] = []
|
|
168
|
+
for column, value in extreme_values:
|
|
169
|
+
matches = np.flatnonzero((df[column] == value).to_numpy())
|
|
170
|
+
if len(matches) == 0:
|
|
171
|
+
continue
|
|
172
|
+
position = int(matches[0])
|
|
173
|
+
if position not in result:
|
|
174
|
+
result.append(position)
|
|
175
|
+
return result
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _append_feature_codes(df: pd.DataFrame, feature_key: str | None) -> pd.DataFrame:
|
|
179
|
+
if not feature_key or feature_key not in df.columns:
|
|
180
|
+
return df
|
|
181
|
+
code_column = f"{feature_key}_codes"
|
|
182
|
+
if code_column in df.columns:
|
|
183
|
+
return df
|
|
184
|
+
out = df.copy()
|
|
185
|
+
values = out[feature_key]
|
|
186
|
+
if isinstance(values.dtype, pd.CategoricalDtype):
|
|
187
|
+
out[code_column] = values.cat.codes.astype("int32")
|
|
188
|
+
else:
|
|
189
|
+
categories = pd.Categorical(values)
|
|
190
|
+
out[code_column] = categories.codes.astype("int32")
|
|
191
|
+
return out
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _move_string_like_columns_right(df: pd.DataFrame) -> pd.DataFrame:
|
|
195
|
+
string_like: list[str] = []
|
|
196
|
+
other: list[str] = []
|
|
197
|
+
for column in df.columns:
|
|
198
|
+
dtype = df[column].dtype
|
|
199
|
+
if isinstance(dtype, pd.CategoricalDtype) or pd.api.types.is_string_dtype(dtype):
|
|
200
|
+
string_like.append(column)
|
|
201
|
+
else:
|
|
202
|
+
other.append(column)
|
|
203
|
+
return df[[*other, *string_like]]
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def morton_sort_points(
|
|
207
|
+
df: pd.DataFrame,
|
|
208
|
+
*,
|
|
209
|
+
feature_key: str | None = None,
|
|
210
|
+
sort_order: Sequence[str] | None = None,
|
|
211
|
+
morton_coarsen_levels: int | None = None,
|
|
212
|
+
) -> pd.DataFrame:
|
|
213
|
+
"""Morton-index the points and sort them, sentinel bounding-box rows first.
|
|
214
|
+
|
|
215
|
+
`morton_coarsen_levels` adds the transient {@link MORTON_COARSE_COLUMN} key,
|
|
216
|
+
`morton_code_2d >> (MORTON_BITS_PER_LEVEL * levels)` — one level of spatial
|
|
217
|
+
subdivision per unit. A `sort_order`
|
|
218
|
+
of `(MORTON_COARSE_COLUMN, feature_codes, MORTON_CODE_2D_COLUMN)` then makes
|
|
219
|
+
same-feature rows contiguous *within* a spatial bucket, which is the shape a
|
|
220
|
+
feature selection needs in order to skip row groups. The column is dropped
|
|
221
|
+
before the frame is returned.
|
|
222
|
+
"""
|
|
223
|
+
missing = [column for column in ("x", "y") if column not in df.columns]
|
|
224
|
+
if missing:
|
|
225
|
+
raise ValueError("Points dataframe is missing required columns: " + ", ".join(missing))
|
|
226
|
+
if morton_coarsen_levels is not None and not 0 < morton_coarsen_levels < MORTON_CODE_BITS_PER_AXIS:
|
|
227
|
+
raise ValueError(
|
|
228
|
+
"morton_coarsen_levels must be between 1 and "
|
|
229
|
+
f"{MORTON_CODE_BITS_PER_AXIS - 1}, got {morton_coarsen_levels}"
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
if morton_coarsen_levels is not None and MORTON_COARSE_COLUMN in df.columns:
|
|
233
|
+
# The name is reserved for the transient sort key, and the drop below cannot
|
|
234
|
+
# tell a caller's column from ours. Refuse rather than silently delete theirs.
|
|
235
|
+
raise ValueError(
|
|
236
|
+
f"Input frame already has a {MORTON_COARSE_COLUMN!r} column, which is "
|
|
237
|
+
"reserved for the coarsened Morton sort key"
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
out = _append_feature_codes(df.copy(), feature_key)
|
|
241
|
+
x_min = float(out["x"].min())
|
|
242
|
+
x_max = float(out["x"].max())
|
|
243
|
+
y_min = float(out["y"].min())
|
|
244
|
+
y_max = float(out["y"].max())
|
|
245
|
+
x_uint = _norm_series_to_uint(out["x"], x_min, x_max)
|
|
246
|
+
y_uint = _norm_series_to_uint(out["y"], y_min, y_max)
|
|
247
|
+
out[MORTON_CODE_2D_COLUMN] = morton_code_2d(x_uint, y_uint)
|
|
248
|
+
if morton_coarsen_levels is not None:
|
|
249
|
+
out[MORTON_COARSE_COLUMN] = np.right_shift(
|
|
250
|
+
out[MORTON_CODE_2D_COLUMN].to_numpy(np.uint32),
|
|
251
|
+
MORTON_BITS_PER_LEVEL * morton_coarsen_levels,
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
sentinel_positions = _extreme_positions(out)
|
|
255
|
+
sentinel = out.iloc[sentinel_positions].copy().reset_index(drop=True)
|
|
256
|
+
sentinel[MORTON_CODE_2D_COLUMN] = MORTON_CODE_EXTREME_VALUE_INDICATOR
|
|
257
|
+
|
|
258
|
+
rest_mask = np.ones(len(out), dtype=bool)
|
|
259
|
+
rest_mask[sentinel_positions] = False
|
|
260
|
+
rest = out.iloc[rest_mask]
|
|
261
|
+
if sort_order is None:
|
|
262
|
+
sort_columns: list[str] = [MORTON_CODE_2D_COLUMN]
|
|
263
|
+
if "z" in rest.columns and rest["z"].nunique(dropna=False) < 100:
|
|
264
|
+
sort_columns = ["z", MORTON_CODE_2D_COLUMN]
|
|
265
|
+
else:
|
|
266
|
+
sort_columns = list(sort_order)
|
|
267
|
+
rest = rest.sort_values(sort_columns, kind="mergesort").reset_index(drop=True)
|
|
268
|
+
|
|
269
|
+
combined = pd.concat([sentinel, rest], ignore_index=True)
|
|
270
|
+
# Only when THIS call added it: with no coarsening requested, a column of that name
|
|
271
|
+
# came from the caller and is theirs to keep.
|
|
272
|
+
if morton_coarsen_levels is not None and MORTON_COARSE_COLUMN in combined.columns:
|
|
273
|
+
combined = combined.drop(columns=[MORTON_COARSE_COLUMN])
|
|
274
|
+
combined = _move_string_like_columns_right(combined)
|
|
275
|
+
combined.attrs[MORTON_SENTINEL_COUNT_ATTR] = len(sentinel)
|
|
276
|
+
return combined
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _write_arrow_table_in_row_groups(
|
|
280
|
+
table: pa.Table,
|
|
281
|
+
output_path: Path,
|
|
282
|
+
*,
|
|
283
|
+
row_group_size: int,
|
|
284
|
+
sentinel_count: int | None = None,
|
|
285
|
+
metadata: dict[str, Any] | None = None,
|
|
286
|
+
compression: str = "zstd",
|
|
287
|
+
encodings: EncodingPolicy = "auto",
|
|
288
|
+
write_page_index: bool = True,
|
|
289
|
+
sort_columns: Sequence[str] | None = None,
|
|
290
|
+
) -> ColumnEncodingPlan | None:
|
|
291
|
+
if row_group_size <= 0:
|
|
292
|
+
raise ValueError("row_group_size must be positive")
|
|
293
|
+
# `EncodingPolicy` is a type hint, which a CLI or TUI string sails straight past. An
|
|
294
|
+
# unrecognised value silently wrote pyarrow defaults and reported them as the tuned
|
|
295
|
+
# plan, so a typo was indistinguishable from the real thing in the manifest.
|
|
296
|
+
if encodings not in ENCODING_POLICIES:
|
|
297
|
+
raise ValueError(
|
|
298
|
+
f"Unknown encoding policy {encodings!r}; expected one of "
|
|
299
|
+
+ ", ".join(sorted(ENCODING_POLICIES))
|
|
300
|
+
)
|
|
301
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
302
|
+
schema = table.schema
|
|
303
|
+
if metadata:
|
|
304
|
+
merged = dict(schema.metadata or {})
|
|
305
|
+
merged[b"spatialdata_multiscale"] = json.dumps(metadata).encode()
|
|
306
|
+
schema = schema.with_metadata(merged)
|
|
307
|
+
|
|
308
|
+
# `None`, not an empty plan: an empty `ColumnEncodingPlan` serialises to
|
|
309
|
+
# `{"use_dictionary": [], "column_encoding": {}}`, which a manifest reader would take
|
|
310
|
+
# to mean "no column uses a dictionary" — the exact opposite of what pyarrow's
|
|
311
|
+
# default does. Absent is the honest record for "we did not choose".
|
|
312
|
+
plan = plan_column_encodings(table) if encodings == "auto" else None
|
|
313
|
+
encoding_options: dict[str, Any] = (
|
|
314
|
+
{
|
|
315
|
+
"use_dictionary": plan.use_dictionary,
|
|
316
|
+
"column_encoding": plan.column_encoding,
|
|
317
|
+
}
|
|
318
|
+
if plan is not None
|
|
319
|
+
else {}
|
|
320
|
+
)
|
|
321
|
+
sorting_columns = _declarable_sorting_columns(table, sort_columns)
|
|
322
|
+
|
|
323
|
+
writer = pq.ParquetWriter(
|
|
324
|
+
output_path,
|
|
325
|
+
schema,
|
|
326
|
+
compression=compression,
|
|
327
|
+
write_statistics=True,
|
|
328
|
+
write_page_index=write_page_index,
|
|
329
|
+
**({"sorting_columns": sorting_columns} if sorting_columns else {}),
|
|
330
|
+
**encoding_options,
|
|
331
|
+
)
|
|
332
|
+
try:
|
|
333
|
+
if sentinel_count is None:
|
|
334
|
+
sentinel_count = 0
|
|
335
|
+
if sentinel_count == 0 and MORTON_CODE_2D_COLUMN in table.column_names:
|
|
336
|
+
morton_column = table.column(MORTON_CODE_2D_COLUMN).combine_chunks()
|
|
337
|
+
for i in range(min(4, table.num_rows)):
|
|
338
|
+
if morton_column[i].as_py() != 0:
|
|
339
|
+
break
|
|
340
|
+
sentinel_count += 1
|
|
341
|
+
if sentinel_count:
|
|
342
|
+
writer.write_table(table.slice(0, sentinel_count), row_group_size=sentinel_count)
|
|
343
|
+
for start in range(sentinel_count, table.num_rows, row_group_size):
|
|
344
|
+
chunk = table.slice(start, min(row_group_size, table.num_rows - start))
|
|
345
|
+
writer.write_table(chunk, row_group_size=chunk.num_rows)
|
|
346
|
+
finally:
|
|
347
|
+
writer.close()
|
|
348
|
+
return plan
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def write_morton_points_parquet(
|
|
352
|
+
df: pd.DataFrame,
|
|
353
|
+
output_path: str | Path,
|
|
354
|
+
*,
|
|
355
|
+
feature_key: str | None = None,
|
|
356
|
+
sort_order: Sequence[str] | None = None,
|
|
357
|
+
row_group_size: int = 50_000,
|
|
358
|
+
compression: str = "zstd",
|
|
359
|
+
encodings: EncodingPolicy = "auto",
|
|
360
|
+
write_page_index: bool = True,
|
|
361
|
+
morton_coarsen_levels: int | None = None,
|
|
362
|
+
) -> pd.DataFrame:
|
|
363
|
+
sorted_df = morton_sort_points(
|
|
364
|
+
df,
|
|
365
|
+
feature_key=feature_key,
|
|
366
|
+
sort_order=sort_order,
|
|
367
|
+
morton_coarsen_levels=morton_coarsen_levels,
|
|
368
|
+
)
|
|
369
|
+
indexed = sorted_df.copy()
|
|
370
|
+
indexed.index.name = "__index_level_0__"
|
|
371
|
+
table = pa.Table.from_pandas(indexed, preserve_index=True)
|
|
372
|
+
sentinel_count = sorted_df.attrs.get(MORTON_SENTINEL_COUNT_ATTR)
|
|
373
|
+
if not isinstance(sentinel_count, int):
|
|
374
|
+
sentinel_count = None
|
|
375
|
+
plan = _write_arrow_table_in_row_groups(
|
|
376
|
+
table,
|
|
377
|
+
Path(output_path),
|
|
378
|
+
row_group_size=row_group_size,
|
|
379
|
+
sentinel_count=sentinel_count,
|
|
380
|
+
compression=compression,
|
|
381
|
+
encodings=encodings,
|
|
382
|
+
write_page_index=write_page_index,
|
|
383
|
+
# A coarsened condition's leading key is dropped before the write, so no
|
|
384
|
+
# column in the file describes the order — `_declarable_sorting_columns`
|
|
385
|
+
# finds nothing and declares nothing, which is the honest answer.
|
|
386
|
+
sort_columns=sort_order or [MORTON_CODE_2D_COLUMN],
|
|
387
|
+
)
|
|
388
|
+
sorted_df.attrs[ENCODING_PLAN_ATTR] = plan
|
|
389
|
+
return sorted_df
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def build_spatialdata_multiscale_metadata(
|
|
393
|
+
df: pd.DataFrame,
|
|
394
|
+
*,
|
|
395
|
+
axes: tuple[str, ...] = ("x", "y", "z"),
|
|
396
|
+
coordinate_space: str = "raw",
|
|
397
|
+
version: str = "1.0",
|
|
398
|
+
levels: list[dict[str, Any]] | None = None,
|
|
399
|
+
limit: int | None = None,
|
|
400
|
+
) -> dict[str, Any]:
|
|
401
|
+
available_axes = [axis for axis in axes if axis in df.columns]
|
|
402
|
+
if not available_axes:
|
|
403
|
+
raise ValueError("No requested coordinate axes are present in the dataframe.")
|
|
404
|
+
return {
|
|
405
|
+
"version": version,
|
|
406
|
+
"format": "spatialdata_multiscale_points",
|
|
407
|
+
"axes": available_axes,
|
|
408
|
+
"bounding_box": {
|
|
409
|
+
"min": [float(df[axis].min()) for axis in available_axes],
|
|
410
|
+
"max": [float(df[axis].max()) for axis in available_axes],
|
|
411
|
+
},
|
|
412
|
+
"coordinate_space": coordinate_space,
|
|
413
|
+
"limit": limit,
|
|
414
|
+
"levels": levels or [],
|
|
415
|
+
"n_points_total": int(len(df)),
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def write_multiscale_points_parquet(
|
|
420
|
+
df: pd.DataFrame,
|
|
421
|
+
output_path: str | Path,
|
|
422
|
+
*,
|
|
423
|
+
metadata: dict[str, Any],
|
|
424
|
+
row_group_size: int = 50_000,
|
|
425
|
+
compression: str = "zstd",
|
|
426
|
+
encodings: EncodingPolicy = "auto",
|
|
427
|
+
write_page_index: bool = True,
|
|
428
|
+
) -> None:
|
|
429
|
+
table = pa.Table.from_pandas(df, preserve_index=False)
|
|
430
|
+
sort_keys: list[tuple[str, str]] = []
|
|
431
|
+
if {"__spatial_index__", "__morton__"}.issubset(df.columns):
|
|
432
|
+
sort_keys = [("__spatial_index__", "ascending"), ("__morton__", "ascending")]
|
|
433
|
+
if "gene" in df.columns:
|
|
434
|
+
sort_keys.insert(0, ("gene", "ascending"))
|
|
435
|
+
table = table.take(pc.sort_indices(table, sort_keys=sort_keys))
|
|
436
|
+
_write_arrow_table_in_row_groups(
|
|
437
|
+
table,
|
|
438
|
+
Path(output_path),
|
|
439
|
+
row_group_size=row_group_size,
|
|
440
|
+
metadata=metadata,
|
|
441
|
+
compression=compression,
|
|
442
|
+
encodings=encodings,
|
|
443
|
+
write_page_index=write_page_index,
|
|
444
|
+
sort_columns=[name for name, _ in sort_keys],
|
|
445
|
+
)
|