spectra-plot 0.2.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.
- spectra_plot-0.2.0/MANIFEST.in +1 -0
- spectra_plot-0.2.0/PKG-INFO +22 -0
- spectra_plot-0.2.0/VERSION +1 -0
- spectra_plot-0.2.0/pyproject.toml +34 -0
- spectra_plot-0.2.0/setup.cfg +4 -0
- spectra_plot-0.2.0/spectra/__init__.py +147 -0
- spectra_plot-0.2.0/spectra/_animation.py +180 -0
- spectra_plot-0.2.0/spectra/_axes.py +275 -0
- spectra_plot-0.2.0/spectra/_blob.py +153 -0
- spectra_plot-0.2.0/spectra/_cli.py +23 -0
- spectra_plot-0.2.0/spectra/_codec.py +545 -0
- spectra_plot-0.2.0/spectra/_easy.py +1213 -0
- spectra_plot-0.2.0/spectra/_embed.py +504 -0
- spectra_plot-0.2.0/spectra/_errors.py +34 -0
- spectra_plot-0.2.0/spectra/_figure.py +132 -0
- spectra_plot-0.2.0/spectra/_launcher.py +149 -0
- spectra_plot-0.2.0/spectra/_log.py +62 -0
- spectra_plot-0.2.0/spectra/_persistence.py +111 -0
- spectra_plot-0.2.0/spectra/_protocol.py +143 -0
- spectra_plot-0.2.0/spectra/_series.py +276 -0
- spectra_plot-0.2.0/spectra/_session.py +387 -0
- spectra_plot-0.2.0/spectra/_transport.py +137 -0
- spectra_plot-0.2.0/spectra/backends/__init__.py +13 -0
- spectra_plot-0.2.0/spectra/backends/_qt_compat.py +237 -0
- spectra_plot-0.2.0/spectra/backends/backend_qtagg.py +898 -0
- spectra_plot-0.2.0/spectra/embed.py +446 -0
- spectra_plot-0.2.0/spectra_plot.egg-info/PKG-INFO +22 -0
- spectra_plot-0.2.0/spectra_plot.egg-info/SOURCES.txt +39 -0
- spectra_plot-0.2.0/spectra_plot.egg-info/dependency_links.txt +1 -0
- spectra_plot-0.2.0/spectra_plot.egg-info/requires.txt +7 -0
- spectra_plot-0.2.0/spectra_plot.egg-info/top_level.txt +1 -0
- spectra_plot-0.2.0/tests/test_codec.py +405 -0
- spectra_plot-0.2.0/tests/test_cross_codec.py +364 -0
- spectra_plot-0.2.0/tests/test_easy.py +338 -0
- spectra_plot-0.2.0/tests/test_easy_embed.py +156 -0
- spectra_plot-0.2.0/tests/test_embed.py +249 -0
- spectra_plot-0.2.0/tests/test_phase2.py +806 -0
- spectra_plot-0.2.0/tests/test_phase3.py +464 -0
- spectra_plot-0.2.0/tests/test_phase4.py +543 -0
- spectra_plot-0.2.0/tests/test_phase5.py +611 -0
- spectra_plot-0.2.0/tests/test_qt_backend.py +229 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
include VERSION
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: spectra-plot
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: GPU-accelerated scientific plotting via IPC
|
|
5
|
+
License: MIT
|
|
6
|
+
Classifier: Development Status :: 3 - Alpha
|
|
7
|
+
Classifier: Intended Audience :: Science/Research
|
|
8
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Topic :: Scientific/Engineering :: Visualization
|
|
16
|
+
Requires-Python: >=3.9
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
Provides-Extra: numpy
|
|
19
|
+
Requires-Dist: numpy>=1.20; extra == "numpy"
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
22
|
+
Requires-Dist: numpy>=1.20; extra == "dev"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.2.0
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "spectra-plot"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "GPU-accelerated scientific plotting via IPC"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = {text = "MIT"}
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 3 - Alpha",
|
|
14
|
+
"Intended Audience :: Science/Research",
|
|
15
|
+
"License :: OSI Approved :: MIT License",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3.9",
|
|
18
|
+
"Programming Language :: Python :: 3.10",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Programming Language :: Python :: 3.13",
|
|
22
|
+
"Topic :: Scientific/Engineering :: Visualization",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.optional-dependencies]
|
|
26
|
+
numpy = ["numpy>=1.20"]
|
|
27
|
+
dev = ["pytest>=7.0", "numpy>=1.20"]
|
|
28
|
+
|
|
29
|
+
[tool.setuptools.dynamic]
|
|
30
|
+
version = {file = "VERSION"}
|
|
31
|
+
|
|
32
|
+
[tool.setuptools.packages.find]
|
|
33
|
+
where = ["."]
|
|
34
|
+
include = ["spectra*"]
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Spectra — GPU-accelerated scientific plotting via IPC.
|
|
2
|
+
|
|
3
|
+
The easiest plotting library. One line to plot.
|
|
4
|
+
|
|
5
|
+
Usage::
|
|
6
|
+
|
|
7
|
+
import spectra as sp
|
|
8
|
+
|
|
9
|
+
sp.plot([1, 4, 9, 16]) # that's it. window opens.
|
|
10
|
+
sp.plot(x, y, color="red", label="data") # with style
|
|
11
|
+
sp.scatter(x, y) # scatter plot
|
|
12
|
+
sp.hist(data, bins=50) # histogram
|
|
13
|
+
sp.plot3(x, y, z) # 3D line
|
|
14
|
+
sp.live(lambda t: math.sin(t)) # live streaming
|
|
15
|
+
|
|
16
|
+
sp.show() # optional — block until windows closed
|
|
17
|
+
|
|
18
|
+
Advanced usage (full control)::
|
|
19
|
+
|
|
20
|
+
s = sp.Session()
|
|
21
|
+
fig = s.figure("My Plot")
|
|
22
|
+
ax = fig.subplot(1, 1, 1)
|
|
23
|
+
line = ax.line(x, y, label="data")
|
|
24
|
+
fig.show()
|
|
25
|
+
s.show()
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from ._session import Session
|
|
29
|
+
from ._figure import Figure
|
|
30
|
+
from ._axes import Axes
|
|
31
|
+
from ._series import Series
|
|
32
|
+
from ._errors import (
|
|
33
|
+
SpectraError,
|
|
34
|
+
ConnectionError,
|
|
35
|
+
ProtocolError,
|
|
36
|
+
TimeoutError,
|
|
37
|
+
FigureNotFoundError,
|
|
38
|
+
BackendError,
|
|
39
|
+
)
|
|
40
|
+
from ._animation import ipc_sleep, FramePacer, BackendAnimator
|
|
41
|
+
|
|
42
|
+
# ─── Easy API (one-liners, everything in background) ─────────────────────────
|
|
43
|
+
from ._easy import (
|
|
44
|
+
plot,
|
|
45
|
+
scatter,
|
|
46
|
+
stem,
|
|
47
|
+
hist,
|
|
48
|
+
bar,
|
|
49
|
+
boxplot,
|
|
50
|
+
violin_plot,
|
|
51
|
+
hline,
|
|
52
|
+
vline,
|
|
53
|
+
plot3,
|
|
54
|
+
scatter3,
|
|
55
|
+
surf,
|
|
56
|
+
plotn,
|
|
57
|
+
subplots,
|
|
58
|
+
figure,
|
|
59
|
+
tab,
|
|
60
|
+
subplot,
|
|
61
|
+
subplot3d,
|
|
62
|
+
gcf,
|
|
63
|
+
gca,
|
|
64
|
+
title,
|
|
65
|
+
xlabel,
|
|
66
|
+
ylabel,
|
|
67
|
+
xlim,
|
|
68
|
+
ylim,
|
|
69
|
+
grid,
|
|
70
|
+
legend,
|
|
71
|
+
live,
|
|
72
|
+
stop_live,
|
|
73
|
+
append,
|
|
74
|
+
show,
|
|
75
|
+
close,
|
|
76
|
+
close_all,
|
|
77
|
+
clear,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
# ─── Backward compatibility ───────────────────────────────────────────────────
|
|
81
|
+
# Old API used sp.line(x, y) — keep as alias for sp.plot(x, y)
|
|
82
|
+
line = plot
|
|
83
|
+
|
|
84
|
+
# Old tests reference these module-level attributes
|
|
85
|
+
_default_session = None
|
|
86
|
+
_current_figure = None
|
|
87
|
+
_current_axes = None
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
from importlib.metadata import version as _pkg_version
|
|
91
|
+
__version__ = _pkg_version("spectra-plot")
|
|
92
|
+
except Exception:
|
|
93
|
+
__version__ = "0.1.0"
|
|
94
|
+
|
|
95
|
+
__all__ = [
|
|
96
|
+
# Easy API — one-liners
|
|
97
|
+
"plot",
|
|
98
|
+
"scatter",
|
|
99
|
+
"stem",
|
|
100
|
+
"hist",
|
|
101
|
+
"bar",
|
|
102
|
+
"boxplot",
|
|
103
|
+
"violin_plot",
|
|
104
|
+
"hline",
|
|
105
|
+
"vline",
|
|
106
|
+
"plot3",
|
|
107
|
+
"scatter3",
|
|
108
|
+
"surf",
|
|
109
|
+
"plotn",
|
|
110
|
+
"subplots",
|
|
111
|
+
"figure",
|
|
112
|
+
"tab",
|
|
113
|
+
"subplot",
|
|
114
|
+
"subplot3d",
|
|
115
|
+
"gcf",
|
|
116
|
+
"gca",
|
|
117
|
+
"title",
|
|
118
|
+
"xlabel",
|
|
119
|
+
"ylabel",
|
|
120
|
+
"xlim",
|
|
121
|
+
"ylim",
|
|
122
|
+
"grid",
|
|
123
|
+
"legend",
|
|
124
|
+
"live",
|
|
125
|
+
"stop_live",
|
|
126
|
+
"append",
|
|
127
|
+
"show",
|
|
128
|
+
"close",
|
|
129
|
+
"close_all",
|
|
130
|
+
"clear",
|
|
131
|
+
# Backward compat
|
|
132
|
+
"line",
|
|
133
|
+
# Advanced API — full control
|
|
134
|
+
"Session",
|
|
135
|
+
"Figure",
|
|
136
|
+
"Axes",
|
|
137
|
+
"Series",
|
|
138
|
+
"SpectraError",
|
|
139
|
+
"ConnectionError",
|
|
140
|
+
"ProtocolError",
|
|
141
|
+
"TimeoutError",
|
|
142
|
+
"FigureNotFoundError",
|
|
143
|
+
"BackendError",
|
|
144
|
+
"ipc_sleep",
|
|
145
|
+
"FramePacer",
|
|
146
|
+
"BackendAnimator",
|
|
147
|
+
]
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Animation utilities for the Spectra Python client.
|
|
2
|
+
|
|
3
|
+
Provides IPC-aware sleep and frame pacing helpers that drain events
|
|
4
|
+
while waiting, preventing the backend from stalling on a blocked client.
|
|
5
|
+
|
|
6
|
+
Two animation modes:
|
|
7
|
+
1. Python-driven: Python controls the loop with FramePacer
|
|
8
|
+
2. Backend-driven: Backend sends ANIM_TICK at fixed rate, Python responds
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import time
|
|
12
|
+
from typing import TYPE_CHECKING, Callable, Optional
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from ._session import Session
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def ipc_sleep(session: "Session", duration: float) -> None:
|
|
19
|
+
"""Sleep for `duration` seconds while draining IPC events.
|
|
20
|
+
|
|
21
|
+
Unlike time.sleep(), this keeps the IPC connection alive by
|
|
22
|
+
processing incoming events (e.g. EVT_WINDOW_CLOSED) during the wait.
|
|
23
|
+
|
|
24
|
+
Thread-safe: acquires the session lock before reading from the socket.
|
|
25
|
+
Falls back to plain sleep if the lock is held by another thread
|
|
26
|
+
(e.g. the main thread doing a _request() or show() recv).
|
|
27
|
+
"""
|
|
28
|
+
import select
|
|
29
|
+
|
|
30
|
+
if session._transport is None or not session._transport.is_open:
|
|
31
|
+
time.sleep(duration)
|
|
32
|
+
return
|
|
33
|
+
|
|
34
|
+
deadline = time.monotonic() + duration
|
|
35
|
+
while True:
|
|
36
|
+
remaining = deadline - time.monotonic()
|
|
37
|
+
if remaining <= 0:
|
|
38
|
+
break
|
|
39
|
+
|
|
40
|
+
timeout = min(remaining, 0.05) # poll at 20 Hz
|
|
41
|
+
try:
|
|
42
|
+
ready, _, _ = select.select([session._transport.fileno()], [], [], timeout)
|
|
43
|
+
except (ValueError, OSError):
|
|
44
|
+
break
|
|
45
|
+
|
|
46
|
+
if ready:
|
|
47
|
+
# Try to acquire the lock without blocking — if another thread
|
|
48
|
+
# is doing a _request() send+recv, just skip this read.
|
|
49
|
+
acquired = session._lock.acquire(blocking=False)
|
|
50
|
+
if not acquired:
|
|
51
|
+
time.sleep(min(remaining, 0.01))
|
|
52
|
+
continue
|
|
53
|
+
try:
|
|
54
|
+
msg = session._transport.recv()
|
|
55
|
+
finally:
|
|
56
|
+
session._lock.release()
|
|
57
|
+
if msg is None:
|
|
58
|
+
break
|
|
59
|
+
session._handle_event(msg)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class FramePacer:
|
|
63
|
+
"""Simple frame pacer for Python-driven animation loops.
|
|
64
|
+
|
|
65
|
+
Usage::
|
|
66
|
+
|
|
67
|
+
pacer = FramePacer(fps=30)
|
|
68
|
+
while running:
|
|
69
|
+
update_data()
|
|
70
|
+
series.set_data(x, y)
|
|
71
|
+
pacer.pace(session)
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
__slots__ = ("_interval", "_last_time")
|
|
75
|
+
|
|
76
|
+
def __init__(self, fps: float = 30.0) -> None:
|
|
77
|
+
self._interval = 1.0 / max(fps, 1.0)
|
|
78
|
+
self._last_time = time.monotonic()
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def fps(self) -> float:
|
|
82
|
+
return 1.0 / self._interval
|
|
83
|
+
|
|
84
|
+
@fps.setter
|
|
85
|
+
def fps(self, value: float) -> None:
|
|
86
|
+
self._interval = 1.0 / max(value, 1.0)
|
|
87
|
+
|
|
88
|
+
def pace(self, session: "Session") -> float:
|
|
89
|
+
"""Wait until the next frame time, draining events. Returns dt since last call."""
|
|
90
|
+
now = time.monotonic()
|
|
91
|
+
elapsed = now - self._last_time
|
|
92
|
+
remaining = self._interval - elapsed
|
|
93
|
+
if remaining > 0:
|
|
94
|
+
ipc_sleep(session, remaining)
|
|
95
|
+
now = time.monotonic()
|
|
96
|
+
dt = now - self._last_time
|
|
97
|
+
self._last_time = now
|
|
98
|
+
return dt
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class BackendAnimator:
|
|
102
|
+
"""Backend-driven animation: the backend controls the clock and sends
|
|
103
|
+
ANIM_TICK events at a fixed rate. Python registers a callback that
|
|
104
|
+
receives (t, dt, frame_num) and updates data accordingly.
|
|
105
|
+
|
|
106
|
+
Usage::
|
|
107
|
+
|
|
108
|
+
def on_tick(t, dt, frame_num):
|
|
109
|
+
y = [math.sin(x_i + t) for x_i in x]
|
|
110
|
+
series.set_data(x, y)
|
|
111
|
+
|
|
112
|
+
animator = BackendAnimator(session, figure, fps=60)
|
|
113
|
+
animator.on_tick = on_tick
|
|
114
|
+
animator.start()
|
|
115
|
+
session.show() # blocks until windows closed
|
|
116
|
+
animator.stop()
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
__slots__ = (
|
|
120
|
+
"_session", "_figure_id", "_fps", "_duration",
|
|
121
|
+
"_running", "_on_tick",
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
def __init__(
|
|
125
|
+
self,
|
|
126
|
+
session: "Session",
|
|
127
|
+
figure_id: int,
|
|
128
|
+
fps: float = 60.0,
|
|
129
|
+
duration: float = 0.0,
|
|
130
|
+
) -> None:
|
|
131
|
+
self._session = session
|
|
132
|
+
self._figure_id = figure_id
|
|
133
|
+
self._fps = fps
|
|
134
|
+
self._duration = duration
|
|
135
|
+
self._running = False
|
|
136
|
+
self._on_tick: Optional[Callable[[float, float, int], None]] = None
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def on_tick(self) -> Optional[Callable[[float, float, int], None]]:
|
|
140
|
+
return self._on_tick
|
|
141
|
+
|
|
142
|
+
@on_tick.setter
|
|
143
|
+
def on_tick(self, callback: Optional[Callable[[float, float, int], None]]) -> None:
|
|
144
|
+
self._on_tick = callback
|
|
145
|
+
|
|
146
|
+
@property
|
|
147
|
+
def is_running(self) -> bool:
|
|
148
|
+
return self._running
|
|
149
|
+
|
|
150
|
+
def start(self) -> None:
|
|
151
|
+
"""Request the backend to start sending ANIM_TICK events."""
|
|
152
|
+
from . import _protocol as P
|
|
153
|
+
from . import _codec as codec
|
|
154
|
+
|
|
155
|
+
if self._running:
|
|
156
|
+
return
|
|
157
|
+
payload = codec.encode_req_anim_start(
|
|
158
|
+
self._figure_id, self._fps, self._duration
|
|
159
|
+
)
|
|
160
|
+
self._session._request(P.REQ_ANIM_START, payload)
|
|
161
|
+
self._running = True
|
|
162
|
+
# Register this animator with the session for tick dispatch
|
|
163
|
+
self._session._register_animator(self)
|
|
164
|
+
|
|
165
|
+
def stop(self) -> None:
|
|
166
|
+
"""Request the backend to stop sending ANIM_TICK events."""
|
|
167
|
+
from . import _protocol as P
|
|
168
|
+
from . import _codec as codec
|
|
169
|
+
|
|
170
|
+
if not self._running:
|
|
171
|
+
return
|
|
172
|
+
payload = codec.encode_req_anim_stop(self._figure_id)
|
|
173
|
+
self._session._request(P.REQ_ANIM_STOP, payload)
|
|
174
|
+
self._running = False
|
|
175
|
+
self._session._unregister_animator(self)
|
|
176
|
+
|
|
177
|
+
def handle_tick(self, t: float, dt: float, frame_num: int) -> None:
|
|
178
|
+
"""Called by Session when an ANIM_TICK is received for this figure."""
|
|
179
|
+
if self._on_tick is not None:
|
|
180
|
+
self._on_tick(t, dt, frame_num)
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"""Axes proxy — lightweight handle to axes within a figure."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, List, Union
|
|
6
|
+
|
|
7
|
+
from . import _protocol as P
|
|
8
|
+
from . import _codec as codec
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from ._session import Session
|
|
12
|
+
from ._series import Series
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Axes:
|
|
16
|
+
"""Proxy for an axes object within a figure.
|
|
17
|
+
|
|
18
|
+
All mutations are sent to the backend via IPC.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
__slots__ = ("_session", "_figure_id", "_index", "_series_list", "_is_3d")
|
|
22
|
+
|
|
23
|
+
def __init__(self, session: Session, figure_id: int, axes_index: int, is_3d: bool = False) -> None:
|
|
24
|
+
self._session = session
|
|
25
|
+
self._figure_id = figure_id
|
|
26
|
+
self._index = axes_index
|
|
27
|
+
self._series_list: List[Series] = []
|
|
28
|
+
self._is_3d = is_3d
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def index(self) -> int:
|
|
32
|
+
return self._index
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def figure_id(self) -> int:
|
|
36
|
+
return self._figure_id
|
|
37
|
+
|
|
38
|
+
def line(
|
|
39
|
+
self,
|
|
40
|
+
x: Union[List[float], "object"],
|
|
41
|
+
y: Union[List[float], "object"],
|
|
42
|
+
label: str = "",
|
|
43
|
+
) -> Series:
|
|
44
|
+
"""Add a line series to this axes."""
|
|
45
|
+
return self._add_series("line", x, y, label)
|
|
46
|
+
|
|
47
|
+
def scatter(
|
|
48
|
+
self,
|
|
49
|
+
x: Union[List[float], "object"],
|
|
50
|
+
y: Union[List[float], "object"],
|
|
51
|
+
label: str = "",
|
|
52
|
+
) -> Series:
|
|
53
|
+
"""Add a scatter series to this axes."""
|
|
54
|
+
return self._add_series("scatter", x, y, label)
|
|
55
|
+
|
|
56
|
+
def _add_series(
|
|
57
|
+
self,
|
|
58
|
+
series_type: str,
|
|
59
|
+
x: Union[List[float], "object"],
|
|
60
|
+
y: Union[List[float], "object"],
|
|
61
|
+
label: str = "",
|
|
62
|
+
) -> Series:
|
|
63
|
+
from ._series import Series
|
|
64
|
+
|
|
65
|
+
# Request series creation
|
|
66
|
+
payload = codec.encode_req_add_series(
|
|
67
|
+
figure_id=self._figure_id,
|
|
68
|
+
axes_index=self._index,
|
|
69
|
+
series_type=series_type,
|
|
70
|
+
label=label,
|
|
71
|
+
)
|
|
72
|
+
resp = self._session._request(P.REQ_ADD_SERIES, payload)
|
|
73
|
+
_, series_index = codec.decode_resp_series_added(resp["payload"])
|
|
74
|
+
|
|
75
|
+
series = Series(self._session, self._figure_id, series_index, series_type, label)
|
|
76
|
+
self._series_list.append(series)
|
|
77
|
+
|
|
78
|
+
# Set initial data
|
|
79
|
+
series.set_data(x, y)
|
|
80
|
+
|
|
81
|
+
return series
|
|
82
|
+
|
|
83
|
+
def _add_series_3d(
|
|
84
|
+
self,
|
|
85
|
+
series_type: str,
|
|
86
|
+
x: Union[List[float], "object"],
|
|
87
|
+
y: Union[List[float], "object"],
|
|
88
|
+
z: Union[List[float], "object"],
|
|
89
|
+
label: str = "",
|
|
90
|
+
) -> Series:
|
|
91
|
+
"""Add a 3D series and set XYZ data."""
|
|
92
|
+
from ._series import Series
|
|
93
|
+
|
|
94
|
+
payload = codec.encode_req_add_series(
|
|
95
|
+
figure_id=self._figure_id,
|
|
96
|
+
axes_index=self._index,
|
|
97
|
+
series_type=series_type,
|
|
98
|
+
label=label,
|
|
99
|
+
)
|
|
100
|
+
resp = self._session._request(P.REQ_ADD_SERIES, payload)
|
|
101
|
+
_, series_index = codec.decode_resp_series_added(resp["payload"])
|
|
102
|
+
|
|
103
|
+
series = Series(self._session, self._figure_id, series_index, series_type, label)
|
|
104
|
+
self._series_list.append(series)
|
|
105
|
+
|
|
106
|
+
series.set_data_xyz(x, y, z)
|
|
107
|
+
|
|
108
|
+
return series
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def series(self) -> List[Series]:
|
|
112
|
+
return list(self._series_list)
|
|
113
|
+
|
|
114
|
+
def set_xlim(self, xmin: float, xmax: float) -> None:
|
|
115
|
+
payload = codec.encode_req_update_property(
|
|
116
|
+
figure_id=self._figure_id,
|
|
117
|
+
axes_index=self._index,
|
|
118
|
+
prop="xlim",
|
|
119
|
+
f1=xmin,
|
|
120
|
+
f2=xmax,
|
|
121
|
+
)
|
|
122
|
+
self._session._request(P.REQ_UPDATE_PROPERTY, payload)
|
|
123
|
+
|
|
124
|
+
def set_ylim(self, ymin: float, ymax: float) -> None:
|
|
125
|
+
payload = codec.encode_req_update_property(
|
|
126
|
+
figure_id=self._figure_id,
|
|
127
|
+
axes_index=self._index,
|
|
128
|
+
prop="ylim",
|
|
129
|
+
f1=ymin,
|
|
130
|
+
f2=ymax,
|
|
131
|
+
)
|
|
132
|
+
self._session._request(P.REQ_UPDATE_PROPERTY, payload)
|
|
133
|
+
|
|
134
|
+
def set_zlim(self, zmin: float, zmax: float) -> None:
|
|
135
|
+
payload = codec.encode_req_update_property(
|
|
136
|
+
figure_id=self._figure_id,
|
|
137
|
+
axes_index=self._index,
|
|
138
|
+
prop="zlim",
|
|
139
|
+
f1=zmin,
|
|
140
|
+
f2=zmax,
|
|
141
|
+
)
|
|
142
|
+
self._session._request(P.REQ_UPDATE_PROPERTY, payload)
|
|
143
|
+
|
|
144
|
+
def set_xlabel(self, label: str) -> None:
|
|
145
|
+
payload = codec.encode_req_update_property(
|
|
146
|
+
figure_id=self._figure_id,
|
|
147
|
+
axes_index=self._index,
|
|
148
|
+
prop="xlabel",
|
|
149
|
+
str_val=label,
|
|
150
|
+
)
|
|
151
|
+
self._session._request(P.REQ_UPDATE_PROPERTY, payload)
|
|
152
|
+
|
|
153
|
+
def set_ylabel(self, label: str) -> None:
|
|
154
|
+
payload = codec.encode_req_update_property(
|
|
155
|
+
figure_id=self._figure_id,
|
|
156
|
+
axes_index=self._index,
|
|
157
|
+
prop="ylabel",
|
|
158
|
+
str_val=label,
|
|
159
|
+
)
|
|
160
|
+
self._session._request(P.REQ_UPDATE_PROPERTY, payload)
|
|
161
|
+
|
|
162
|
+
def set_title(self, title: str) -> None:
|
|
163
|
+
payload = codec.encode_req_update_property(
|
|
164
|
+
figure_id=self._figure_id,
|
|
165
|
+
axes_index=self._index,
|
|
166
|
+
prop="axes_title",
|
|
167
|
+
str_val=title,
|
|
168
|
+
)
|
|
169
|
+
self._session._request(P.REQ_UPDATE_PROPERTY, payload)
|
|
170
|
+
|
|
171
|
+
def grid(self, visible: bool = True) -> None:
|
|
172
|
+
payload = codec.encode_req_update_property(
|
|
173
|
+
figure_id=self._figure_id,
|
|
174
|
+
axes_index=self._index,
|
|
175
|
+
prop="grid",
|
|
176
|
+
bool_val=visible,
|
|
177
|
+
)
|
|
178
|
+
self._session._request(P.REQ_UPDATE_PROPERTY, payload)
|
|
179
|
+
|
|
180
|
+
def legend(self, visible: bool = True) -> None:
|
|
181
|
+
"""Toggle legend visibility on this axes."""
|
|
182
|
+
payload = codec.encode_req_update_property(
|
|
183
|
+
figure_id=self._figure_id,
|
|
184
|
+
axes_index=self._index,
|
|
185
|
+
prop="legend",
|
|
186
|
+
bool_val=visible,
|
|
187
|
+
)
|
|
188
|
+
self._session._request(P.REQ_UPDATE_PROPERTY, payload)
|
|
189
|
+
|
|
190
|
+
def remove_series(self, index: int) -> None:
|
|
191
|
+
"""Remove a series by index from this axes."""
|
|
192
|
+
payload = codec.encode_req_remove_series(
|
|
193
|
+
figure_id=self._figure_id,
|
|
194
|
+
series_index=index,
|
|
195
|
+
)
|
|
196
|
+
self._session._request(P.REQ_REMOVE_SERIES, payload)
|
|
197
|
+
self._series_list = [s for s in self._series_list if s._index != index]
|
|
198
|
+
|
|
199
|
+
def clear(self) -> None:
|
|
200
|
+
"""Remove all series from this axes."""
|
|
201
|
+
# Remove in reverse order so indices stay valid
|
|
202
|
+
for s in reversed(list(self._series_list)):
|
|
203
|
+
payload = codec.encode_req_remove_series(
|
|
204
|
+
figure_id=self._figure_id,
|
|
205
|
+
series_index=s._index,
|
|
206
|
+
)
|
|
207
|
+
self._session._request(P.REQ_REMOVE_SERIES, payload)
|
|
208
|
+
self._series_list.clear()
|
|
209
|
+
|
|
210
|
+
def batch(self) -> "_AxesBatchContext":
|
|
211
|
+
"""Context manager for batching multiple property updates into one IPC call.
|
|
212
|
+
|
|
213
|
+
Usage::
|
|
214
|
+
|
|
215
|
+
with ax.batch() as b:
|
|
216
|
+
b.set_xlim(0, 10)
|
|
217
|
+
b.set_ylim(-1, 1)
|
|
218
|
+
b.set_xlabel("Time")
|
|
219
|
+
b.set_ylabel("Value")
|
|
220
|
+
b.grid(True)
|
|
221
|
+
"""
|
|
222
|
+
return _AxesBatchContext(self)
|
|
223
|
+
|
|
224
|
+
@property
|
|
225
|
+
def is_3d(self) -> bool:
|
|
226
|
+
return getattr(self, "_is_3d", False)
|
|
227
|
+
|
|
228
|
+
def __repr__(self) -> str:
|
|
229
|
+
is_3d = getattr(self, "_is_3d", False)
|
|
230
|
+
return f"Axes(figure_id={self._figure_id}, index={self._index}, is_3d={is_3d})"
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
class _AxesBatchContext:
|
|
235
|
+
"""Collects property updates and sends them as a single REQ_UPDATE_BATCH."""
|
|
236
|
+
|
|
237
|
+
__slots__ = ("_axes", "_updates")
|
|
238
|
+
|
|
239
|
+
def __init__(self, axes: Axes) -> None:
|
|
240
|
+
self._axes = axes
|
|
241
|
+
self._updates: list = []
|
|
242
|
+
|
|
243
|
+
def __enter__(self) -> "_AxesBatchContext":
|
|
244
|
+
return self
|
|
245
|
+
|
|
246
|
+
def __exit__(self, *args) -> None:
|
|
247
|
+
if self._updates:
|
|
248
|
+
self._axes._session.batch_update(self._updates)
|
|
249
|
+
self._updates.clear()
|
|
250
|
+
|
|
251
|
+
def _add(self, **kwargs) -> None:
|
|
252
|
+
kwargs.setdefault("figure_id", self._axes._figure_id)
|
|
253
|
+
kwargs.setdefault("axes_index", self._axes._index)
|
|
254
|
+
self._updates.append(kwargs)
|
|
255
|
+
|
|
256
|
+
def set_xlim(self, xmin: float, xmax: float) -> None:
|
|
257
|
+
self._add(prop="xlim", f1=xmin, f2=xmax)
|
|
258
|
+
|
|
259
|
+
def set_ylim(self, ymin: float, ymax: float) -> None:
|
|
260
|
+
self._add(prop="ylim", f1=ymin, f2=ymax)
|
|
261
|
+
|
|
262
|
+
def set_xlabel(self, label: str) -> None:
|
|
263
|
+
self._add(prop="xlabel", str_val=label)
|
|
264
|
+
|
|
265
|
+
def set_ylabel(self, label: str) -> None:
|
|
266
|
+
self._add(prop="ylabel", str_val=label)
|
|
267
|
+
|
|
268
|
+
def set_title(self, title: str) -> None:
|
|
269
|
+
self._add(prop="axes_title", str_val=title)
|
|
270
|
+
|
|
271
|
+
def grid(self, visible: bool = True) -> None:
|
|
272
|
+
self._add(prop="grid", bool_val=visible)
|
|
273
|
+
|
|
274
|
+
def legend(self, visible: bool = True) -> None:
|
|
275
|
+
self._add(prop="legend", bool_val=visible)
|