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/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
+