better-rtplot 0.3.0__tar.gz → 0.4.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.
@@ -0,0 +1,149 @@
1
+ Metadata-Version: 2.1
2
+ Name: better-rtplot
3
+ Version: 0.4.0
4
+ Summary:
5
+ License: GPL V3.0
6
+ Author: jmontp
7
+ Author-email: jmontp@umich.edu
8
+ Requires-Python: >=3.9,<3.13
9
+ Classifier: License :: Other/Proprietary License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.9
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Provides-Extra: browser
16
+ Provides-Extra: server
17
+ Requires-Dist: aiohttp (>=3.9.0) ; extra == "browser"
18
+ Requires-Dist: numpy (>=1.23.5)
19
+ Requires-Dist: pyqtgraph (>=0.13.0) ; extra == "server"
20
+ Requires-Dist: pyside6 (>6.4.0) ; extra == "server"
21
+ Requires-Dist: pyzmq (>=25.0.0)
22
+ Description-Content-Type: text/markdown
23
+
24
+ # rtplot — real-time plotting over ZMQ
25
+
26
+ **rtplot** pushes live data from a Python script to a browser plot —
27
+ locally or across a network — in a few lines of code. The plot page
28
+ also hosts interactive widgets (buttons, sliders, dials, numeric / text
29
+ displays) that feed values back to the sender in real time.
30
+
31
+ Typical use: a robot or data-acquisition script runs on a Raspberry Pi,
32
+ and you watch signals and tweak gains from a laptop on the same Wi-Fi.
33
+
34
+ ---
35
+
36
+ ## How it works
37
+
38
+ Two processes talking over ZMQ, plus a browser viewer:
39
+
40
+ ```mermaid
41
+ flowchart TD
42
+ script["Your Python script<br/>send_array() · poll_controls()"]
43
+ subgraph server["rtplot-server"]
44
+ browser["browser tab<br/>localhost:8050"]
45
+ end
46
+ script -- "data :5555" --> server
47
+ server -- "controls :5556" --> script
48
+ ```
49
+
50
+ Sender and server don't have to be on the same machine — see the
51
+ [networking guide](docs/networking.md).
52
+
53
+ ---
54
+
55
+ ## Install
56
+
57
+ **Server** — grab the prebuilt binary from the
58
+ [Releases page](https://github.com/jmontp/rtplot/releases):
59
+
60
+ | Platform | Asset |
61
+ |---|---|
62
+ | Windows | `rtplot-server-<version>-windows-x64.exe` |
63
+ | Linux | `rtplot-server-<version>-linux-x86_64.tar.gz` |
64
+ | macOS (Apple Silicon) | `rtplot-server-<version>-macos-arm64.tar.gz` |
65
+
66
+ No Python needed on the viewing machine. On Windows the binary opens
67
+ a small Tk status window showing the listening URL.
68
+
69
+ **Client** — pip install in the env that runs your script:
70
+
71
+ ```bash
72
+ pip install better-rtplot
73
+ ```
74
+
75
+ (If you'd rather run the server from Python too, use
76
+ `pip install "better-rtplot[browser]"` — see the
77
+ [API reference](docs/api.md#install-detail).)
78
+
79
+ ---
80
+
81
+ ## Your first plot
82
+
83
+ ```mermaid
84
+ flowchart TD
85
+ t1["terminal 1: start the server"]
86
+ t2["terminal 2: run your script"]
87
+ br["browser: open localhost:8050"]
88
+ t1 --> t2 --> br
89
+ ```
90
+
91
+ **Terminal 1 — start the server.** Run the prebuilt `rtplot-server`
92
+ binary you downloaded above. Open `http://localhost:8050` in a
93
+ browser — the page is blank until data arrives.
94
+
95
+ **Terminal 2 — run your sender script.** Save as `my_plot.py`:
96
+
97
+ ```python
98
+ from rtplot import client
99
+ import time
100
+
101
+ client.local_plot()
102
+ client.initialize_plots(["my signal"])
103
+
104
+ for i in range(1000):
105
+ client.send_array(i * 0.01)
106
+ time.sleep(0.01)
107
+ ```
108
+
109
+ ```bash
110
+ python my_plot.py
111
+ ```
112
+
113
+ A rising line now draws itself in the browser tab.
114
+
115
+ ---
116
+
117
+ ## Highlights
118
+
119
+ - **Fast.** Binary WebSocket deltas up to 1 kHz; the browser coalesces
120
+ samples into one repaint per `requestAnimationFrame`, so rendering
121
+ tracks your monitor refresh rate regardless of sample rate.
122
+ - **Browser-based.** aiohttp + uPlot, no desktop GUI toolkit, works
123
+ over SSH port forwarding.
124
+ - **Remote-friendly.** Sender or plot host can bind. Live Bind /
125
+ Connect buttons retarget without restart.
126
+ - **Config lives with the data.** The sender declares plot layout.
127
+ - **Interactive controls.** Buttons, sliders, dials, displays — polled
128
+ from your loop, no threads, no callbacks.
129
+ - **Static HTML snapshots.** `save_snapshot("out.html")` writes a
130
+ self-contained ~65 KB file with the current trace embedded.
131
+
132
+ ---
133
+
134
+ ## Where to go next
135
+
136
+ - **[API reference](docs/api.md)** — every `rtplot.client` function,
137
+ the plot-layout schema, interactive controls, snapshots, browser UI,
138
+ and `rtplot-server` CLI flags.
139
+ - **[Networking guide](docs/networking.md)** — Mode A vs. Mode B,
140
+ viewing from a phone or second laptop, the WSL2 wrinkle, Cloudflare
141
+ Tunnel, Tailscale.
142
+ - **[Examples](examples/README.md)** — runnable scripts with embedded
143
+ HTML snapshots you can open offline.
144
+
145
+ ---
146
+
147
+ Issues and feature requests:
148
+ [github.com/jmontp/rtplot/issues](https://github.com/jmontp/rtplot/issues).
149
+
@@ -0,0 +1,125 @@
1
+ # rtplot — real-time plotting over ZMQ
2
+
3
+ **rtplot** pushes live data from a Python script to a browser plot —
4
+ locally or across a network — in a few lines of code. The plot page
5
+ also hosts interactive widgets (buttons, sliders, dials, numeric / text
6
+ displays) that feed values back to the sender in real time.
7
+
8
+ Typical use: a robot or data-acquisition script runs on a Raspberry Pi,
9
+ and you watch signals and tweak gains from a laptop on the same Wi-Fi.
10
+
11
+ ---
12
+
13
+ ## How it works
14
+
15
+ Two processes talking over ZMQ, plus a browser viewer:
16
+
17
+ ```mermaid
18
+ flowchart TD
19
+ script["Your Python script<br/>send_array() · poll_controls()"]
20
+ subgraph server["rtplot-server"]
21
+ browser["browser tab<br/>localhost:8050"]
22
+ end
23
+ script -- "data :5555" --> server
24
+ server -- "controls :5556" --> script
25
+ ```
26
+
27
+ Sender and server don't have to be on the same machine — see the
28
+ [networking guide](docs/networking.md).
29
+
30
+ ---
31
+
32
+ ## Install
33
+
34
+ **Server** — grab the prebuilt binary from the
35
+ [Releases page](https://github.com/jmontp/rtplot/releases):
36
+
37
+ | Platform | Asset |
38
+ |---|---|
39
+ | Windows | `rtplot-server-<version>-windows-x64.exe` |
40
+ | Linux | `rtplot-server-<version>-linux-x86_64.tar.gz` |
41
+ | macOS (Apple Silicon) | `rtplot-server-<version>-macos-arm64.tar.gz` |
42
+
43
+ No Python needed on the viewing machine. On Windows the binary opens
44
+ a small Tk status window showing the listening URL.
45
+
46
+ **Client** — pip install in the env that runs your script:
47
+
48
+ ```bash
49
+ pip install better-rtplot
50
+ ```
51
+
52
+ (If you'd rather run the server from Python too, use
53
+ `pip install "better-rtplot[browser]"` — see the
54
+ [API reference](docs/api.md#install-detail).)
55
+
56
+ ---
57
+
58
+ ## Your first plot
59
+
60
+ ```mermaid
61
+ flowchart TD
62
+ t1["terminal 1: start the server"]
63
+ t2["terminal 2: run your script"]
64
+ br["browser: open localhost:8050"]
65
+ t1 --> t2 --> br
66
+ ```
67
+
68
+ **Terminal 1 — start the server.** Run the prebuilt `rtplot-server`
69
+ binary you downloaded above. Open `http://localhost:8050` in a
70
+ browser — the page is blank until data arrives.
71
+
72
+ **Terminal 2 — run your sender script.** Save as `my_plot.py`:
73
+
74
+ ```python
75
+ from rtplot import client
76
+ import time
77
+
78
+ client.local_plot()
79
+ client.initialize_plots(["my signal"])
80
+
81
+ for i in range(1000):
82
+ client.send_array(i * 0.01)
83
+ time.sleep(0.01)
84
+ ```
85
+
86
+ ```bash
87
+ python my_plot.py
88
+ ```
89
+
90
+ A rising line now draws itself in the browser tab.
91
+
92
+ ---
93
+
94
+ ## Highlights
95
+
96
+ - **Fast.** Binary WebSocket deltas up to 1 kHz; the browser coalesces
97
+ samples into one repaint per `requestAnimationFrame`, so rendering
98
+ tracks your monitor refresh rate regardless of sample rate.
99
+ - **Browser-based.** aiohttp + uPlot, no desktop GUI toolkit, works
100
+ over SSH port forwarding.
101
+ - **Remote-friendly.** Sender or plot host can bind. Live Bind /
102
+ Connect buttons retarget without restart.
103
+ - **Config lives with the data.** The sender declares plot layout.
104
+ - **Interactive controls.** Buttons, sliders, dials, displays — polled
105
+ from your loop, no threads, no callbacks.
106
+ - **Static HTML snapshots.** `save_snapshot("out.html")` writes a
107
+ self-contained ~65 KB file with the current trace embedded.
108
+
109
+ ---
110
+
111
+ ## Where to go next
112
+
113
+ - **[API reference](docs/api.md)** — every `rtplot.client` function,
114
+ the plot-layout schema, interactive controls, snapshots, browser UI,
115
+ and `rtplot-server` CLI flags.
116
+ - **[Networking guide](docs/networking.md)** — Mode A vs. Mode B,
117
+ viewing from a phone or second laptop, the WSL2 wrinkle, Cloudflare
118
+ Tunnel, Tailscale.
119
+ - **[Examples](examples/README.md)** — runnable scripts with embedded
120
+ HTML snapshots you can open offline.
121
+
122
+ ---
123
+
124
+ Issues and feature requests:
125
+ [github.com/jmontp/rtplot/issues](https://github.com/jmontp/rtplot/issues).
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "better-rtplot"
3
- version = "0.3.0"
3
+ version = "0.4.0"
4
4
  description = ""
5
5
  authors = ["jmontp <jmontp@umich.edu>"]
6
6
  license = "GPL V3.0"
@@ -2,6 +2,8 @@ import zmq
2
2
  import numpy as np
3
3
  import time
4
4
  from collections import OrderedDict, namedtuple
5
+ from dataclasses import dataclass, field
6
+ from typing import List, Optional, Tuple, Union
5
7
 
6
8
  ###################
7
9
  # ZMQ Networking #
@@ -85,19 +87,172 @@ SENDING_DISPLAY = "4"
85
87
  #Lightweight result type returned by poll_controls()
86
88
  ControlState = namedtuple("ControlState", ["values", "buttons"])
87
89
 
90
+
91
+ ##########################################
92
+ # Typed plot / controls configuration API #
93
+ ##########################################
94
+ #
95
+ # These dataclasses are an alternative to the dict-based configuration
96
+ # accepted by initialize_plots(). Both APIs coexist — the typed form
97
+ # serializes to the exact same on-the-wire dict, so the server is
98
+ # unaware of which one the caller used.
99
+ #
100
+ # client.initialize_plots([
101
+ # client.Plot(names=["signal"], yrange=(-6, 6), title="demo"),
102
+ # client.ControlsRow([client.Button("reset", "Reset")]),
103
+ # ])
104
+ #
105
+ # is equivalent to the classic:
106
+ #
107
+ # client.initialize_plots([
108
+ # {"names": ["signal"], "yrange": [-6, 6], "title": "demo"},
109
+ # {"controls": [{"type": "button", "id": "reset", "label": "Reset"}]},
110
+ # ])
111
+
112
+
113
+ def _drop_none(d):
114
+ # Wire format treats missing keys as "use default"; None would
115
+ # JSON-serialize to null which the server wouldn't handle.
116
+ return {k: v for k, v in d.items() if v is not None}
117
+
118
+
119
+ def _range_to_list(r):
120
+ # yrange/xrange go over the wire as JSON lists.
121
+ return list(r) if r is not None else None
122
+
123
+
124
+ @dataclass
125
+ class Plot:
126
+ """A single plot with optional styling.
127
+
128
+ All fields beyond ``names`` are optional and mirror the styled-plot
129
+ dict keys documented in ``docs/api.md``.
130
+ """
131
+ names: List[str]
132
+ colors: Optional[List[str]] = None
133
+ line_style: Optional[List[str]] = None
134
+ line_width: Optional[float] = None
135
+ title: Optional[str] = None
136
+ xlabel: Optional[str] = None
137
+ ylabel: Optional[str] = None
138
+ yrange: Optional[Tuple[float, float]] = None
139
+ xrange: Optional[int] = None
140
+ height: Optional[float] = None
141
+
142
+ def to_dict(self):
143
+ return _drop_none({
144
+ "names": list(self.names),
145
+ "colors": list(self.colors) if self.colors is not None else None,
146
+ "line_style": list(self.line_style) if self.line_style is not None else None,
147
+ "line_width": self.line_width,
148
+ "title": self.title,
149
+ "xlabel": self.xlabel,
150
+ "ylabel": self.ylabel,
151
+ "yrange": _range_to_list(self.yrange),
152
+ "xrange": self.xrange,
153
+ "height": self.height,
154
+ })
155
+
156
+
157
+ @dataclass
158
+ class Button:
159
+ id: str
160
+ label: str
161
+ height: Optional[float] = None
162
+
163
+ def to_dict(self):
164
+ return _drop_none({
165
+ "type": "button", "id": self.id, "label": self.label,
166
+ "height": self.height,
167
+ })
168
+
169
+
170
+ @dataclass
171
+ class Slider:
172
+ id: str
173
+ label: str
174
+ min: float
175
+ max: float
176
+ value: float = 0.0
177
+ step: Optional[float] = None
178
+ format: Optional[str] = None
179
+ height: Optional[float] = None
180
+
181
+ def to_dict(self):
182
+ return _drop_none({
183
+ "type": "slider", "id": self.id, "label": self.label,
184
+ "min": self.min, "max": self.max, "value": self.value,
185
+ "step": self.step, "format": self.format, "height": self.height,
186
+ })
187
+
188
+
189
+ @dataclass
190
+ class Dial:
191
+ id: str
192
+ label: str
193
+ min: float
194
+ max: float
195
+ value: float = 0.0
196
+ step: Optional[float] = None
197
+ sensitivity: Optional[float] = None
198
+ format: Optional[str] = None
199
+ height: Optional[float] = None
200
+
201
+ def to_dict(self):
202
+ return _drop_none({
203
+ "type": "dial", "id": self.id, "label": self.label,
204
+ "min": self.min, "max": self.max, "value": self.value,
205
+ "step": self.step, "sensitivity": self.sensitivity,
206
+ "format": self.format, "height": self.height,
207
+ })
208
+
209
+
210
+ @dataclass
211
+ class Display:
212
+ id: str
213
+ label: str
214
+ format: Optional[str] = None
215
+ height: Optional[float] = None
216
+
217
+ def to_dict(self):
218
+ return _drop_none({
219
+ "type": "display", "id": self.id, "label": self.label,
220
+ "format": self.format, "height": self.height,
221
+ })
222
+
223
+
224
+ @dataclass
225
+ class Text:
226
+ id: str
227
+ label: str
228
+ value: str = ""
229
+ height: Optional[float] = None
230
+
231
+ def to_dict(self):
232
+ return _drop_none({
233
+ "type": "text", "id": self.id, "label": self.label,
234
+ "value": self.value, "height": self.height,
235
+ })
236
+
237
+
238
+ @dataclass
239
+ class ControlsRow:
240
+ """A row of control widgets, rendered in place of a plot."""
241
+ controls: List[Union[Button, Slider, Dial, Display, Text, dict]] = field(default_factory=list)
242
+
243
+ def to_dict(self):
244
+ return {"controls": [
245
+ c.to_dict() if hasattr(c, "to_dict") else c
246
+ for c in self.controls
247
+ ]}
248
+
249
+
88
250
  def local_plot():
89
251
  """Send data to a plot in the same computer"""
90
252
 
91
253
  local_address = "tcp://127.0.0.1:5555"
92
254
  configure_ip(ip = local_address)
93
255
 
94
- def plot_to_neurobionics_tv():
95
- """Send data to a plot in the same computer"""
96
-
97
- tv_computer_address = "tcp://141.212.77.23:5555"
98
- configure_ip(ip = tv_computer_address)
99
-
100
-
101
256
  def configure_port(new_port:int):
102
257
  """Rebind the local publisher on ``new_port`` (bind mode only).
103
258
 
@@ -277,6 +432,11 @@ def initialize_plots(plot_descriptions=1):
277
432
  plot_desc_dict = OrderedDict()
278
433
  plot_desc_dict["plot0"] = plot_descriptions
279
434
 
435
+ #Process typed inputs (Plot / ControlsRow) passed alone
436
+ elif hasattr(plot_descriptions, "to_dict"):
437
+ plot_desc_dict = OrderedDict()
438
+ plot_desc_dict["plot0"] = plot_descriptions.to_dict()
439
+
280
440
  #Process lists of things
281
441
  elif isinstance(plot_descriptions, list):
282
442
 
@@ -291,10 +451,14 @@ def initialize_plots(plot_descriptions=1):
291
451
  for i,plot_desc in enumerate(plot_descriptions):
292
452
  plot_desc_dict["plot{}".format(i)] = {"names":plot_desc}
293
453
 
294
- #Process list of dics
295
- elif isinstance(plot_descriptions[0],dict):
454
+ #Process list of dicts or typed objects (Plot / ControlsRow),
455
+ # including mixed lists — anything exposing .to_dict() is normalized
456
+ # to the wire dict form.
457
+ elif isinstance(plot_descriptions[0],dict) or hasattr(plot_descriptions[0], "to_dict"):
296
458
  plot_desc_dict = OrderedDict()
297
459
  for i,plot_desc in enumerate(plot_descriptions):
460
+ if hasattr(plot_desc, "to_dict"):
461
+ plot_desc = plot_desc.to_dict()
298
462
  plot_desc_dict["plot{}".format(i)] = plot_desc
299
463
 
300
464
  #Throw error