plot3 0.6.8__tar.gz → 0.8.0__tar.gz

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 (84) hide show
  1. {plot3-0.6.8 → plot3-0.8.0}/PKG-INFO +2 -1
  2. plot3-0.8.0/plot3/__version__.py +1 -0
  3. {plot3-0.6.8 → plot3-0.8.0}/plot3/build.py +39 -16
  4. {plot3-0.6.8 → plot3-0.8.0}/plot3/encode.py +8 -4
  5. {plot3-0.6.8 → plot3-0.8.0}/plot3/geoms.py +1 -1
  6. {plot3-0.6.8 → plot3-0.8.0}/plot3/jupyter.py +9 -3
  7. plot3-0.8.0/plot3/kde.py +124 -0
  8. {plot3-0.6.8 → plot3-0.8.0}/plot3/masking.py +172 -55
  9. {plot3-0.6.8 → plot3-0.8.0}/plot3/scales.py +13 -3
  10. {plot3-0.6.8 → plot3-0.8.0}/plot3/stat2d.py +6 -0
  11. {plot3-0.6.8 → plot3-0.8.0}/plot3.egg-info/PKG-INFO +2 -1
  12. {plot3-0.6.8 → plot3-0.8.0}/plot3.egg-info/SOURCES.txt +2 -0
  13. {plot3-0.6.8 → plot3-0.8.0}/plot3.egg-info/requires.txt +1 -0
  14. {plot3-0.6.8 → plot3-0.8.0}/pyproject.toml +3 -0
  15. plot3-0.8.0/tests/test_fast_paths.py +106 -0
  16. plot3-0.8.0/tests/test_masking.py +437 -0
  17. plot3-0.6.8/plot3/__version__.py +0 -1
  18. plot3-0.6.8/tests/test_masking.py +0 -240
  19. {plot3-0.6.8 → plot3-0.8.0}/LICENSE +0 -0
  20. {plot3-0.6.8 → plot3-0.8.0}/README.md +0 -0
  21. {plot3-0.6.8 → plot3-0.8.0}/plot3/__init__.py +0 -0
  22. {plot3-0.6.8 → plot3-0.8.0}/plot3/aesexpr.py +0 -0
  23. {plot3-0.6.8 → plot3-0.8.0}/plot3/calculus.py +0 -0
  24. {plot3-0.6.8 → plot3-0.8.0}/plot3/compose.py +0 -0
  25. {plot3-0.6.8 → plot3-0.8.0}/plot3/contour.py +0 -0
  26. {plot3-0.6.8 → plot3-0.8.0}/plot3/craft.py +0 -0
  27. {plot3-0.6.8 → plot3-0.8.0}/plot3/expr.py +0 -0
  28. {plot3-0.6.8 → plot3-0.8.0}/plot3/flip.py +0 -0
  29. {plot3-0.6.8 → plot3-0.8.0}/plot3/function.py +0 -0
  30. {plot3-0.6.8 → plot3-0.8.0}/plot3/ggplot.py +0 -0
  31. {plot3-0.6.8 → plot3-0.8.0}/plot3/io.py +0 -0
  32. {plot3-0.6.8 → plot3-0.8.0}/plot3/latexin.py +0 -0
  33. {plot3-0.6.8 → plot3-0.8.0}/plot3/mathtext.py +0 -0
  34. {plot3-0.6.8 → plot3-0.8.0}/plot3/payload.py +0 -0
  35. {plot3-0.6.8 → plot3-0.8.0}/plot3/remote.py +0 -0
  36. {plot3-0.6.8 → plot3-0.8.0}/plot3/scaling.py +0 -0
  37. {plot3-0.6.8 → plot3-0.8.0}/plot3/special.py +0 -0
  38. {plot3-0.6.8 → plot3-0.8.0}/plot3/static.py +0 -0
  39. {plot3-0.6.8 → plot3-0.8.0}/plot3/stats3d.py +0 -0
  40. {plot3-0.6.8 → plot3-0.8.0}/plot3/table.py +0 -0
  41. {plot3-0.6.8 → plot3-0.8.0}/plot3/themes.py +0 -0
  42. {plot3-0.6.8 → plot3-0.8.0}/plot3/viewer.py +0 -0
  43. {plot3-0.6.8 → plot3-0.8.0}/plot3.egg-info/dependency_links.txt +0 -0
  44. {plot3-0.6.8 → plot3-0.8.0}/plot3.egg-info/top_level.txt +0 -0
  45. {plot3-0.6.8 → plot3-0.8.0}/setup.cfg +0 -0
  46. {plot3-0.6.8 → plot3-0.8.0}/tests/test_2d_density.py +0 -0
  47. {plot3-0.6.8 → plot3-0.8.0}/tests/test_3d.py +0 -0
  48. {plot3-0.6.8 → plot3-0.8.0}/tests/test_animation.py +0 -0
  49. {plot3-0.6.8 → plot3-0.8.0}/tests/test_annotate.py +0 -0
  50. {plot3-0.6.8 → plot3-0.8.0}/tests/test_api_public.py +0 -0
  51. {plot3-0.6.8 → plot3-0.8.0}/tests/test_array_backend.py +0 -0
  52. {plot3-0.6.8 → plot3-0.8.0}/tests/test_arrow.py +0 -0
  53. {plot3-0.6.8 → plot3-0.8.0}/tests/test_build_contract.py +0 -0
  54. {plot3-0.6.8 → plot3-0.8.0}/tests/test_contour.py +0 -0
  55. {plot3-0.6.8 → plot3-0.8.0}/tests/test_everyday_ggplot2.py +0 -0
  56. {plot3-0.6.8 → plot3-0.8.0}/tests/test_expr.py +0 -0
  57. {plot3-0.6.8 → plot3-0.8.0}/tests/test_facets_compose.py +0 -0
  58. {plot3-0.6.8 → plot3-0.8.0}/tests/test_geom_function.py +0 -0
  59. {plot3-0.6.8 → plot3-0.8.0}/tests/test_geoms_ds.py +0 -0
  60. {plot3-0.6.8 → plot3-0.8.0}/tests/test_ggplot2_extras2.py +0 -0
  61. {plot3-0.6.8 → plot3-0.8.0}/tests/test_ggplot2_parity.py +0 -0
  62. {plot3-0.6.8 → plot3-0.8.0}/tests/test_ggplot_extras.py +0 -0
  63. {plot3-0.6.8 → plot3-0.8.0}/tests/test_grammar.py +0 -0
  64. {plot3-0.6.8 → plot3-0.8.0}/tests/test_latex_input.py +0 -0
  65. {plot3-0.6.8 → plot3-0.8.0}/tests/test_lidar_scene.py +0 -0
  66. {plot3-0.6.8 → plot3-0.8.0}/tests/test_math_layers.py +0 -0
  67. {plot3-0.6.8 → plot3-0.8.0}/tests/test_mathtext.py +0 -0
  68. {plot3-0.6.8 → plot3-0.8.0}/tests/test_payload.py +0 -0
  69. {plot3-0.6.8 → plot3-0.8.0}/tests/test_payload_display.py +0 -0
  70. {plot3-0.6.8 → plot3-0.8.0}/tests/test_point_cloud.py +0 -0
  71. {plot3-0.6.8 → plot3-0.8.0}/tests/test_r_oracle_parity.py +0 -0
  72. {plot3-0.6.8 → plot3-0.8.0}/tests/test_readme.py +0 -0
  73. {plot3-0.6.8 → plot3-0.8.0}/tests/test_remote_bridge.py +0 -0
  74. {plot3-0.6.8 → plot3-0.8.0}/tests/test_review_fixes.py +0 -0
  75. {plot3-0.6.8 → plot3-0.8.0}/tests/test_save_menu.py +0 -0
  76. {plot3-0.6.8 → plot3-0.8.0}/tests/test_scales_api.py +0 -0
  77. {plot3-0.6.8 → plot3-0.8.0}/tests/test_scales_themes.py +0 -0
  78. {plot3-0.6.8 → plot3-0.8.0}/tests/test_special.py +0 -0
  79. {plot3-0.6.8 → plot3-0.8.0}/tests/test_stat2d.py +0 -0
  80. {plot3-0.6.8 → plot3-0.8.0}/tests/test_static_save.py +0 -0
  81. {plot3-0.6.8 → plot3-0.8.0}/tests/test_stats_semantic.py +0 -0
  82. {plot3-0.6.8 → plot3-0.8.0}/tests/test_step3_geoms.py +0 -0
  83. {plot3-0.6.8 → plot3-0.8.0}/tests/test_table_backends.py +0 -0
  84. {plot3-0.6.8 → plot3-0.8.0}/tests/test_titles_theme.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plot3
3
- Version: 0.6.8
3
+ Version: 0.8.0
4
4
  Summary: ggplot2 grammar of graphics for Python: interactive WebGL figures and journal-ready PNG, SVG, and PDF
5
5
  Author: Rigoberto Leyva Salmeron
6
6
  License-Expression: MIT
@@ -17,6 +17,7 @@ Description-Content-Type: text/markdown
17
17
  License-File: LICENSE
18
18
  Requires-Dist: numpy>=1.24
19
19
  Requires-Dist: pandas>=2.0
20
+ Requires-Dist: tidysyntax<0.2,>=0.1
20
21
  Provides-Extra: pdf
21
22
  Requires-Dist: cairosvg>=2.7; extra == "pdf"
22
23
  Provides-Extra: dev
@@ -0,0 +1 @@
1
+ __version__ = "0.8.0"
@@ -10,6 +10,7 @@ import pandas as pd
10
10
  import copy
11
11
  import os
12
12
 
13
+ from plot3.kde import EXACT_WORK, gaussian_1d
13
14
  from plot3.encode import encode_codes, encode_norm, pack_u16, pack_u32, pack_u8
14
15
  from plot3.mathtext import split_math
15
16
  from plot3.geoms import (
@@ -100,6 +101,11 @@ def _kde_1d(
100
101
  if hi <= lo:
101
102
  hi = lo + 1.0
102
103
  grid = np.linspace(lo, hi, n)
104
+ if x.size * n > EXACT_WORK:
105
+ # Large inputs: linear binning and an FFT, as R's density() does.
106
+ sums = gaussian_1d(x, lo, hi, n, bw)
107
+ if sums is not None:
108
+ return grid, sums / x.size
103
109
  # (n_grid, n_obs)
104
110
  u = (grid[:, None] - x[None, :]) / bw
105
111
  dens = np.exp(-0.5 * u * u).sum(axis=1) / (
@@ -1522,6 +1528,26 @@ def _rug_series(rug, g):
1522
1528
  return out
1523
1529
 
1524
1530
 
1531
+ def _remap_codes(codes, remap: dict, default) -> np.ndarray:
1532
+ """``remap.get(int(c), default)`` for every code, as one table lookup."""
1533
+ c = np.asarray(codes)
1534
+ if c.dtype.kind not in "biuf" or (c.dtype.kind == "f" and not np.isfinite(c).all()):
1535
+ # Same per-value rule, errors included (int() of NaN raises).
1536
+ return np.array([remap.get(int(x), default) for x in c.ravel()]).reshape(c.shape)
1537
+ ints = c.astype(np.int64)
1538
+ out = np.full(ints.shape, default)
1539
+ if remap and ints.size:
1540
+ keys = np.fromiter(remap.keys(), dtype=np.int64, count=len(remap))
1541
+ values = np.array(list(remap.values()))
1542
+ order = np.argsort(keys)
1543
+ keys, values = keys[order], values[order]
1544
+ at = np.clip(np.searchsorted(keys, ints), 0, len(keys) - 1)
1545
+ hit = keys[at] == ints
1546
+ out = out.astype(np.result_type(out, values))
1547
+ out[hit] = values[at[hit]]
1548
+ return out
1549
+
1550
+
1525
1551
  def _rug_positions(scale, series) -> np.ndarray:
1526
1552
  """Values in the scale's own units; NaN where they have no place."""
1527
1553
  kind, pos, cats = col_values(series)
@@ -1531,6 +1557,13 @@ def _rug_positions(scale, series) -> np.ndarray:
1531
1557
  lookup = np.array([index.get(c, np.nan) for c in cats] + [np.nan], dtype=np.float64)
1532
1558
  codes = np.where(np.isfinite(pos), pos, len(cats)).astype(np.int64)
1533
1559
  return lookup[codes]
1560
+ values = np.asarray(series)
1561
+ # float32 prints differently as a NumPy scalar than as a float.
1562
+ if values.dtype.kind in "biu" or values.dtype == np.float64:
1563
+ # One text lookup per distinct value (NaN included), not per row.
1564
+ codes, uniques = pd.factorize(values, use_na_sentinel=False)
1565
+ lookup = np.array([index.get(str(u), np.nan) for u in uniques.tolist()], dtype=np.float64)
1566
+ return lookup[codes] if len(codes) else np.array([], dtype=np.float64)
1534
1567
  return np.array([index.get(str(v), np.nan) for v in series], dtype=np.float64)
1535
1568
  if kind == "cat":
1536
1569
  return np.full(len(pos), np.nan)
@@ -2211,10 +2244,8 @@ def build_spec(g: ggplot) -> tuple[dict, list[tuple[str, str]]]:
2211
2244
  }
2212
2245
  out = np.full(np.size(v), np.nan, dtype=np.float64)
2213
2246
  flat = np.asarray(v, dtype=np.float64).ravel()
2214
- for i, c in enumerate(flat):
2215
- if not math.isfinite(c):
2216
- continue
2217
- out[i] = remap.get(int(c), -1)
2247
+ finite = np.isfinite(flat)
2248
+ out[finite] = _remap_codes(flat[finite], remap, -1)
2218
2249
  v = out.reshape(np.shape(v))
2219
2250
  sc.cats = merged
2220
2251
  else:
@@ -3089,9 +3120,7 @@ def build_spec(g: ggplot) -> tuple[dict, list[tuple[str, str]]]:
3089
3120
  i: color_scale[1].index(c)
3090
3121
  for i, c in enumerate(local)
3091
3122
  }
3092
- codes = np.array(
3093
- [remap.get(int(c), 0) for c in cv], dtype="<u2"
3094
- )
3123
+ codes = _remap_codes(cv, remap, 0).astype("<u2")
3095
3124
  payloads.append((pid, pack_u16(codes, g.compress)))
3096
3125
  spec_l["ocolor"] = {
3097
3126
  "id": pid, "dtype": "u16", "kind": "cat"
@@ -3121,8 +3150,7 @@ def build_spec(g: ggplot) -> tuple[dict, list[tuple[str, str]]]:
3121
3150
  # remap onto the global cat list
3122
3151
  local = vals["color"][2]
3123
3152
  remap = {i: color_scale[1].index(c) for i, c in enumerate(local)}
3124
- codes = np.array([remap.get(int(c), 0) for c in cv[order]],
3125
- dtype="<u2")
3153
+ codes = _remap_codes(cv[order], remap, 0).astype("<u2")
3126
3154
  payloads.append((pid, pack_u16(codes, g.compress)))
3127
3155
  spec_l["color"] = {"id": pid, "dtype": "u16", "kind": "cat"}
3128
3156
  else:
@@ -3140,9 +3168,7 @@ def build_spec(g: ggplot) -> tuple[dict, list[tuple[str, str]]]:
3140
3168
  if ckind == "cat":
3141
3169
  local = vals["color"][2]
3142
3170
  remap = {i: color_scale[1].index(c) for i, c in enumerate(local)}
3143
- codes = np.array(
3144
- [remap.get(int(c), 0) for c in cv[order]], dtype="<u2"
3145
- )
3171
+ codes = _remap_codes(cv[order], remap, 0).astype("<u2")
3146
3172
  payloads.append((pid, pack_u16(codes, g.compress)))
3147
3173
  spec_l["color"] = {"id": pid, "dtype": "u16", "kind": "cat"}
3148
3174
  else:
@@ -3322,10 +3348,7 @@ def build_spec(g: ggplot) -> tuple[dict, list[tuple[str, str]]]:
3322
3348
  flat = np.full(raw.shape, 65535, dtype=np.uint16)
3323
3349
  ok = np.isfinite(raw) & (raw >= 0)
3324
3350
  if ok.any():
3325
- mapped = np.array(
3326
- [remap.get(int(c), 0) for c in raw[ok]],
3327
- dtype=np.uint16,
3328
- )
3351
+ mapped = _remap_codes(raw[ok], remap, 0).astype(np.uint16)
3329
3352
  flat[np.flatnonzero(ok)] = mapped
3330
3353
  enc = encode_codes(flat, g.compress)
3331
3354
  pid = f"p{li}fc"
@@ -7,6 +7,10 @@ import gzip as _gzip
7
7
 
8
8
  import numpy as np
9
9
 
10
+ # gzip is lossless at every level. Level 1 builds large payloads several
11
+ # times faster than 6 for a few percent more bytes.
12
+ _LEVEL = 1
13
+
10
14
  def delta_u16(a: np.ndarray) -> np.ndarray:
11
15
  """Column-wise delta mod 2^16 (lossless; tiny values on ordered data)."""
12
16
  d = a.astype(np.int32)
@@ -21,7 +25,7 @@ def pack_u16(q: np.ndarray, compress: bool) -> str:
21
25
  raw = _gzip.compress(
22
26
  np.ascontiguousarray(d[:, 0]).tobytes()
23
27
  + np.ascontiguousarray(d[:, 1]).tobytes(),
24
- 6,
28
+ compresslevel=_LEVEL, mtime=0,
25
29
  )
26
30
  else:
27
31
  raw = np.ascontiguousarray(q, dtype="<u2").tobytes()
@@ -31,7 +35,7 @@ def pack_u16(q: np.ndarray, compress: bool) -> str:
31
35
  def pack_f32(v: np.ndarray, compress: bool) -> str:
32
36
  raw = np.ascontiguousarray(v, dtype="<f4").tobytes()
33
37
  if compress:
34
- raw = _gzip.compress(raw, 6)
38
+ raw = _gzip.compress(raw, _LEVEL, mtime=0)
35
39
  return base64.b64encode(raw).decode("ascii")
36
40
 
37
41
 
@@ -55,7 +59,7 @@ def pack_u8(values: np.ndarray, compress: bool) -> str:
55
59
  """uint8 array -> base64 (optional gzip; no delta)."""
56
60
  raw = np.ascontiguousarray(values, dtype=np.uint8).tobytes()
57
61
  if compress:
58
- raw = _gzip.compress(raw, 6)
62
+ raw = _gzip.compress(raw, _LEVEL, mtime=0)
59
63
  return base64.b64encode(raw).decode("ascii")
60
64
 
61
65
 
@@ -63,6 +67,6 @@ def pack_u32(values: np.ndarray, compress: bool) -> str:
63
67
  """uint32 array -> base64 (optional gzip; no delta)."""
64
68
  raw = np.ascontiguousarray(values, dtype="<u4").tobytes()
65
69
  if compress:
66
- raw = _gzip.compress(raw, 6)
70
+ raw = _gzip.compress(raw, _LEVEL, mtime=0)
67
71
  return base64.b64encode(raw).decode("ascii")
68
72
 
@@ -1798,7 +1798,7 @@ class facet_wrap:
1798
1798
  # Accept "~cyl", ". ~ cyl", "cyl ~ ."
1799
1799
  parts = [p.strip() for p in name.split("~")]
1800
1800
  candidates = [p for p in parts if p and p != "."]
1801
- if len(candidates) != 1:
1801
+ if len(candidates) != 1 or "+" in candidates[0]:
1802
1802
  raise ValueError(
1803
1803
  "facet_wrap() currently accepts a single facet column "
1804
1804
  f"(got {facets!r})"
@@ -22,6 +22,7 @@ from plot3.masking import (
22
22
  is_mask_transformer,
23
23
  is_tidy3_backtick_transformer,
24
24
  plot3_backtick_transform,
25
+ plot3_formula_transform,
25
26
  plot3_layer_transform,
26
27
  )
27
28
 
@@ -62,16 +63,21 @@ def enable_r_style(ipython: Any | None = None) -> bool:
62
63
  # When tidy3 is not loaded, leave tidy3 sentinel unset; plot3 AST
63
64
  # still rewrites __tidy3_bt__ if tidy3's preparser ran first.
64
65
 
65
- # Source preparser: only install plot3's if tidy3 did not already provide one.
66
+ # Source preparser: when tidy3 already rewrites backticks, run only
67
+ # plot3's own formula rewrites (^ in geom_function, ~ in facets), after it.
66
68
  for attr in ("input_transformers_cleanup", "input_transformers_post"):
67
69
  transformers = getattr(ipython, attr, None)
68
70
  if not isinstance(transformers, list):
69
71
  continue
70
- has_tidy3_bt = any(is_tidy3_backtick_transformer(t) for t in transformers)
71
72
  transformers[:] = [
72
73
  t for t in transformers if not is_backtick_transformer(t)
73
74
  ]
74
- if not has_tidy3_bt:
75
+ tidy3_at = [
76
+ i for i, t in enumerate(transformers) if is_tidy3_backtick_transformer(t)
77
+ ]
78
+ if tidy3_at:
79
+ transformers.insert(tidy3_at[-1] + 1, plot3_formula_transform)
80
+ else:
75
81
  transformers.insert(0, plot3_backtick_transform)
76
82
 
77
83
  # AST pass for bare names inside aes / facet_wrap.
@@ -0,0 +1,124 @@
1
+ """Binned Gaussian kernel density sums (linear binning + FFT convolution).
2
+
3
+ R's ``density()`` bins too. Values are spread onto a fine grid (at least
4
+ four points per output interval, at most 1/20 of a bandwidth apart in
5
+ 1D and 1/4 in 2D),
6
+ convolved with the kernel by FFT, and read back at the output points. The
7
+ relative error is far below what a plot shows. Values more than
8
+ ``_REACH`` bandwidths past the output range add nothing in float64 and are
9
+ left out. The callers keep the exact sum for small inputs, and these
10
+ helpers return None when the fine grid would be too large.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import numpy as np
16
+
17
+ # Below this many (observations x output points) the exact sum is cheap.
18
+ EXACT_WORK = 2_000_000
19
+ # The fine spacing is at most this fraction of the bandwidth (1D, 2D), and
20
+ # the fine grid has at least four points per output interval. Linear
21
+ # binning's error grows with the square of that fraction.
22
+ _MAX_STEP_1D = 0.05
23
+ _MAX_STEP_2D = 0.25
24
+ _MIN_FINE = 4
25
+ # Past this many bandwidths a kernel underflows to zero in float64.
26
+ _REACH = 40.0
27
+ # Largest fine grid (points, or cells in 2D) worth binning onto.
28
+ _MAX_CELLS = 1 << 22
29
+
30
+
31
+ def _fine_axis(values: np.ndarray, lo: float, hi: float, n: int, h: float, max_step: float):
32
+ """``(origin, step, size, first, fine)`` of a fine grid through the output points.
33
+
34
+ The output point ``k`` sits at fine index ``first + k * fine``. The grid
35
+ covers every value within ``_REACH`` bandwidths of the output range;
36
+ values farther out add nothing to the sums.
37
+ """
38
+ if n < 2 or not hi > lo or not h > 0 or not values.size:
39
+ return None
40
+ fine = max(_MIN_FINE, int(np.ceil((hi - lo) / ((n - 1) * max_step * h))))
41
+ step = (hi - lo) / ((n - 1) * fine)
42
+ low = max(float(values.min()), lo - _REACH * h)
43
+ high = min(float(values.max()), hi + _REACH * h)
44
+ below = max(0, int(np.ceil((lo - low) / step)))
45
+ above = max(0, int(np.ceil((high - hi) / step)))
46
+ size = (n - 1) * fine + 1 + below + above
47
+ if size > _MAX_CELLS:
48
+ return None
49
+ return lo - below * step, step, size, below, fine
50
+
51
+
52
+ def _within(values: np.ndarray, origin: float, step: float, size: int) -> np.ndarray:
53
+ return (values >= origin) & (values <= origin + (size - 1) * step)
54
+
55
+
56
+ def _linear_bins(values: np.ndarray, origin: float, step: float, size: int):
57
+ """Indices and weights of the two fine points around each value."""
58
+ pos = (values - origin) / step
59
+ left = np.clip(np.floor(pos).astype(np.int64), 0, size - 2)
60
+ frac = np.clip(pos - left, 0.0, 1.0)
61
+ return left, frac
62
+
63
+
64
+ def _kernel_fft(step: float, size: int, h: float, length: int) -> np.ndarray:
65
+ offsets = np.arange(-(size - 1), size) * step
66
+ kernel = np.exp(-0.5 * (offsets / h) ** 2) / (h * np.sqrt(2.0 * np.pi))
67
+ return np.fft.rfft(kernel, length)
68
+
69
+
70
+ def _fft_length(size: int) -> int:
71
+ return 1 << int(np.ceil(np.log2(3 * size - 2)))
72
+
73
+
74
+ def _convolve(weights: np.ndarray, step: float, size: int, h: float, axis: int) -> np.ndarray:
75
+ """``sum_i w[i] K((j - i) step)`` for every fine index ``j`` along *axis*."""
76
+ length = _fft_length(size)
77
+ spectrum = np.fft.rfft(weights, length, axis=axis)
78
+ shape = [1] * weights.ndim
79
+ shape[axis] = -1
80
+ full = np.fft.irfft(spectrum * _kernel_fft(step, size, h, length).reshape(shape), length, axis=axis)
81
+ return np.take(full, np.arange(size - 1, 2 * size - 1), axis=axis)
82
+
83
+
84
+ def gaussian_1d(x: np.ndarray, lo: float, hi: float, n: int, h: float) -> np.ndarray | None:
85
+ """``sum_i phi((g - x_i) / h) / h`` at ``linspace(lo, hi, n)``, or None."""
86
+ axis = _fine_axis(x, lo, hi, n, h, _MAX_STEP_1D)
87
+ if axis is None:
88
+ return None
89
+ origin, step, size, first, fine = axis
90
+ x = x[_within(x, origin, step, size)]
91
+ left, frac = _linear_bins(x, origin, step, size)
92
+ weights = np.bincount(left, 1.0 - frac, size) + np.bincount(left + 1, frac, size)
93
+ sums = _convolve(weights, step, size, h, axis=0)
94
+ return np.maximum(sums[first:first + (n - 1) * fine + 1:fine], 0.0)
95
+
96
+
97
+ def gaussian_2d(
98
+ xs: np.ndarray, ys: np.ndarray, x_lims: tuple[float, float], y_lims: tuple[float, float],
99
+ n: int, hx: float, hy: float,
100
+ ) -> np.ndarray | None:
101
+ """``sum_i phi_x(gx - x_i) phi_y(gy - y_i)`` as an ``(n, n)`` (y, x) grid, or None."""
102
+ ax = _fine_axis(xs, x_lims[0], x_lims[1], n, hx, _MAX_STEP_2D)
103
+ ay = _fine_axis(ys, y_lims[0], y_lims[1], n, hy, _MAX_STEP_2D)
104
+ if ax is None or ay is None:
105
+ return None
106
+ (x_origin, x_step, x_size, x_first, x_fine), (y_origin, y_step, y_size, y_first, y_fine) = ax, ay
107
+ # Two FFT passes over the fine grid must beat n exponentials per value
108
+ # on each axis; a grid stretched over wide limits does not.
109
+ if x_size * y_size > _MAX_CELLS or x_size * y_size * 16 > xs.size * n:
110
+ return None
111
+ keep = _within(xs, x_origin, x_step, x_size) & _within(ys, y_origin, y_step, y_size)
112
+ xs, ys = xs[keep], ys[keep]
113
+ xl, xf = _linear_bins(xs, x_origin, x_step, x_size)
114
+ yl, yf = _linear_bins(ys, y_origin, y_step, y_size)
115
+ cells = y_size * x_size
116
+ weights = np.zeros(cells)
117
+ for dy, wy in ((0, 1.0 - yf), (1, yf)):
118
+ for dx, wx in ((0, 1.0 - xf), (1, xf)):
119
+ weights += np.bincount((yl + dy) * x_size + xl + dx, wy * wx, cells)
120
+ weights = weights.reshape(y_size, x_size)
121
+ sums = _convolve(_convolve(weights, x_step, x_size, hx, axis=1), y_step, y_size, hy, axis=0)
122
+ rows = slice(y_first, y_first + (n - 1) * y_fine + 1, y_fine)
123
+ cols = slice(x_first, x_first + (n - 1) * x_fine + 1, x_fine)
124
+ return np.maximum(sums[rows, cols], 0.0)
@@ -30,11 +30,16 @@ from __future__ import annotations
30
30
 
31
31
  import ast
32
32
  import builtins
33
+ import dataclasses
33
34
  import io
34
35
  import re
35
36
  import tokenize
36
37
  from typing import Any, Iterable
37
38
 
39
+ import tidysyntax
40
+ from tidysyntax import repairs
41
+ from tidysyntax.scanner import Spans, apply
42
+
38
43
  # Sentinels (plot3 own + tidy3 compatibility).
39
44
  BT_NAME = "__plot3_bt__"
40
45
  TIDY3_BT_NAME = "__tidy3_bt__"
@@ -108,69 +113,121 @@ _PASSTHROUGH_KW = frozenset(
108
113
 
109
114
  _FORMULA_CALLS = frozenset({"geom_function", "geom_vector_field"})
110
115
 
111
- _BT_RE = re.compile(r"`([^`\n]+)`")
116
+ _FACETS = frozenset({"facet_wrap", "facet_grid"})
117
+
118
+ # plot3's part of the notebook syntax tidy3, tidy3a, plot3 and stats3 share
119
+ # (tidysyntax). ``drv ~ cyl`` and ``~`my col``` are not Python, so they are
120
+ # quoted in any facet call; ``^`` in geom_function (XOR in Python) and ``~cyl``
121
+ # in a facet (bitwise not) are valid Python, so they are rewritten only where
122
+ # the call is plot3's: a notebook's own geom_function keeps XOR.
123
+ DIALECT = tidysyntax.Dialect(
124
+ "plot3",
125
+ formula_calls=_FACETS,
126
+ formula_keywords={"facets"},
127
+ formula_backticks="strip",
128
+ power_calls=_FORMULA_CALLS,
129
+ unary_formula_calls=_FACETS,
130
+ )
131
+ _POWER_ONLY = dataclasses.replace(DIALECT, formula_calls=(), formula_keywords=(), unary_formula_calls=())
132
+ _FACETS_ONLY = dataclasses.replace(DIALECT, power_calls=())
133
+
134
+ _SESSION = object() # default: the running IPython's namespace, if any
135
+
136
+
137
+ def _session_namespace(namespace: Any) -> dict[str, Any] | None:
138
+ if namespace is not _SESSION:
139
+ return namespace
140
+ try:
141
+ from IPython import get_ipython
142
+
143
+ ip = get_ipython()
144
+ except Exception:
145
+ return None
146
+ return getattr(ip, "user_ns", None) if ip is not None else None
147
+
148
+
149
+ def _plot3_bt(source: str) -> str:
150
+ """tidysyntax's ``__r_name__`` placeholder as plot3's ``__plot3_bt__``, outside strings and comments."""
151
+ if tidysyntax.NAME not in source:
152
+ return source
153
+ spans = Spans.of(source)
154
+ edits = [
155
+ (m.start(), m.end(), BT_NAME)
156
+ for m in re.finditer(re.escape(tidysyntax.NAME) + r"(?=\s*\()", source)
157
+ if m.start() in spans
158
+ ]
159
+ return apply(source, edits)
160
+
161
+
162
+ def _repair(source: str) -> str:
163
+ """The rewrites of text that is not Python: ``drv ~ cyl``, ``~`my col``` and backticks."""
164
+ return _plot3_bt(repairs.backticks(repairs.quote_formulas(source, [DIALECT])))
112
165
 
113
166
 
114
- def rewrite_formula_carets(source: str) -> str:
167
+ def rewrite_facet_formulas(source: str, *, unary: str = "owned", namespace: Any = _SESSION) -> str:
168
+ """Quote R formulas inside ``facet_wrap`` / ``facet_grid`` before parsing.
169
+
170
+ ``facet_grid(drv ~ cyl)`` → ``facet_grid('drv ~ cyl')``: ``~`` with a left
171
+ side is not Python, so it is quoted in any facet call, as is a ``~`` formula
172
+ holding a backtick name. ``facet_wrap(~cyl)`` → ``facet_wrap('~cyl')``
173
+ only where the facet call is plot3's, since ``~cyl`` is Python's bitwise
174
+ not (``unary="owned"``); ``unary="never"`` leaves such formulas for a
175
+ later pass. Backtick names inside the formula lose their backticks
176
+ (``~`my col``` → ``'~my col'``).
177
+ """
178
+ source = repairs.quote_formulas(source, [DIALECT])
179
+ if unary == "never":
180
+ return source
181
+ return tidysyntax.reinterpret(source, [_FACETS_ONLY], _session_namespace(namespace))
182
+
183
+
184
+ def rewrite_formulas(source: str, namespace: Any = _SESSION) -> str:
185
+ """The text rewrites plot3 needs even when tidy3 handles backticks:
186
+ ``^`` inside ``geom_function`` and ``~`` formulas inside facets.
187
+
188
+ ``drv ~ cyl`` is quoted first, since the cell must parse before the
189
+ ownership of ``geom_function`` and ``facet_wrap`` can be read."""
190
+ source = repairs.quote_formulas(source, [DIALECT])
191
+ return tidysyntax.reinterpret(source, [DIALECT], _session_namespace(namespace))
192
+
193
+
194
+ def rewrite_formula_carets(source: str, namespace: Any = _SESSION) -> str:
115
195
  """Inside ``geom_function(...)``, turn ``^`` into ``**`` before Python parses.
116
196
 
117
197
  ``2*x^3`` and ``(2*x)^3`` are the same tree once Python has parsed ``^``
118
- as xor, so the rewrite has to happen on the text.
198
+ as xor, so the rewrite has to happen on the text. Only plot3's own
199
+ ``geom_function`` is rewritten: in a notebook's own function of that
200
+ name, ``^`` stays XOR.
119
201
  """
120
- if "^" not in source or not any(name in source for name in _FORMULA_CALLS):
121
- return source
122
- try:
123
- tokens = list(tokenize.generate_tokens(io.StringIO(source).readline))
124
- except (tokenize.TokenError, SyntaxError):
125
- return source
126
- starts = [0]
127
- for line in source.splitlines(keepends=True):
128
- starts.append(starts[-1] + len(line))
129
- hits: list[int] = []
130
- depth = 0 # paren depth inside the current geom_function call
131
- armed = False # just saw the name geom_function
132
- for tok in tokens:
133
- if depth == 0:
134
- if tok.type == tokenize.NAME and tok.string in _FORMULA_CALLS:
135
- armed = True
136
- continue
137
- if armed and tok.type == tokenize.OP and tok.string == "(":
138
- depth = 1
139
- armed = False
140
- continue
141
- if tok.type == tokenize.OP:
142
- if tok.string in "([{":
143
- depth += 1
144
- elif tok.string in ")]}":
145
- depth -= 1
146
- elif tok.string == "^":
147
- row, col = tok.start
148
- hits.append(starts[row - 1] + col)
149
- for pos in reversed(hits):
150
- source = source[:pos] + "**" + source[pos + 1 :]
151
- return source
152
-
153
-
154
- def rewrite_backticks(source: str) -> str:
155
- """Preparse backticks, and ``^`` inside ``geom_function``, before parsing.
202
+ return tidysyntax.reinterpret(source, [_POWER_ONLY], _session_namespace(namespace))
203
+
204
+
205
+ def rewrite_backticks(source: str, namespace: Any = _SESSION) -> str:
206
+ """Preparse backticks, ``^`` inside ``geom_function``, and facet formulas.
156
207
 
157
208
  `` `col name` `` → ``__plot3_bt__("col name")``. ``^`` → ``**`` only
158
- inside a ``geom_function(...)`` call, where it means power.
209
+ inside plot3's ``geom_function(...)``, where it means power. Backticks
210
+ inside string literals and comments stay as written. The text that is
211
+ not Python goes first, so the cell parses when the ownership of
212
+ ``geom_function`` and ``facet_wrap`` is read.
159
213
  """
160
- source = rewrite_formula_carets(source)
161
- if "`" not in source:
162
- return source
163
- return _BT_RE.sub(lambda m: f"{BT_NAME}({m.group(1)!r})", source)
214
+ return tidysyntax.reinterpret(_repair(source), [DIALECT], _session_namespace(namespace))
164
215
 
165
216
 
166
- def plot3_backtick_transform(lines: list[str]) -> list[str]:
167
- """IPython input transformer: backticks, and ``^`` inside geom_function."""
168
- if not lines:
169
- return lines
170
- src = "".join(lines)
171
- if "`" not in src and "^" not in src:
172
- return lines
173
- out = rewrite_backticks(src)
217
+ def _parse_dedented(source: str) -> tuple[ast.Module, list[int]] | None:
218
+ """Parse *source* with indented ``+`` lines moved to column 0 (they are
219
+ joined later); the shift per line maps positions back. ``None`` if the
220
+ cell does not parse, in which case nothing valid is reinterpreted."""
221
+ raw = source.split("\n")
222
+ shift = [len(line) - len(line.lstrip()) if line.lstrip().startswith("+") else 0 for line in raw]
223
+ try:
224
+ tree = ast.parse("\n".join(line[n:] for line, n in zip(raw, shift)))
225
+ except (SyntaxError, ValueError):
226
+ return None
227
+ return tree, shift
228
+
229
+
230
+ def _as_lines(src: str, out: str, lines: list[str]) -> list[str]:
174
231
  if out == src:
175
232
  return lines
176
233
  if out.endswith("\n"):
@@ -179,6 +236,28 @@ def plot3_backtick_transform(lines: list[str]) -> list[str]:
179
236
  return parts or [out]
180
237
 
181
238
 
239
+ def plot3_backtick_transform(lines: list[str]) -> list[str]:
240
+ """IPython input transformer: backticks, ``^`` inside geom_function, and
241
+ ``~`` formulas inside facets."""
242
+ if not lines:
243
+ return lines
244
+ src = "".join(lines)
245
+ if "`" not in src and "^" not in src and "~" not in src:
246
+ return lines
247
+ return _as_lines(src, rewrite_backticks(src), lines)
248
+
249
+
250
+ def plot3_formula_transform(lines: list[str]) -> list[str]:
251
+ """IPython input transformer used beside tidy3's backtick preparser:
252
+ ``^`` inside geom_function and ``~`` formulas inside facets only."""
253
+ if not lines:
254
+ return lines
255
+ src = "".join(lines)
256
+ if "^" not in src and "~" not in src:
257
+ return lines
258
+ return _as_lines(src, rewrite_formulas(src), lines)
259
+
260
+
182
261
  _OPENERS = frozenset("([{")
183
262
  _CLOSERS = frozenset(")]}")
184
263
  _SKIP_TOKENS = frozenset({
@@ -229,7 +308,7 @@ def _value_start(tokens: list[tokenize.TokenInfo]) -> tuple[int, int]:
229
308
  return tokens[0].start
230
309
 
231
310
 
232
- def join_layer_lines(source: str) -> str | None:
311
+ def join_layer_lines(source: str, namespace: Any = _SESSION) -> str | None:
233
312
  """Join ``+`` lines that add plot3 layers to the statement above them.
234
313
 
235
314
  In a notebook, ``ggplot(df, aes(...))`` followed by lines that start
@@ -240,6 +319,12 @@ def join_layer_lines(source: str) -> str | None:
240
319
  lines; the expression gets parentheses on the lines as typed, so error
241
320
  line numbers still match. Returns ``None`` when nothing needs joining,
242
321
  or when the result would not parse. Pipes (``>>``) are tidy3's.
322
+
323
+ An indented ``+`` line is not Python, so it joins by name. An unindented
324
+ one is a valid unary-plus statement, so it joins only when the name after
325
+ ``+`` is plot3's and the statement above starts with a plot3 call or a
326
+ value not known to be something else: after ``x = 2``, or with the
327
+ notebook's own ``geom_point``, ``+ geom_point()`` stays a statement.
243
328
  """
244
329
  if not source or "+" not in source:
245
330
  return None
@@ -253,6 +338,36 @@ def join_layer_lines(source: str) -> str | None:
253
338
  logical = _logical_lines(analysed)
254
339
  if not logical:
255
340
  return None
341
+ parsed = _parse_dedented(_repair(source))
342
+ owners = tidysyntax.Owners(parsed[0], _session_namespace(namespace), [DIALECT]) if parsed is not None else None
343
+
344
+ def plot3_name(name: str, row: int) -> bool:
345
+ return owners is not None and DIALECT.owns_module(owners.module(name, (row, 0)))
346
+
347
+ def plot3_head(tokens: list[tokenize.TokenInfo]) -> bool:
348
+ start = _value_start(tokens)
349
+ rest = [t for t in tokens if t.start >= start]
350
+ while rest and rest[0].string == "(":
351
+ rest = rest[1:]
352
+ if owners is None or not rest or rest[0].type != tokenize.NAME:
353
+ return False
354
+ name, row = rest[0].string, rest[0].start[0]
355
+ if len(rest) > 1 and rest[1].string in ("(", "."):
356
+ return plot3_name(name, row) # ggplot(...), p3.ggplot(...)
357
+ # A variable: refused only when its value is known not to be plot3's.
358
+ bound = [package for end, package in owners.bindings.get(name, ()) if end <= (row, 0)]
359
+ if bound:
360
+ return bound[-1] is None or DIALECT.owns_module(bound[-1])
361
+ if owners.context is not None and name in owners.context:
362
+ return DIALECT.owns_module(tidysyntax.module_of(owners.context[name]))
363
+ return True
364
+
365
+ def may_join(tokens: list[tokenize.TokenInfo], head: list[tokenize.TokenInfo]) -> bool:
366
+ """Whether joining this ``+`` line changes valid Python, and may: see above."""
367
+ row = tokens[0].start[0]
368
+ if shift[row - 1]:
369
+ return True # indented: not Python on its own
370
+ return plot3_name(tokens[1].string, row) and plot3_head(head)
256
371
 
257
372
  def is_layer(tokens: list[tokenize.TokenInfo]) -> bool:
258
373
  return (
@@ -273,6 +388,7 @@ def join_layer_lines(source: str) -> str | None:
273
388
  and head.start[1] == 0
274
389
  and head.type != tokenize.OP
275
390
  and head.string not in {"if", "for", "while", "with", "def", "class", "try", "elif", "else", "except", "finally", "return", "import", "from"}
391
+ and may_join(tokens, previous[0])
276
392
  ):
277
393
  previous.append(tokens)
278
394
  else:
@@ -618,7 +734,7 @@ def apply_masking(
618
734
  backticks: bool = True,
619
735
  ) -> str:
620
736
  """Apply backtick rewrite + AST masking; return unparsed source (tests)."""
621
- text = rewrite_formula_carets(source)
737
+ text = rewrite_formulas(source)
622
738
  if backticks:
623
739
  text = rewrite_backticks(text)
624
740
  tree = ast.parse(text)
@@ -637,7 +753,8 @@ def is_mask_transformer(obj: Any) -> bool:
637
753
  def is_backtick_transformer(obj: Any) -> bool:
638
754
  return (
639
755
  getattr(obj, "__module__", "") in {"plot3.masking", "plot3.jupyter"}
640
- and getattr(obj, "__name__", "") == "plot3_backtick_transform"
756
+ and getattr(obj, "__name__", "")
757
+ in {"plot3_backtick_transform", "plot3_formula_transform"}
641
758
  )
642
759
 
643
760