better-rtplot 0.2.0__tar.gz → 0.2.1__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,446 @@
1
+ Metadata-Version: 2.1
2
+ Name: better-rtplot
3
+ Version: 0.2.1
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: pandas (>=1.5.3) ; extra == "server" or extra == "browser"
20
+ Requires-Dist: pyarrow (>=11.0.0) ; extra == "server" or extra == "browser"
21
+ Requires-Dist: pyqtgraph (>=0.13.0) ; extra == "server"
22
+ Requires-Dist: pyside6 (>6.4.0) ; extra == "server"
23
+ Requires-Dist: pyzmq (>=25.0.0)
24
+ Description-Content-Type: text/markdown
25
+
26
+ ![Logo of the project](https://github.com/jmontp/rtplot/blob/master/.images/signature-stationery.png)
27
+
28
+ # rtplot — real-time plotting over ZMQ
29
+
30
+ **rtplot** lets a Python script push live data to a plot window — locally, or
31
+ across the network — with a few lines of code on the sender side. The plot
32
+ window can be a traditional Qt application or a modern browser UI, and it
33
+ also supports interactive controls (buttons, sliders, dials, text and
34
+ numeric displays) that feed values back into the sending script in real time.
35
+
36
+ Typical use: a robot or data-acquisition script runs on a Raspberry Pi or
37
+ microcontroller host, and you watch live signals and tweak gains from a
38
+ laptop on the same network.
39
+
40
+ ---
41
+
42
+ ## Table of contents
43
+
44
+ - [Highlights](#highlights)
45
+ - [Install](#install)
46
+ - [60-second quickstart](#60-second-quickstart)
47
+ - [Choosing a server: browser vs. Qt](#choosing-a-server-browser-vs-qt)
48
+ - [Interactive controls](#interactive-controls)
49
+ - [Reading controls from Python](#reading-controls-from-python)
50
+ - [Pushing values into displays](#pushing-values-into-displays)
51
+ - [Element reference](#element-reference)
52
+ - [Plot configuration](#plot-configuration)
53
+ - [Sending data](#sending-data)
54
+ - [Saving data](#saving-data)
55
+ - [Networking modes](#networking-modes)
56
+ - [Performance tuning](#performance-tuning)
57
+ - [CLI reference](#cli-reference)
58
+ - [Examples](#examples)
59
+
60
+ ---
61
+
62
+ ## Highlights
63
+
64
+ - **Fast.** 500+ fps on a single trace on a modern laptop. Binary WebSocket
65
+ deltas on the browser server; raw Qt rendering on the desktop server.
66
+ - **Two frontends.** A new browser-based server (aiohttp + uPlot) and the
67
+ original pyqtgraph desktop server. Both speak the same ZMQ protocol, so
68
+ client code is identical.
69
+ - **Remote-friendly.** Either the sender or the plot host can bind — pick
70
+ whichever fits your network. Works across LAN, WSL, and SSH tunnels.
71
+ - **Plot config lives with the data.** The sender declares the plot layout,
72
+ so a Pi running your experiment owns the look of its own dashboards.
73
+ - **Interactive controls** *(browser server only)*. Declare buttons,
74
+ sliders, dials, numeric/text displays in the same `initialize_plots`
75
+ call. Poll from your tight loop; no threads, no callbacks.
76
+ - **Save to Parquet** with a single button click or `client.save_plot()`
77
+ call.
78
+
79
+ ---
80
+
81
+ ## Install
82
+
83
+ Minimum install — just the client (send data only):
84
+
85
+ ```bash
86
+ pip install better-rtplot
87
+ ```
88
+
89
+ Add the browser server (recommended):
90
+
91
+ ```bash
92
+ pip install "better-rtplot[browser]"
93
+ ```
94
+
95
+ Add the Qt/pyqtgraph server instead:
96
+
97
+ ```bash
98
+ pip install "better-rtplot[server]"
99
+ ```
100
+
101
+ The `browser` extra pulls `aiohttp` + `pandas` + `pyarrow`; the `server`
102
+ extra pulls `pyqtgraph` + `PySide6` + `pandas` + `pyarrow`. If you only
103
+ `pip install better-rtplot` and try to launch a server, rtplot will print
104
+ a friendly message telling you which extra to add.
105
+
106
+ WSL users: the browser server works out of the box — open the URL it
107
+ prints in your Windows browser. The Qt server needs an X server such as
108
+ [VcXsrv](https://sourceforge.net/projects/vcxsrv/).
109
+
110
+ ---
111
+
112
+ ## 60-second quickstart
113
+
114
+ **Terminal 1 — start a plot window:**
115
+
116
+ ```bash
117
+ python -m rtplot.server_browser # browser UI at http://localhost:8050
118
+ # or
119
+ python -m rtplot.server # desktop Qt window
120
+ ```
121
+
122
+ **Terminal 2 — send data:**
123
+
124
+ ```python
125
+ from rtplot import client
126
+ import numpy as np, time
127
+
128
+ client.local_plot() # send to the server on 127.0.0.1
129
+ client.initialize_plots(["sin", "cos"]) # one plot with two named traces
130
+
131
+ for i in range(10000):
132
+ t = i * 0.01
133
+ client.send_array([np.sin(t), np.cos(t)])
134
+ time.sleep(0.01)
135
+ ```
136
+
137
+ That's it. Open http://localhost:8050 if you used the browser server; the
138
+ Qt server will pop up its own window.
139
+
140
+ ---
141
+
142
+ ## Choosing a server: browser vs. Qt
143
+
144
+ | | **Browser server** (`rtplot.server_browser`) | **Qt server** (`rtplot.server`) |
145
+ |---|---|---|
146
+ | Frontend | aiohttp + uPlot in any modern browser | pyqtgraph + PySide6 desktop window |
147
+ | Extra | `[browser]` | `[server]` |
148
+ | Works over SSH | Yes (just forward the HTTP port) | No (needs X forwarding) |
149
+ | Interactive controls | **Yes** — buttons, sliders, dials, displays | No |
150
+ | Typical frame rate | 60 Hz render, 1000 Hz data push cap | 500+ fps |
151
+ | Saves to Parquet | Yes | Yes |
152
+
153
+ If you're on WSL, running remotely, or you want interactive controls,
154
+ **use the browser server**. The Qt server is still available for local
155
+ desktop use and for legacy setups.
156
+
157
+ ---
158
+
159
+ ## Interactive controls
160
+
161
+ *Browser server only.* Declare a control row inline in your plot layout:
162
+
163
+ ```python
164
+ from rtplot import client
165
+ import numpy as np, time
166
+
167
+ client.local_plot()
168
+ client.initialize_plots([
169
+ {"names": ["signal"], "yrange": [-6, 6]},
170
+ {"controls": [
171
+ {"type": "button", "id": "reset", "label": "Reset"},
172
+ {"type": "button", "id": "pause", "label": "Pause"},
173
+ {"type": "slider", "id": "gain", "label": "Gain",
174
+ "min": 0, "max": 5, "value": 1.0, "step": 0.1, "format": "{:.2f}"},
175
+ ]},
176
+ {"controls": [
177
+ {"type": "dial", "id": "freq", "label": "Freq (Hz)",
178
+ "min": 0.1, "max": 5.0, "value": 1.0, "step": 0.05,
179
+ "sensitivity": 0.5, "format": "{:.2f}"},
180
+ {"type": "display", "id": "t", "label": "t (s)", "format": "{:.2f}"},
181
+ {"type": "text", "id": "msg", "label": "Status",
182
+ "value": "running"},
183
+ ]},
184
+ ])
185
+
186
+ running = True
187
+ t0 = time.time()
188
+ while True:
189
+ ctrl = client.poll_controls()
190
+ for btn in ctrl.buttons:
191
+ if btn == "reset": t0 = time.time()
192
+ if btn == "pause": running = not running
193
+
194
+ gain = ctrl.values.get("gain", 1.0)
195
+ freq = ctrl.values.get("freq", 1.0)
196
+ t = time.time() - t0
197
+ amp = gain * np.sin(2 * np.pi * freq * t) if running else 0.0
198
+
199
+ client.set_display("t", t)
200
+ client.set_display("msg", "paused" if not running else "running")
201
+ client.send_array(amp)
202
+ time.sleep(0.01)
203
+ ```
204
+
205
+ ### Reading controls from Python
206
+
207
+ ```python
208
+ ctrl = client.poll_controls() # non-blocking, cheap to call every loop
209
+ gain = ctrl.values.get("gain", 1.0) # latest slider/dial value
210
+ for btn_id in ctrl.buttons: # list of buttons fired since last poll
211
+ handle(btn_id)
212
+ ```
213
+
214
+ `poll_controls()` returns a `ControlState(values, buttons)` namedtuple:
215
+
216
+ - `values` — a `dict` of `{element_id: float}` for every slider and dial
217
+ the server has told the client about. Defaults declared in
218
+ `initialize_plots` are pre-seeded so the **first** call already sees
219
+ them.
220
+ - `buttons` — a `list` of button ids fired since the previous poll, in
221
+ order. The list is cleared on return, so each event is delivered
222
+ exactly once.
223
+
224
+ Call it from your tight loop before computing the next sample. No
225
+ threads, no callbacks, no missed events.
226
+
227
+ ### Pushing values into displays
228
+
229
+ ```python
230
+ client.set_display("t", 12.34) # numeric display box
231
+ client.set_display("msg", "running") # text field
232
+ ```
233
+
234
+ `set_display()` accepts either a number (for `type: "display"` elements)
235
+ or a string (for `type: "text"` elements). Updates are coalesced on the
236
+ server and rebroadcast to every connected browser at ~30 Hz.
237
+
238
+ ### Element reference
239
+
240
+ | Type | Purpose | Notable fields |
241
+ |---|---|---|
242
+ | `button` | Fires a discrete event when clicked | `id`, `label` |
243
+ | `slider` | Scalar input via horizontal range | `id`, `label`, `min`, `max`, `value`, `step`, `format` |
244
+ | `dial` | Scalar input via rotational drag | same as slider, plus `sensitivity` (full turns per range sweep; default `1.0`) |
245
+ | `display` | Read-only numeric readout | `id`, `label`, `format` |
246
+ | `text` | Read-only text field (prompts, status) | `id`, `label`, `value` |
247
+
248
+ Slider and dial widgets both render as **`[widget] [−] [number input] [+]`**,
249
+ so you can drag, type a value directly, or nudge by `step`. The dial
250
+ accepts "round and round" circular drag — each full rotation walks the
251
+ value through `(max − min) × sensitivity`, so `sensitivity: 0.25` gives
252
+ you four rotations per sweep for fine control.
253
+
254
+ The `format` field accepts Python-style `{:.Nf}` strings (e.g. `"{:.2f}"`).
255
+
256
+ ---
257
+
258
+ ## Plot configuration
259
+
260
+ Each entry in `initialize_plots` is one of:
261
+
262
+ - an **integer** — `client.initialize_plots(3)` → one plot with 3 anonymous
263
+ traces
264
+ - a **string** — `client.initialize_plots("torque")` → one plot with one
265
+ named trace
266
+ - a **list of strings** — one plot, one trace per name
267
+ - a **list of lists of strings** — one plot per sublist
268
+ - a **dict** — one plot, with full styling options (below)
269
+ - a **list of dicts** — multiple plots with full styling
270
+
271
+ A styled plot dict accepts any of:
272
+
273
+ | Key | Meaning |
274
+ |---|---|
275
+ | `names` | **Required.** List of trace names. |
276
+ | `colors` | List of per-trace colors. Single letter (`r g b c m y k w`) or any CSS color string. |
277
+ | `line_style` | `"-"` for dashed, `""` (or anything else) for solid, per trace. |
278
+ | `line_width` | Per-trace line width in pixels. |
279
+ | `title` | Plot title. |
280
+ | `xlabel` / `ylabel` | Axis labels. |
281
+ | `yrange` | `[ymin, ymax]` — pins the Y axis and significantly speeds up rendering. |
282
+ | `xrange` | Integer number of samples visible at once (default 200). |
283
+
284
+ Special row entries (not plots themselves):
285
+
286
+ - `{"controls": [...]}` — a row of interactive controls (browser server only)
287
+ - `{"non_plot_labels": ["name1", "name2"]}` — extra scalar names that ride
288
+ along with `send_array` and get saved into the output Parquet file, but
289
+ aren't rendered as traces
290
+
291
+ ---
292
+
293
+ ## Sending data
294
+
295
+ ```python
296
+ client.send_array(scalar) # float
297
+ client.send_array([a, b, c]) # 1-D list: one sample per trace
298
+ client.send_array(np.array([...])) # 1-D numpy array: one sample per trace
299
+ client.send_array(np.array([[...]]))# 2-D (num_traces, N): N samples at once
300
+ ```
301
+
302
+ Passing a 2-D array with `N > 1` lets you push a batch of samples per
303
+ `send_array` call, which is the fastest way to get many samples through
304
+ without dropping frames.
305
+
306
+ ---
307
+
308
+ ## Saving data
309
+
310
+ The server saves every sample it has received since the latest
311
+ `initialize_plots` call to a Parquet file, including any
312
+ `non_plot_labels` data that rode along with your normal data.
313
+
314
+ Trigger a save from either side:
315
+
316
+ - **Browser UI:** click the **Save Plot** button.
317
+ - **Python:** `client.save_plot("my_run")`
318
+
319
+ Control where things get written:
320
+
321
+ ```bash
322
+ python -m rtplot.server_browser -sd ./saved_plots -sn experiment1
323
+ ```
324
+
325
+ - `-sd` / `--save-dir` — target directory
326
+ - `-sn` / `--save-name` — filename prefix (a timestamp is always appended)
327
+
328
+ ### Save non-plot signals alongside the plotted ones
329
+
330
+ ```python
331
+ client.initialize_plots([
332
+ {"names": ["hip_angle", "knee_angle"]},
333
+ {"non_plot_labels": ["battery", "cpu_temp", "loop_latency"]},
334
+ ])
335
+ ```
336
+
337
+ Send `battery`, `cpu_temp` and `loop_latency` as extra rows after the
338
+ plotted traces in each `send_array` call; they won't be drawn but they
339
+ will land in the Parquet file.
340
+
341
+ ---
342
+
343
+ ## Networking modes
344
+
345
+ rtplot uses ZMQ, so either the sender or the plot host can be the one
346
+ that *binds* a socket. Pick whichever works for your network and
347
+ firewalls.
348
+
349
+ **Mode A — plot host binds, sender connects** *(typical for lab laptops)*
350
+
351
+ ```bash
352
+ # on the plot host (e.g. your laptop)
353
+ python -m rtplot.server_browser
354
+ ```
355
+
356
+ ```python
357
+ # on the sender (e.g. the Pi)
358
+ from rtplot import client
359
+ client.configure_ip("192.168.1.42") # the laptop's LAN IP
360
+ ```
361
+
362
+ **Mode B — sender binds, plot host connects** *(typical when the sender
363
+ has a static IP and the viewer roams around)*
364
+
365
+ ```bash
366
+ # on the plot host
367
+ python -m rtplot.server_browser -p 192.168.1.50 # the sender's IP
368
+ ```
369
+
370
+ ```python
371
+ # on the sender
372
+ from rtplot import client
373
+ # no configure_ip call needed — the default behavior binds
374
+ ```
375
+
376
+ If you pass `-p host:port` to the server, rtplot also derives the control
377
+ return-channel endpoint from that same host/port (it uses `port+1`). This
378
+ means sliders, buttons, and dials work transparently in both modes with
379
+ no extra config.
380
+
381
+ ---
382
+
383
+ ## Performance tuning
384
+
385
+ If you start running out of frames, try these, in roughly this order:
386
+
387
+ 1. **Pin the Y range.** `{"yrange": [-2, 2]}` on each plot lets the
388
+ renderer skip autoscaling work and gives the single biggest win.
389
+ 2. **Batch your samples.** Pass a 2-D numpy array to `send_array` so N
390
+ samples ship per call.
391
+ 3. **Shrink the window.** Fewer pixels to redraw per frame.
392
+ 4. **Reduce `line_width`.** Thicker lines cost more to rasterize.
393
+ 5. **Use the `-s N` / `--skip N` server flag** to push every Nth sample
394
+ batch to the browser instead of every one. Add `-a` / `--adaptable`
395
+ to let the server tune `N` to your data rate automatically.
396
+ 6. **Increase `xrange`.** Counterintuitively, a longer visible history
397
+ can be cheaper than a short one because the browser ring-buffers the
398
+ data and only replaces the tail on each push.
399
+
400
+ ---
401
+
402
+ ## CLI reference
403
+
404
+ **Browser server** (`python -m rtplot.server_browser`):
405
+
406
+ | Flag | Default | Meaning |
407
+ |---|---|---|
408
+ | `-p HOST[:PORT]` | (bind) | Connect to a sender at this address instead of binding |
409
+ | `--host HOST` | `0.0.0.0` | HTTP bind interface |
410
+ | `--port N` | `8050` | HTTP port |
411
+ | `--no-browser` | off | Don't try to open a browser on startup |
412
+ | `--rate N` | `1000` | Max WebSocket push rate (Hz) |
413
+ | `-n N` / `--skip N` | `1` | Push every Nth sample batch |
414
+ | `-a` / `--adaptable` | off | Auto-tune skip rate to data rate |
415
+ | `-c` / `--column` | row | Lay plots out in columns instead of rows |
416
+ | `-d` / `--debug` | off | Extra debug logging |
417
+ | `-sd DIR` / `--save-dir DIR` | cwd | Where to write `.parquet` saves |
418
+ | `-sn NAME` / `--save-name NAME` | — | Prefix for saved filenames |
419
+
420
+ **Qt server** (`python -m rtplot.server`): same `-p`, `-n`, `-a`, `-c`,
421
+ `-d`, `-sd`, `-sn` flags as above, plus:
422
+
423
+ | Flag | Meaning |
424
+ |---|---|
425
+ | `-b` / `--bigscreen` | Pre-configure for the neurobionics lab big-screen display |
426
+ | `-t FILE` / `--plot_config FILE` | Load a plot configuration from a file on startup |
427
+
428
+ ---
429
+
430
+ ## Examples
431
+
432
+ - [`rtplot/example_code.py`](rtplot/example_code.py) — a walk through
433
+ every `initialize_plots` signature, plus a controls demo at the bottom.
434
+ - [`rtplot/interactive_test.py`](rtplot/interactive_test.py) — a guided
435
+ end-to-end test that walks you through clicking buttons, dragging
436
+ sliders, typing into the number input, using the ± nudge arrows, and
437
+ spinning the dial. Good for smoke-testing a fresh install.
438
+
439
+ ```bash
440
+ python -m rtplot.server_browser &
441
+ python -m rtplot.interactive_test
442
+ ```
443
+
444
+ ![Qt server example 1](https://github.com/jmontp/rtplot/blob/master/.images/rtplot_example1.png)
445
+ ![Qt server example 2](https://github.com/jmontp/rtplot/blob/master/.images/rtplot_example2.png)
446
+