plot3 0.4.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.
- plot3/__init__.py +301 -0
- plot3/__version__.py +1 -0
- plot3/aesexpr.py +271 -0
- plot3/build.py +3948 -0
- plot3/calculus.py +1179 -0
- plot3/compose.py +285 -0
- plot3/contour.py +476 -0
- plot3/craft.py +142 -0
- plot3/encode.py +68 -0
- plot3/expr.py +1557 -0
- plot3/flip.py +245 -0
- plot3/function.py +1301 -0
- plot3/geoms.py +2558 -0
- plot3/ggplot.py +713 -0
- plot3/io.py +76 -0
- plot3/jupyter.py +514 -0
- plot3/latexin.py +616 -0
- plot3/masking.py +494 -0
- plot3/mathtext.py +842 -0
- plot3/payload.py +216 -0
- plot3/remote.py +220 -0
- plot3/scales.py +387 -0
- plot3/scaling.py +636 -0
- plot3/special.py +407 -0
- plot3/stat2d.py +1539 -0
- plot3/static.py +3760 -0
- plot3/stats3d.py +462 -0
- plot3/table.py +775 -0
- plot3/themes.py +104 -0
- plot3/viewer.py +3354 -0
- plot3-0.4.0.dist-info/METADATA +504 -0
- plot3-0.4.0.dist-info/RECORD +35 -0
- plot3-0.4.0.dist-info/WHEEL +5 -0
- plot3-0.4.0.dist-info/licenses/LICENSE +21 -0
- plot3-0.4.0.dist-info/top_level.txt +1 -0
plot3/contour.py
ADDED
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
"""Implicit-curve contours.
|
|
2
|
+
|
|
3
|
+
The NumPy backend is the default. ``contourpy`` is used when it is installed
|
|
4
|
+
and ``PLOT3_CONTOUR_BACKEND`` asks for it. ``import plot3`` does not import
|
|
5
|
+
``contourpy``; the import happens the first time a contour is drawn.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import warnings
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
# One segment per non-saddle case: local edge indices
|
|
16
|
+
# 0 bottom, 1 right, 2 top, 3 left. Saddles (5 and 10) are filled below
|
|
17
|
+
# from the cell-centre value, same pairing as the original cell loop.
|
|
18
|
+
_EDGE_LUT = np.array(
|
|
19
|
+
[
|
|
20
|
+
[-1, -1], # 0
|
|
21
|
+
[0, 3], # 1
|
|
22
|
+
[0, 1], # 2
|
|
23
|
+
[1, 3], # 3
|
|
24
|
+
[1, 2], # 4
|
|
25
|
+
[-1, -1], # 5 saddle
|
|
26
|
+
[0, 2], # 6
|
|
27
|
+
[2, 3], # 7
|
|
28
|
+
[2, 3], # 8
|
|
29
|
+
[0, 2], # 9
|
|
30
|
+
[-1, -1], # 10 saddle
|
|
31
|
+
[1, 2], # 11
|
|
32
|
+
[1, 3], # 12
|
|
33
|
+
[0, 1], # 13
|
|
34
|
+
[0, 3], # 14
|
|
35
|
+
[-1, -1], # 15
|
|
36
|
+
],
|
|
37
|
+
dtype=np.int8,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
# Subdivide each coarse cell the contour crosses. 8× on the default 80-grid
|
|
41
|
+
# keeps a unit circle smooth on (-10, 10). A full-window refit is not used:
|
|
42
|
+
# it cannot refine a small loop that shares the window with a long curve.
|
|
43
|
+
_REFINE_SUB = 8
|
|
44
|
+
_REFINE_NODE_BUDGET = 400_000
|
|
45
|
+
|
|
46
|
+
_contourpy_mod = None
|
|
47
|
+
_contourpy_loaded = False
|
|
48
|
+
_contourpy_warned = False
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _reset_contour_backend() -> None:
|
|
52
|
+
"""Forget the cached contourpy import. Tests use this."""
|
|
53
|
+
global _contourpy_mod, _contourpy_loaded, _contourpy_warned
|
|
54
|
+
_contourpy_mod = None
|
|
55
|
+
_contourpy_loaded = False
|
|
56
|
+
_contourpy_warned = False
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _version_at_least(version: str, major: int, minor: int) -> bool:
|
|
60
|
+
parts: list[int] = []
|
|
61
|
+
for piece in str(version).split("."):
|
|
62
|
+
num = ""
|
|
63
|
+
for ch in piece:
|
|
64
|
+
if ch.isdigit():
|
|
65
|
+
num += ch
|
|
66
|
+
else:
|
|
67
|
+
break
|
|
68
|
+
if not num:
|
|
69
|
+
break
|
|
70
|
+
parts.append(int(num))
|
|
71
|
+
if len(parts) == 2:
|
|
72
|
+
break
|
|
73
|
+
while len(parts) < 2:
|
|
74
|
+
parts.append(0)
|
|
75
|
+
return (parts[0], parts[1]) >= (major, minor)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _load_contourpy():
|
|
79
|
+
"""Return the contourpy module, or None if it is missing or too old."""
|
|
80
|
+
global _contourpy_mod, _contourpy_loaded
|
|
81
|
+
if _contourpy_loaded:
|
|
82
|
+
return _contourpy_mod
|
|
83
|
+
_contourpy_loaded = True
|
|
84
|
+
try:
|
|
85
|
+
import contourpy
|
|
86
|
+
except ImportError:
|
|
87
|
+
_contourpy_mod = None
|
|
88
|
+
return None
|
|
89
|
+
version = getattr(contourpy, "__version__", "0")
|
|
90
|
+
if not _version_at_least(version, 1, 0):
|
|
91
|
+
_contourpy_mod = None
|
|
92
|
+
return None
|
|
93
|
+
_contourpy_mod = contourpy
|
|
94
|
+
return contourpy
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _backend_setting() -> str:
|
|
98
|
+
"""Raw ``PLOT3_CONTOUR_BACKEND`` value: auto, numpy, or contourpy."""
|
|
99
|
+
raw = os.environ.get("PLOT3_CONTOUR_BACKEND", "auto").strip().lower()
|
|
100
|
+
if raw in ("", "auto", "numpy", "contourpy"):
|
|
101
|
+
return raw or "auto"
|
|
102
|
+
raise ValueError(
|
|
103
|
+
"PLOT3_CONTOUR_BACKEND must be auto, numpy, or contourpy, "
|
|
104
|
+
f"got {raw!r}"
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _backend_name() -> str:
|
|
109
|
+
"""Resolved backend name. ``contourpy`` is an error when it is missing."""
|
|
110
|
+
choice = _backend_setting()
|
|
111
|
+
if choice == "numpy":
|
|
112
|
+
return "numpy"
|
|
113
|
+
if choice == "contourpy":
|
|
114
|
+
if _load_contourpy() is None:
|
|
115
|
+
raise ValueError(
|
|
116
|
+
"PLOT3_CONTOUR_BACKEND=contourpy but contourpy>=1.0 is not "
|
|
117
|
+
"installed. Install it with: pip install plot3[fast]"
|
|
118
|
+
)
|
|
119
|
+
return "contourpy"
|
|
120
|
+
if _load_contourpy() is None:
|
|
121
|
+
return "numpy"
|
|
122
|
+
return "contourpy"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _interp_t(a: np.ndarray, b: np.ndarray, level: float) -> np.ndarray:
|
|
126
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
127
|
+
span = b - a
|
|
128
|
+
safe = np.where(span == 0.0, 1.0, span)
|
|
129
|
+
t = np.where(span == 0.0, 0.5, (level - a) / safe)
|
|
130
|
+
return np.clip(t, 0.0, 1.0)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _pair_segments(
|
|
134
|
+
local_ids: np.ndarray,
|
|
135
|
+
cases: np.ndarray,
|
|
136
|
+
center_inside: np.ndarray,
|
|
137
|
+
) -> np.ndarray:
|
|
138
|
+
"""Turn per-cell edge ids and case numbers into segment endpoint pairs."""
|
|
139
|
+
if cases.size == 0:
|
|
140
|
+
return np.zeros((0, 2), dtype=np.int64)
|
|
141
|
+
saddle = (cases == 5) | (cases == 10)
|
|
142
|
+
chunks: list[np.ndarray] = []
|
|
143
|
+
regular = ~saddle
|
|
144
|
+
if np.any(regular):
|
|
145
|
+
lut = _EDGE_LUT[cases[regular]]
|
|
146
|
+
loc = local_ids[regular]
|
|
147
|
+
index = np.arange(loc.shape[0])
|
|
148
|
+
chunks.append(np.stack([loc[index, lut[:, 0]], loc[index, lut[:, 1]]], axis=1))
|
|
149
|
+
if np.any(saddle):
|
|
150
|
+
loc = local_ids[saddle]
|
|
151
|
+
inside = center_inside[saddle]
|
|
152
|
+
if np.any(inside):
|
|
153
|
+
chunks.append(np.stack([loc[inside, 0], loc[inside, 1]], axis=1))
|
|
154
|
+
chunks.append(np.stack([loc[inside, 2], loc[inside, 3]], axis=1))
|
|
155
|
+
outside = ~inside
|
|
156
|
+
if np.any(outside):
|
|
157
|
+
chunks.append(np.stack([loc[outside, 0], loc[outside, 3]], axis=1))
|
|
158
|
+
chunks.append(np.stack([loc[outside, 1], loc[outside, 2]], axis=1))
|
|
159
|
+
if not chunks:
|
|
160
|
+
return np.zeros((0, 2), dtype=np.int64)
|
|
161
|
+
return np.concatenate(chunks, axis=0).astype(np.int64, copy=False)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _chain_edge_pairs(pairs: np.ndarray, point_of) -> list[list[tuple[float, float]]]:
|
|
165
|
+
"""Join segments that share an edge id. The loop touches segments only."""
|
|
166
|
+
nseg = int(pairs.shape[0])
|
|
167
|
+
if nseg == 0:
|
|
168
|
+
return []
|
|
169
|
+
by_edge: dict[int, list[int]] = {}
|
|
170
|
+
for index in range(nseg):
|
|
171
|
+
for edge in (int(pairs[index, 0]), int(pairs[index, 1])):
|
|
172
|
+
by_edge.setdefault(edge, []).append(index)
|
|
173
|
+
used = np.zeros(nseg, dtype=bool)
|
|
174
|
+
|
|
175
|
+
def walk(tip: int) -> list[int]:
|
|
176
|
+
found: list[int] = []
|
|
177
|
+
while True:
|
|
178
|
+
nxt = -1
|
|
179
|
+
for cand in by_edge.get(tip, ()):
|
|
180
|
+
if not used[cand]:
|
|
181
|
+
nxt = cand
|
|
182
|
+
break
|
|
183
|
+
if nxt < 0:
|
|
184
|
+
return found
|
|
185
|
+
used[nxt] = True
|
|
186
|
+
left = int(pairs[nxt, 0])
|
|
187
|
+
right = int(pairs[nxt, 1])
|
|
188
|
+
other = right if left == tip else left
|
|
189
|
+
found.append(other)
|
|
190
|
+
tip = other
|
|
191
|
+
|
|
192
|
+
polylines: list[list[tuple[float, float]]] = []
|
|
193
|
+
for start in range(nseg):
|
|
194
|
+
if used[start]:
|
|
195
|
+
continue
|
|
196
|
+
used[start] = True
|
|
197
|
+
edge0 = int(pairs[start, 0])
|
|
198
|
+
edge1 = int(pairs[start, 1])
|
|
199
|
+
if edge0 == edge1:
|
|
200
|
+
continue
|
|
201
|
+
forward = walk(edge1)
|
|
202
|
+
backward = walk(edge0)
|
|
203
|
+
edges = list(reversed(backward)) + [edge0, edge1] + forward
|
|
204
|
+
polylines.append([point_of(edge) for edge in edges])
|
|
205
|
+
return polylines
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _cell_cases(
|
|
209
|
+
field: np.ndarray, level: float
|
|
210
|
+
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
211
|
+
"""Return ``(case, finite_active, center_inside)`` for each cell."""
|
|
212
|
+
f00 = field[:-1, :-1]
|
|
213
|
+
f10 = field[:-1, 1:]
|
|
214
|
+
f11 = field[1:, 1:]
|
|
215
|
+
f01 = field[1:, :-1]
|
|
216
|
+
finite = (
|
|
217
|
+
np.isfinite(f00) & np.isfinite(f10) & np.isfinite(f11) & np.isfinite(f01)
|
|
218
|
+
)
|
|
219
|
+
case = (
|
|
220
|
+
(f00 >= level).astype(np.uint8)
|
|
221
|
+
| ((f10 >= level).astype(np.uint8) << 1)
|
|
222
|
+
| ((f11 >= level).astype(np.uint8) << 2)
|
|
223
|
+
| ((f01 >= level).astype(np.uint8) << 3)
|
|
224
|
+
)
|
|
225
|
+
active = finite & (case != 0) & (case != 15)
|
|
226
|
+
center_inside = (0.25 * (f00 + f10 + f11 + f01)) >= level
|
|
227
|
+
return case, active, center_inside
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _contour_numpy(
|
|
231
|
+
xs: np.ndarray,
|
|
232
|
+
ys: np.ndarray,
|
|
233
|
+
field: np.ndarray,
|
|
234
|
+
level: float,
|
|
235
|
+
) -> list[list[tuple[float, float]]]:
|
|
236
|
+
"""Marching squares over the whole grid. Cells are classified in NumPy."""
|
|
237
|
+
field = np.asarray(field, dtype=np.float64)
|
|
238
|
+
xs = np.asarray(xs, dtype=np.float64)
|
|
239
|
+
ys = np.asarray(ys, dtype=np.float64)
|
|
240
|
+
ny, nx = field.shape
|
|
241
|
+
if ny < 2 or nx < 2:
|
|
242
|
+
return []
|
|
243
|
+
case, active, center_inside = _cell_cases(field, level)
|
|
244
|
+
if not np.any(active):
|
|
245
|
+
return []
|
|
246
|
+
jj, ii = np.nonzero(active)
|
|
247
|
+
cases = case[jj, ii]
|
|
248
|
+
centers = center_inside[jj, ii]
|
|
249
|
+
|
|
250
|
+
h_count = ny * (nx - 1)
|
|
251
|
+
bottom = jj.astype(np.int64) * (nx - 1) + ii.astype(np.int64)
|
|
252
|
+
top = (jj.astype(np.int64) + 1) * (nx - 1) + ii.astype(np.int64)
|
|
253
|
+
left = h_count + jj.astype(np.int64) * nx + ii.astype(np.int64)
|
|
254
|
+
right = h_count + jj.astype(np.int64) * nx + (ii.astype(np.int64) + 1)
|
|
255
|
+
local = np.stack([bottom, right, top, left], axis=1)
|
|
256
|
+
pairs = _pair_segments(local, cases, centers)
|
|
257
|
+
if pairs.size == 0:
|
|
258
|
+
return []
|
|
259
|
+
|
|
260
|
+
h_a = field[:, :-1]
|
|
261
|
+
h_b = field[:, 1:]
|
|
262
|
+
h_t = _interp_t(h_a, h_b, level)
|
|
263
|
+
h_x = xs[:-1][None, :] + h_t * (xs[1:] - xs[:-1])[None, :]
|
|
264
|
+
h_y = np.broadcast_to(ys[:, None], h_x.shape)
|
|
265
|
+
v_a = field[:-1, :]
|
|
266
|
+
v_b = field[1:, :]
|
|
267
|
+
v_t = _interp_t(v_a, v_b, level)
|
|
268
|
+
v_y = ys[:-1][:, None] + v_t * (ys[1:] - ys[:-1])[:, None]
|
|
269
|
+
v_x = np.broadcast_to(xs[None, :], v_y.shape)
|
|
270
|
+
point_x = np.concatenate([np.ravel(h_x), np.ravel(v_x)])
|
|
271
|
+
point_y = np.concatenate([np.ravel(h_y), np.ravel(v_y)])
|
|
272
|
+
|
|
273
|
+
def point_of(edge: int) -> tuple[float, float]:
|
|
274
|
+
return (float(point_x[edge]), float(point_y[edge]))
|
|
275
|
+
|
|
276
|
+
return _chain_edge_pairs(pairs, point_of)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _as_polyline(line: np.ndarray) -> list[tuple[float, float]] | None:
|
|
280
|
+
arr = np.asarray(line, dtype=np.float64)
|
|
281
|
+
if arr.ndim != 2 or arr.shape[0] < 2 or arr.shape[1] < 2:
|
|
282
|
+
return None
|
|
283
|
+
points = [(float(row[0]), float(row[1])) for row in arr]
|
|
284
|
+
if len(points) >= 3:
|
|
285
|
+
x0, y0 = points[0]
|
|
286
|
+
x1, y1 = points[-1]
|
|
287
|
+
# Closed loops repeat the first point. Snap a numeric near-close too.
|
|
288
|
+
if abs(x0 - x1) <= 1e-9 and abs(y0 - y1) <= 1e-9:
|
|
289
|
+
points[-1] = points[0]
|
|
290
|
+
return points
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _contour_contourpy(
|
|
294
|
+
xs: np.ndarray,
|
|
295
|
+
ys: np.ndarray,
|
|
296
|
+
field: np.ndarray,
|
|
297
|
+
level: float,
|
|
298
|
+
) -> list[list[tuple[float, float]]]:
|
|
299
|
+
contourpy = _load_contourpy()
|
|
300
|
+
if contourpy is None:
|
|
301
|
+
raise ValueError(
|
|
302
|
+
"PLOT3_CONTOUR_BACKEND=contourpy but contourpy>=1.0 is not "
|
|
303
|
+
"installed. Install it with: pip install plot3[fast]"
|
|
304
|
+
)
|
|
305
|
+
masked = np.ma.masked_invalid(np.asarray(field, dtype=np.float64))
|
|
306
|
+
generator = contourpy.contour_generator(
|
|
307
|
+
x=np.asarray(xs, dtype=np.float64),
|
|
308
|
+
y=np.asarray(ys, dtype=np.float64),
|
|
309
|
+
z=masked,
|
|
310
|
+
line_type=contourpy.LineType.Separate,
|
|
311
|
+
)
|
|
312
|
+
polylines: list[list[tuple[float, float]]] = []
|
|
313
|
+
for line in generator.lines(float(level)):
|
|
314
|
+
poly = _as_polyline(line)
|
|
315
|
+
if poly is not None:
|
|
316
|
+
polylines.append(poly)
|
|
317
|
+
return polylines
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _warn_contourpy_fallback(exc: BaseException) -> None:
|
|
321
|
+
global _contourpy_warned
|
|
322
|
+
if _contourpy_warned:
|
|
323
|
+
return
|
|
324
|
+
_contourpy_warned = True
|
|
325
|
+
warnings.warn(
|
|
326
|
+
f"plot3: contourpy failed ({exc}), using the NumPy contour backend",
|
|
327
|
+
UserWarning,
|
|
328
|
+
stacklevel=4,
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _contour_lines(
|
|
333
|
+
xs: np.ndarray,
|
|
334
|
+
ys: np.ndarray,
|
|
335
|
+
field: np.ndarray,
|
|
336
|
+
level: float = 0.0,
|
|
337
|
+
) -> list[list[tuple[float, float]]]:
|
|
338
|
+
"""Contour ``field`` at ``level``. Backend comes from the environment."""
|
|
339
|
+
choice = _backend_setting()
|
|
340
|
+
if choice == "numpy" or (choice == "auto" and _load_contourpy() is None):
|
|
341
|
+
return _contour_numpy(xs, ys, field, level)
|
|
342
|
+
try:
|
|
343
|
+
return _contour_contourpy(xs, ys, field, level)
|
|
344
|
+
except Exception as exc:
|
|
345
|
+
if choice == "contourpy":
|
|
346
|
+
raise
|
|
347
|
+
_warn_contourpy_fallback(exc)
|
|
348
|
+
return _contour_numpy(xs, ys, field, level)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _refine_active_cells(
|
|
352
|
+
xs: np.ndarray,
|
|
353
|
+
ys: np.ndarray,
|
|
354
|
+
field: np.ndarray,
|
|
355
|
+
level: float,
|
|
356
|
+
sample,
|
|
357
|
+
sub: int = _REFINE_SUB,
|
|
358
|
+
) -> list[list[tuple[float, float]]] | None:
|
|
359
|
+
"""Re-sample only the cells the contour crosses, on a finer sub-grid.
|
|
360
|
+
|
|
361
|
+
Returns None when the fine grid would be too large; the caller keeps the
|
|
362
|
+
coarse contour, which is already a correct curve.
|
|
363
|
+
"""
|
|
364
|
+
field = np.asarray(field, dtype=np.float64)
|
|
365
|
+
xs = np.asarray(xs, dtype=np.float64)
|
|
366
|
+
ys = np.asarray(ys, dtype=np.float64)
|
|
367
|
+
_case, active, _center = _cell_cases(field, level)
|
|
368
|
+
if not np.any(active):
|
|
369
|
+
return []
|
|
370
|
+
jj, ii = np.nonzero(active)
|
|
371
|
+
sub = int(sub)
|
|
372
|
+
nodes = sub + 1
|
|
373
|
+
if len(ii) * nodes * nodes > _REFINE_NODE_BUDGET:
|
|
374
|
+
sub = 4
|
|
375
|
+
nodes = sub + 1
|
|
376
|
+
if len(ii) * nodes * nodes > _REFINE_NODE_BUDGET:
|
|
377
|
+
return None
|
|
378
|
+
step = np.linspace(0.0, 1.0, nodes)
|
|
379
|
+
x0 = xs[ii]
|
|
380
|
+
x1 = xs[ii + 1]
|
|
381
|
+
y0 = ys[jj]
|
|
382
|
+
y1 = ys[jj + 1]
|
|
383
|
+
xf = x0[:, None] + (x1 - x0)[:, None] * step
|
|
384
|
+
yf = y0[:, None] + (y1 - y0)[:, None] * step
|
|
385
|
+
xx = np.empty((len(ii), nodes, nodes), dtype=np.float64)
|
|
386
|
+
yy = np.empty((len(ii), nodes, nodes), dtype=np.float64)
|
|
387
|
+
xx[:] = xf[:, None, :]
|
|
388
|
+
yy[:] = yf[:, :, None]
|
|
389
|
+
fine = np.asarray(sample(xx, yy), dtype=np.float64)
|
|
390
|
+
if fine.shape != xx.shape:
|
|
391
|
+
fine = np.broadcast_to(fine, xx.shape).astype(np.float64, copy=False)
|
|
392
|
+
return _contour_refined(ii, jj, xs, ys, fine, xf, yf, level, sub)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _contour_refined(
|
|
396
|
+
ii: np.ndarray,
|
|
397
|
+
jj: np.ndarray,
|
|
398
|
+
xs: np.ndarray,
|
|
399
|
+
ys: np.ndarray,
|
|
400
|
+
fine: np.ndarray,
|
|
401
|
+
xf: np.ndarray,
|
|
402
|
+
yf: np.ndarray,
|
|
403
|
+
level: float,
|
|
404
|
+
sub: int,
|
|
405
|
+
) -> list[list[tuple[float, float]]]:
|
|
406
|
+
"""Marching squares on a batch of refined cells, chained by global edge id."""
|
|
407
|
+
f00 = fine[:, :-1, :-1]
|
|
408
|
+
f10 = fine[:, :-1, 1:]
|
|
409
|
+
f11 = fine[:, 1:, 1:]
|
|
410
|
+
f01 = fine[:, 1:, :-1]
|
|
411
|
+
finite = (
|
|
412
|
+
np.isfinite(f00) & np.isfinite(f10) & np.isfinite(f11) & np.isfinite(f01)
|
|
413
|
+
)
|
|
414
|
+
case = (
|
|
415
|
+
(f00 >= level).astype(np.uint8)
|
|
416
|
+
| ((f10 >= level).astype(np.uint8) << 1)
|
|
417
|
+
| ((f11 >= level).astype(np.uint8) << 2)
|
|
418
|
+
| ((f01 >= level).astype(np.uint8) << 3)
|
|
419
|
+
)
|
|
420
|
+
active = finite & (case != 0) & (case != 15)
|
|
421
|
+
if not np.any(active):
|
|
422
|
+
return []
|
|
423
|
+
aj, lj, li = np.nonzero(active)
|
|
424
|
+
cases = case[aj, lj, li]
|
|
425
|
+
center_inside = (0.25 * (f00 + f10 + f11 + f01))[aj, lj, li]
|
|
426
|
+
gi = ii[aj].astype(np.int64) * sub + li.astype(np.int64)
|
|
427
|
+
gj = jj[aj].astype(np.int64) * sub + lj.astype(np.int64)
|
|
428
|
+
nx = int(xs.shape[0])
|
|
429
|
+
ny = int(ys.shape[0])
|
|
430
|
+
h_stride = (nx - 1) * sub
|
|
431
|
+
v_stride = (nx - 1) * sub + 1
|
|
432
|
+
n_horizontal = h_stride * ((ny - 1) * sub)
|
|
433
|
+
bottom = gj * h_stride + gi
|
|
434
|
+
top = (gj + 1) * h_stride + gi
|
|
435
|
+
left = n_horizontal + gj * v_stride + gi
|
|
436
|
+
right = n_horizontal + gj * v_stride + (gi + 1)
|
|
437
|
+
local = np.stack([bottom, right, top, left], axis=1)
|
|
438
|
+
pairs = _pair_segments(local, cases, center_inside)
|
|
439
|
+
if pairs.size == 0:
|
|
440
|
+
return []
|
|
441
|
+
|
|
442
|
+
points: dict[int, tuple[float, float]] = {}
|
|
443
|
+
|
|
444
|
+
def put(edge_ids: np.ndarray, x_vals: np.ndarray, y_vals: np.ndarray) -> None:
|
|
445
|
+
for edge, x_val, y_val in zip(edge_ids.tolist(), x_vals.tolist(), y_vals.tolist()):
|
|
446
|
+
points[int(edge)] = (float(x_val), float(y_val))
|
|
447
|
+
|
|
448
|
+
t = _interp_t(fine[aj, lj, li], fine[aj, lj, li + 1], level)
|
|
449
|
+
put(
|
|
450
|
+
bottom,
|
|
451
|
+
xf[aj, li] + t * (xf[aj, li + 1] - xf[aj, li]),
|
|
452
|
+
yf[aj, lj],
|
|
453
|
+
)
|
|
454
|
+
t = _interp_t(fine[aj, lj, li + 1], fine[aj, lj + 1, li + 1], level)
|
|
455
|
+
put(
|
|
456
|
+
right,
|
|
457
|
+
xf[aj, li + 1],
|
|
458
|
+
yf[aj, lj] + t * (yf[aj, lj + 1] - yf[aj, lj]),
|
|
459
|
+
)
|
|
460
|
+
t = _interp_t(fine[aj, lj + 1, li], fine[aj, lj + 1, li + 1], level)
|
|
461
|
+
put(
|
|
462
|
+
top,
|
|
463
|
+
xf[aj, li] + t * (xf[aj, li + 1] - xf[aj, li]),
|
|
464
|
+
yf[aj, lj + 1],
|
|
465
|
+
)
|
|
466
|
+
t = _interp_t(fine[aj, lj, li], fine[aj, lj + 1, li], level)
|
|
467
|
+
put(
|
|
468
|
+
left,
|
|
469
|
+
xf[aj, li],
|
|
470
|
+
yf[aj, lj] + t * (yf[aj, lj + 1] - yf[aj, lj]),
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
def point_of(edge: int) -> tuple[float, float]:
|
|
474
|
+
return points[edge]
|
|
475
|
+
|
|
476
|
+
return _chain_edge_pairs(pairs, point_of)
|
plot3/craft.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""CRAFT remote seeding — ship the local plot3 source to the remote kernel.
|
|
2
|
+
|
|
3
|
+
Mirrors ``tidy3.craft``: tar the package, bootstrap on the remote via
|
|
4
|
+
``remote_run_``, install pandas if needed, register the plot3 IPython
|
|
5
|
+
extension. Idempotent via content stamp.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
import hashlib
|
|
12
|
+
import io
|
|
13
|
+
import tarfile
|
|
14
|
+
import time
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Callable
|
|
17
|
+
|
|
18
|
+
__all__ = ["build_payload", "bootstrap_code", "seed"]
|
|
19
|
+
|
|
20
|
+
_OK_PREFIX = "plot3 remote: OK"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _pkg_files() -> list[tuple[str, bytes]]:
|
|
24
|
+
pkg = Path(__file__).resolve().parent
|
|
25
|
+
files: list[tuple[str, bytes]] = []
|
|
26
|
+
for path in sorted(pkg.rglob("*")):
|
|
27
|
+
if not path.is_file():
|
|
28
|
+
continue
|
|
29
|
+
if path.suffix not in {".py", ".typed"} and path.name != "py.typed":
|
|
30
|
+
continue
|
|
31
|
+
if "__pycache__" in path.parts:
|
|
32
|
+
continue
|
|
33
|
+
rel = path.relative_to(pkg.parent).as_posix() # plot3/...
|
|
34
|
+
files.append((rel, path.read_bytes()))
|
|
35
|
+
return files
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def build_payload() -> tuple[str, str]:
|
|
39
|
+
"""Return (base64 tar.gz of the package, content stamp)."""
|
|
40
|
+
from plot3 import __version__
|
|
41
|
+
|
|
42
|
+
files = _pkg_files()
|
|
43
|
+
h = hashlib.sha256()
|
|
44
|
+
for name, data in files:
|
|
45
|
+
h.update(name.encode())
|
|
46
|
+
h.update(b"\0")
|
|
47
|
+
h.update(data)
|
|
48
|
+
h.update(b"\0")
|
|
49
|
+
stamp = f"{__version__}-{h.hexdigest()[:16]}"
|
|
50
|
+
|
|
51
|
+
buf = io.BytesIO()
|
|
52
|
+
now = int(time.time())
|
|
53
|
+
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
|
|
54
|
+
for name, data in files:
|
|
55
|
+
ti = tarfile.TarInfo(name)
|
|
56
|
+
ti.size = len(data)
|
|
57
|
+
ti.mtime = now
|
|
58
|
+
tar.addfile(ti, io.BytesIO(data))
|
|
59
|
+
return base64.b64encode(buf.getvalue()).decode("ascii"), stamp
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
_BOOTSTRAP = r'''
|
|
63
|
+
import base64 as _b64, io as _io, sys as _sys, tarfile as _tarfile
|
|
64
|
+
from pathlib import Path as _Path
|
|
65
|
+
_root = _Path.home() / ".plot3-src"
|
|
66
|
+
_stampf = _root / ".stamp"
|
|
67
|
+
_stamp = "%(stamp)s"
|
|
68
|
+
try:
|
|
69
|
+
_fresh = _stampf.read_text().strip() == _stamp
|
|
70
|
+
except Exception:
|
|
71
|
+
_fresh = False
|
|
72
|
+
if not _fresh:
|
|
73
|
+
import shutil as _shutil
|
|
74
|
+
_root.mkdir(parents=True, exist_ok=True)
|
|
75
|
+
_shutil.rmtree(_root / "plot3", ignore_errors=True)
|
|
76
|
+
_buf = _io.BytesIO(_b64.b64decode("%(payload)s"))
|
|
77
|
+
with _tarfile.open(fileobj=_buf, mode="r:gz") as _tar:
|
|
78
|
+
try:
|
|
79
|
+
_tar.extractall(_root, filter="data")
|
|
80
|
+
except TypeError:
|
|
81
|
+
_tar.extractall(_root)
|
|
82
|
+
_stampf.write_text(_stamp)
|
|
83
|
+
if str(_root) not in _sys.path:
|
|
84
|
+
_sys.path.insert(0, str(_root))
|
|
85
|
+
try:
|
|
86
|
+
import pandas as _pd # noqa: F401
|
|
87
|
+
except Exception:
|
|
88
|
+
import subprocess as _sp
|
|
89
|
+
print("plot3 remote: installing pandas (first time only)...", flush=True)
|
|
90
|
+
_r = _sp.run(["uv", "pip", "install", "pandas"], capture_output=True, text=True)
|
|
91
|
+
if _r.returncode != 0:
|
|
92
|
+
_r = _sp.run([_sys.executable, "-m", "pip", "install", "pandas"],
|
|
93
|
+
capture_output=True, text=True)
|
|
94
|
+
if _r.returncode != 0:
|
|
95
|
+
print((_r.stdout or "")[-600:])
|
|
96
|
+
print((_r.stderr or "")[-600:])
|
|
97
|
+
raise RuntimeError("plot3 seed: pandas install failed")
|
|
98
|
+
import importlib as _il
|
|
99
|
+
_il.invalidate_caches()
|
|
100
|
+
if not _fresh and "plot3" in _sys.modules:
|
|
101
|
+
for _k in [_m for _m in list(_sys.modules)
|
|
102
|
+
if _m == "plot3" or _m.startswith("plot3.")]:
|
|
103
|
+
del _sys.modules[_k]
|
|
104
|
+
import plot3 as _p3
|
|
105
|
+
from IPython import get_ipython as _gi
|
|
106
|
+
_ip = _gi()
|
|
107
|
+
if _ip is not None:
|
|
108
|
+
from plot3.jupyter import register_plot3 as _reg
|
|
109
|
+
_reg(quiet=True, r_style=True)
|
|
110
|
+
try:
|
|
111
|
+
from plot3.jupyter import enable_r_style as _rstyle
|
|
112
|
+
_rstyle(_ip)
|
|
113
|
+
except Exception as _e:
|
|
114
|
+
print("plot3 remote: R-style masking warning: " + repr(_e), flush=True)
|
|
115
|
+
print("plot3 remote: OK v" + _p3.__version__ + " (" + _stamp + ")")
|
|
116
|
+
'''
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def bootstrap_code(payload: str, stamp: str) -> str:
|
|
120
|
+
return _BOOTSTRAP % {"payload": payload, "stamp": stamp}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def seed(
|
|
124
|
+
remote_run: Callable[..., str],
|
|
125
|
+
*,
|
|
126
|
+
payload: str | None = None,
|
|
127
|
+
stamp: str | None = None,
|
|
128
|
+
max_chars: int = 12000,
|
|
129
|
+
) -> tuple[bool, str]:
|
|
130
|
+
"""Run the bootstrap via CRAFT's ``remote_run_``. Returns (ok, message)."""
|
|
131
|
+
if payload is None or stamp is None:
|
|
132
|
+
payload, stamp = build_payload()
|
|
133
|
+
code = bootstrap_code(payload, stamp)
|
|
134
|
+
try:
|
|
135
|
+
out = remote_run(code, max_chars=max_chars) or ""
|
|
136
|
+
except Exception as e:
|
|
137
|
+
return False, f"remote bootstrap did not run: {e}"
|
|
138
|
+
out = out.strip()
|
|
139
|
+
for line in out.splitlines():
|
|
140
|
+
if line.startswith(_OK_PREFIX):
|
|
141
|
+
return True, line.strip()
|
|
142
|
+
return False, (out[-1500:] if out else "no output from remote bootstrap")
|
plot3/encode.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Payload packing (uint16 quantize, delta, gzip)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import gzip as _gzip
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
def delta_u16(a: np.ndarray) -> np.ndarray:
|
|
11
|
+
"""Column-wise delta mod 2^16 (lossless; tiny values on ordered data)."""
|
|
12
|
+
d = a.astype(np.int32)
|
|
13
|
+
d[1:] = (d[1:] - d[:-1]) % 65536
|
|
14
|
+
return d.astype("<u2")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def pack_u16(q: np.ndarray, compress: bool) -> str:
|
|
18
|
+
"""uint16 array -> base64; compressed = delta + byte-plane shuffle + gzip."""
|
|
19
|
+
if compress:
|
|
20
|
+
d = delta_u16(q).view(np.uint8).reshape(-1, 2)
|
|
21
|
+
raw = _gzip.compress(
|
|
22
|
+
np.ascontiguousarray(d[:, 0]).tobytes()
|
|
23
|
+
+ np.ascontiguousarray(d[:, 1]).tobytes(),
|
|
24
|
+
6,
|
|
25
|
+
)
|
|
26
|
+
else:
|
|
27
|
+
raw = np.ascontiguousarray(q, dtype="<u2").tobytes()
|
|
28
|
+
return base64.b64encode(raw).decode("ascii")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def pack_f32(v: np.ndarray, compress: bool) -> str:
|
|
32
|
+
raw = np.ascontiguousarray(v, dtype="<f4").tobytes()
|
|
33
|
+
if compress:
|
|
34
|
+
raw = _gzip.compress(raw, 6)
|
|
35
|
+
return base64.b64encode(raw).decode("ascii")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def encode_norm(v: np.ndarray, lo: float, hi: float, *, quantize: bool,
|
|
39
|
+
compress: bool) -> dict:
|
|
40
|
+
"""Encode values normalized to [0,1] over [lo,hi] (u16 or f32)."""
|
|
41
|
+
span = (hi - lo) or 1.0
|
|
42
|
+
t = (np.asarray(v, dtype=np.float64) - lo) / span
|
|
43
|
+
if quantize:
|
|
44
|
+
q = np.round(np.clip(t, 0.0, 1.0) * 65535.0).astype("<u2")
|
|
45
|
+
return {"dtype": "u16", "b64": pack_u16(q, compress)}
|
|
46
|
+
return {"dtype": "f32", "b64": pack_f32(t.astype(np.float32), compress)}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def encode_codes(codes: np.ndarray, compress: bool) -> dict:
|
|
50
|
+
q = np.ascontiguousarray(codes, dtype="<u2")
|
|
51
|
+
return {"dtype": "u16", "b64": pack_u16(q, compress), "raw": True}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def pack_u8(values: np.ndarray, compress: bool) -> str:
|
|
55
|
+
"""uint8 array -> base64 (optional gzip; no delta)."""
|
|
56
|
+
raw = np.ascontiguousarray(values, dtype=np.uint8).tobytes()
|
|
57
|
+
if compress:
|
|
58
|
+
raw = _gzip.compress(raw, 6)
|
|
59
|
+
return base64.b64encode(raw).decode("ascii")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def pack_u32(values: np.ndarray, compress: bool) -> str:
|
|
63
|
+
"""uint32 array -> base64 (optional gzip; no delta)."""
|
|
64
|
+
raw = np.ascontiguousarray(values, dtype="<u4").tobytes()
|
|
65
|
+
if compress:
|
|
66
|
+
raw = _gzip.compress(raw, 6)
|
|
67
|
+
return base64.b64encode(raw).decode("ascii")
|
|
68
|
+
|