better-rtplot 0.2.0__tar.gz → 0.2.2__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,538 @@
1
+ Metadata-Version: 2.1
2
+ Name: better-rtplot
3
+ Version: 0.2.2
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 runs in any modern browser and supports interactive controls
33
+ (buttons, sliders, dials, text and numeric displays) that feed values
34
+ 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
+ - [Interactive controls](#interactive-controls)
48
+ - [Reading controls from Python](#reading-controls-from-python)
49
+ - [Pushing values into displays](#pushing-values-into-displays)
50
+ - [Element reference](#element-reference)
51
+ - [Plot configuration](#plot-configuration)
52
+ - [Sending data](#sending-data)
53
+ - [Saving data](#saving-data)
54
+ - [Networking modes](#networking-modes)
55
+ - [Viewing the plot from another device](#viewing-the-plot-from-another-device)
56
+ - [Performance tuning](#performance-tuning)
57
+ - [CLI reference](#cli-reference)
58
+ - [Examples](#examples)
59
+
60
+ ---
61
+
62
+ ## Highlights
63
+
64
+ - **Fast.** Binary WebSocket deltas push data at up to 1 kHz. The
65
+ browser coalesces incoming samples into a single repaint per
66
+ `requestAnimationFrame`, so rendering runs at your monitor's refresh
67
+ rate (typically 60 Hz, 120 Hz on higher-refresh displays) regardless
68
+ of how fast samples arrive.
69
+ - **Browser-based.** The plot window is served by aiohttp and rendered
70
+ by uPlot in any modern browser. No desktop GUI toolkit to install,
71
+ works over SSH port forwarding out of the box.
72
+ - **Remote-friendly.** Either the sender or the plot host can bind —
73
+ pick whichever fits your network. Works across LAN, WSL, and SSH
74
+ tunnels.
75
+ - **Plot config lives with the data.** The sender declares the plot
76
+ layout, so a Pi running your experiment owns the look of its own
77
+ dashboards.
78
+ - **Interactive controls.** Declare buttons, sliders, dials,
79
+ numeric/text displays in the same `initialize_plots` call. Poll from
80
+ your tight loop; no threads, no callbacks.
81
+ - **Save to Parquet** with a single button click or `client.save_plot()`
82
+ call.
83
+
84
+ ---
85
+
86
+ ## Install
87
+
88
+ Install rtplot with the server bundle — this is the normal path and
89
+ gets you everything:
90
+
91
+ ```bash
92
+ pip install "better-rtplot[browser]"
93
+ ```
94
+
95
+ This pulls `aiohttp` (for serving the plot UI) plus `pandas` + `pyarrow`
96
+ (for saving runs to Parquet). If you only need the sender side — your
97
+ script pushes data to someone else's plot host and you don't run a
98
+ server locally — you can install the client-only minimum instead:
99
+
100
+ ```bash
101
+ pip install better-rtplot
102
+ ```
103
+
104
+ In that case, if you later try to launch a server locally you'll get a
105
+ clear error telling you to add the `[browser]` extra.
106
+
107
+ WSL users: nothing extra needed. The plot window is served by HTTP, so
108
+ just open the URL rtplot prints in your Windows browser.
109
+
110
+ ---
111
+
112
+ ## 60-second quickstart
113
+
114
+ **Terminal 1 — start the plot server:**
115
+
116
+ ```bash
117
+ python -m rtplot.server_browser
118
+ ```
119
+
120
+ It prints a URL like `http://localhost:8050` — open that in your
121
+ browser. The page stays blank until a client sends a plot config.
122
+
123
+ **Terminal 2 — send data:**
124
+
125
+ ```python
126
+ from rtplot import client
127
+ import numpy as np, time
128
+
129
+ client.local_plot() # send to the server on 127.0.0.1
130
+ client.initialize_plots(["sin", "cos"]) # one plot with two named traces
131
+
132
+ for i in range(10000):
133
+ t = i * 0.01
134
+ client.send_array([np.sin(t), np.cos(t)])
135
+ time.sleep(0.01)
136
+ ```
137
+
138
+ That's it. The browser tab you opened will start drawing the two
139
+ traces in real time.
140
+
141
+ ---
142
+
143
+ ## Interactive controls
144
+
145
+ Declare a control row inline in your plot layout:
146
+
147
+ ```python
148
+ from rtplot import client
149
+ import numpy as np, time
150
+
151
+ client.local_plot()
152
+ client.initialize_plots([
153
+ {"names": ["signal"], "yrange": [-6, 6]},
154
+ {"controls": [
155
+ {"type": "button", "id": "reset", "label": "Reset"},
156
+ {"type": "button", "id": "pause", "label": "Pause"},
157
+ {"type": "slider", "id": "gain", "label": "Gain",
158
+ "min": 0, "max": 5, "value": 1.0, "step": 0.1, "format": "{:.2f}"},
159
+ ]},
160
+ {"controls": [
161
+ {"type": "dial", "id": "freq", "label": "Freq (Hz)",
162
+ "min": 0.1, "max": 5.0, "value": 1.0, "step": 0.05,
163
+ "sensitivity": 0.5, "format": "{:.2f}"},
164
+ {"type": "display", "id": "t", "label": "t (s)", "format": "{:.2f}"},
165
+ {"type": "text", "id": "msg", "label": "Status",
166
+ "value": "running"},
167
+ ]},
168
+ ])
169
+
170
+ running = True
171
+ t0 = time.time()
172
+ while True:
173
+ ctrl = client.poll_controls()
174
+ for btn in ctrl.buttons:
175
+ if btn == "reset": t0 = time.time()
176
+ if btn == "pause": running = not running
177
+
178
+ gain = ctrl.values.get("gain", 1.0)
179
+ freq = ctrl.values.get("freq", 1.0)
180
+ t = time.time() - t0
181
+ amp = gain * np.sin(2 * np.pi * freq * t) if running else 0.0
182
+
183
+ client.set_display("t", t)
184
+ client.set_display("msg", "paused" if not running else "running")
185
+ client.send_array(amp)
186
+ time.sleep(0.01)
187
+ ```
188
+
189
+ ### Reading controls from Python
190
+
191
+ ```python
192
+ ctrl = client.poll_controls() # non-blocking, cheap to call every loop
193
+ gain = ctrl.values.get("gain", 1.0) # latest slider/dial value
194
+ for btn_id in ctrl.buttons: # list of buttons fired since last poll
195
+ handle(btn_id)
196
+ ```
197
+
198
+ `poll_controls()` returns a `ControlState(values, buttons)` namedtuple:
199
+
200
+ - `values` — a `dict` of `{element_id: float}` for every slider and dial
201
+ the server has told the client about. Defaults declared in
202
+ `initialize_plots` are pre-seeded so the **first** call already sees
203
+ them.
204
+ - `buttons` — a `list` of button ids fired since the previous poll, in
205
+ order. The list is cleared on return, so each event is delivered
206
+ exactly once.
207
+
208
+ Call it from your tight loop before computing the next sample. No
209
+ threads, no callbacks, no missed events.
210
+
211
+ ### Pushing values into displays
212
+
213
+ ```python
214
+ client.set_display("t", 12.34) # numeric display box
215
+ client.set_display("msg", "running") # text field
216
+ ```
217
+
218
+ `set_display()` accepts either a number (for `type: "display"` elements)
219
+ or a string (for `type: "text"` elements). Updates are coalesced on the
220
+ server and rebroadcast to every connected browser at ~30 Hz.
221
+
222
+ ### Element reference
223
+
224
+ | Type | Purpose | Notable fields |
225
+ |---|---|---|
226
+ | `button` | Fires a discrete event when clicked | `id`, `label` |
227
+ | `slider` | Scalar input via horizontal range | `id`, `label`, `min`, `max`, `value`, `step`, `format` |
228
+ | `dial` | Scalar input via rotational drag | same as slider, plus `sensitivity` (full turns per range sweep; default `1.0`) |
229
+ | `display` | Read-only numeric readout | `id`, `label`, `format` |
230
+ | `text` | Read-only text field (prompts, status) | `id`, `label`, `value` |
231
+
232
+ Slider and dial widgets both render as **`[widget] [−] [number input] [+]`**,
233
+ so you can drag, type a value directly, or nudge by `step`. The dial
234
+ accepts "round and round" circular drag — each full rotation walks the
235
+ value through `(max − min) × sensitivity`, so `sensitivity: 0.25` gives
236
+ you four rotations per sweep for fine control.
237
+
238
+ The `format` field accepts Python-style `{:.Nf}` strings (e.g. `"{:.2f}"`).
239
+
240
+ ---
241
+
242
+ ## Plot configuration
243
+
244
+ Each entry in `initialize_plots` is one of:
245
+
246
+ - an **integer** — `client.initialize_plots(3)` → one plot with 3 anonymous
247
+ traces
248
+ - a **string** — `client.initialize_plots("torque")` → one plot with one
249
+ named trace
250
+ - a **list of strings** — one plot, one trace per name
251
+ - a **list of lists of strings** — one plot per sublist
252
+ - a **dict** — one plot, with full styling options (below)
253
+ - a **list of dicts** — multiple plots with full styling
254
+
255
+ A styled plot dict accepts any of:
256
+
257
+ | Key | Meaning |
258
+ |---|---|
259
+ | `names` | **Required.** List of trace names. |
260
+ | `colors` | List of per-trace colors. Single letter (`r g b c m y k w`) or any CSS color string. |
261
+ | `line_style` | `"-"` for dashed, `""` (or anything else) for solid, per trace. |
262
+ | `line_width` | Per-trace line width in pixels. |
263
+ | `title` | Plot title. |
264
+ | `xlabel` / `ylabel` | Axis labels. |
265
+ | `yrange` | `[ymin, ymax]` — pins the Y axis and significantly speeds up rendering. |
266
+ | `xrange` | Integer number of samples visible at once (default 200). |
267
+
268
+ Special row entries (not plots themselves):
269
+
270
+ - `{"controls": [...]}` — a row of interactive controls (see
271
+ [Interactive controls](#interactive-controls))
272
+ - `{"non_plot_labels": ["name1", "name2"]}` — extra scalar names that ride
273
+ along with `send_array` and get saved into the output Parquet file, but
274
+ aren't rendered as traces
275
+
276
+ ---
277
+
278
+ ## Sending data
279
+
280
+ ```python
281
+ client.send_array(scalar) # float
282
+ client.send_array([a, b, c]) # 1-D list: one sample per trace
283
+ client.send_array(np.array([...])) # 1-D numpy array: one sample per trace
284
+ client.send_array(np.array([[...]]))# 2-D (num_traces, N): N samples at once
285
+ ```
286
+
287
+ Passing a 2-D array with `N > 1` lets you push a batch of samples per
288
+ `send_array` call, which is the fastest way to get many samples through
289
+ without dropping frames.
290
+
291
+ ---
292
+
293
+ ## Saving data
294
+
295
+ The server saves every sample it has received since the latest
296
+ `initialize_plots` call to a Parquet file, including any
297
+ `non_plot_labels` data that rode along with your normal data.
298
+
299
+ Trigger a save from either side:
300
+
301
+ - **Browser UI:** click the **Save Plot** button.
302
+ - **Python:** `client.save_plot("my_run")`
303
+
304
+ Control where things get written:
305
+
306
+ ```bash
307
+ python -m rtplot.server_browser -sd ./saved_plots -sn experiment1
308
+ ```
309
+
310
+ - `-sd` / `--save-dir` — target directory
311
+ - `-sn` / `--save-name` — filename prefix (a timestamp is always appended)
312
+
313
+ ### Save non-plot signals alongside the plotted ones
314
+
315
+ ```python
316
+ client.initialize_plots([
317
+ {"names": ["hip_angle", "knee_angle"]},
318
+ {"non_plot_labels": ["battery", "cpu_temp", "loop_latency"]},
319
+ ])
320
+ ```
321
+
322
+ Send `battery`, `cpu_temp` and `loop_latency` as extra rows after the
323
+ plotted traces in each `send_array` call; they won't be drawn but they
324
+ will land in the Parquet file.
325
+
326
+ ---
327
+
328
+ ## Networking modes
329
+
330
+ rtplot uses ZMQ, so either the sender or the plot host can be the one
331
+ that *binds* a socket. Pick whichever works for your network and
332
+ firewalls.
333
+
334
+ **Mode A — plot host binds, sender connects** *(typical for lab laptops)*
335
+
336
+ ```bash
337
+ # on the plot host (e.g. your laptop)
338
+ python -m rtplot.server_browser
339
+ ```
340
+
341
+ ```python
342
+ # on the sender (e.g. the Pi)
343
+ from rtplot import client
344
+ client.configure_ip("192.168.1.42") # the laptop's LAN IP
345
+ ```
346
+
347
+ **Mode B — sender binds, plot host connects** *(typical when the sender
348
+ has a static IP and the viewer roams around)*
349
+
350
+ ```bash
351
+ # on the plot host
352
+ python -m rtplot.server_browser -p 192.168.1.50 # the sender's IP
353
+ ```
354
+
355
+ ```python
356
+ # on the sender
357
+ from rtplot import client
358
+ # no configure_ip call needed — the default behavior binds
359
+ ```
360
+
361
+ If you pass `-p host:port` to the server, rtplot also derives the control
362
+ return-channel endpoint from that same host/port (it uses `port+1`). This
363
+ means sliders, buttons, and dials work transparently in both modes with
364
+ no extra config.
365
+
366
+ ---
367
+
368
+ ## Viewing the plot from another device
369
+
370
+ The section above is about the link between your *sender script* and the
371
+ *plot host* (the machine running `rtplot.server_browser`). This section
372
+ is about the other relationship: the link between the plot host and a
373
+ separate *viewer device* — a phone, tablet, or another laptop that just
374
+ wants to open the browser UI.
375
+
376
+ **You don't need SSH for this.** The plot host already runs a plain HTTP
377
+ server on port `8050`, bound to every interface, and the viewer device
378
+ is only a web browser. All you need to do is get traffic from the
379
+ viewer to port `8050` on the plot host.
380
+
381
+ ### On the same LAN (phone, tablet, another laptop on the same Wi-Fi)
382
+
383
+ 1. Find the plot host's LAN IP:
384
+
385
+ ```powershell
386
+ ipconfig | findstr IPv4 # Windows
387
+ ```
388
+ ```bash
389
+ ip -4 addr | grep inet # Linux/WSL
390
+ ```
391
+
392
+ 2. Open `http://<lan_ip>:8050` in the browser on the viewer device.
393
+
394
+ 3. If Windows, allow inbound connections on port `8050` through Windows
395
+ Defender Firewall. The very first time you run
396
+ `python -m rtplot.server_browser`, Windows pops up an "Allow Python to
397
+ receive connections" dialog — tick **Private networks** and click
398
+ **Allow**. If you missed the dialog, add the rule manually from an
399
+ elevated PowerShell:
400
+
401
+ ```powershell
402
+ # PowerShell as Administrator
403
+ New-NetFirewallRule -DisplayName "rtplot" `
404
+ -Direction Inbound -LocalPort 8050 -Protocol TCP `
405
+ -Action Allow -Profile Private
406
+ ```
407
+
408
+ Only allow on **Private** (home / trusted Wi-Fi), not **Public**,
409
+ unless you know what you're doing. To remove the rule later:
410
+
411
+ ```powershell
412
+ Remove-NetFirewallRule -DisplayName "rtplot"
413
+ ```
414
+
415
+ No router configuration, no SSH tunneling, no external accounts. Just a
416
+ firewall exception.
417
+
418
+ ### WSL2 wrinkle
419
+
420
+ If you run the server inside WSL2 instead of native Windows, WSL2's
421
+ `localhost` auto-forward lets **you** reach it from your Windows browser,
422
+ but does **not** forward traffic from the LAN. To expose a WSL2-hosted
423
+ server to other devices you need one extra hop — a Windows-side port
424
+ proxy that forwards incoming LAN traffic into WSL2:
425
+
426
+ ```powershell
427
+ # PowerShell as Administrator
428
+ $wslIp = (wsl hostname -I).Trim().Split()[0]
429
+ netsh interface portproxy add v4tov4 `
430
+ listenport=8050 listenaddress=0.0.0.0 `
431
+ connectport=8050 connectaddress=$wslIp
432
+ New-NetFirewallRule -DisplayName "rtplot wsl" `
433
+ -Direction Inbound -LocalPort 8050 -Protocol TCP `
434
+ -Action Allow -Profile Private
435
+ ```
436
+
437
+ WSL2's IP changes on every reboot, so rerun the `netsh` line after a
438
+ restart (or just run `rtplot.server_browser` from native Windows and
439
+ skip this whole step).
440
+
441
+ To undo:
442
+ ```powershell
443
+ netsh interface portproxy delete v4tov4 listenport=8050 listenaddress=0.0.0.0
444
+ Remove-NetFirewallRule -DisplayName "rtplot wsl"
445
+ ```
446
+
447
+ ### Across the internet (viewer on cellular, another network, etc.)
448
+
449
+ Two easy options, neither of which requires touching your router:
450
+
451
+ **Cloudflare Tunnel** (free, one-shot URL):
452
+
453
+ ```powershell
454
+ winget install --id Cloudflare.cloudflared
455
+ cloudflared tunnel --url http://localhost:8050
456
+ ```
457
+
458
+ Prints an `https://<random>.trycloudflare.com` URL valid for the
459
+ lifetime of the command — paste it into the viewer's browser. Kill the
460
+ command when you're done.
461
+
462
+ **Tailscale** (private mesh VPN, best for recurring setups):
463
+
464
+ Install [Tailscale](https://tailscale.com) on both the plot host and
465
+ every viewer device. Each device gets a stable `100.x.y.z` IP that
466
+ works from any network. Open `http://100.x.y.z:8050` on the viewer.
467
+
468
+ Both tunnel paths forward the HTTP + WebSocket traffic that the browser
469
+ needs; neither involves ZMQ, since the viewer is browser-only. Your
470
+ sender script keeps talking to the plot host locally as usual.
471
+
472
+ ### Ports at a glance
473
+
474
+ | Port | What it's for | Who actually needs it open |
475
+ |---|---|---|
476
+ | `8050` (TCP) | HTTP + WebSocket to the browser UI | the plot host, inbound from viewers |
477
+ | `5555` (TCP) | ZMQ data (sender → server) | only the sender and the plot host |
478
+ | `5556` (TCP) | ZMQ control return channel (server → sender) | only the sender and the plot host |
479
+
480
+ For the "other device is a viewer" case, you only need to expose `8050`.
481
+ `5555` / `5556` are between the sender script and the plot host — they
482
+ do not need to be reachable from the viewer device at all.
483
+
484
+ ---
485
+
486
+ ## Performance tuning
487
+
488
+ If you start running out of frames, try these, in roughly this order:
489
+
490
+ 1. **Pin the Y range.** `{"yrange": [-2, 2]}` on each plot lets the
491
+ renderer skip autoscaling work and gives the single biggest win.
492
+ 2. **Batch your samples.** Pass a 2-D numpy array to `send_array` so N
493
+ samples ship per call.
494
+ 3. **Shrink the window.** Fewer pixels to redraw per frame.
495
+ 4. **Reduce `line_width`.** Thicker lines cost more to rasterize.
496
+ 5. **Use the `-s N` / `--skip N` server flag** to push every Nth sample
497
+ batch to the browser instead of every one. Add `-a` / `--adaptable`
498
+ to let the server tune `N` to your data rate automatically.
499
+ 6. **Increase `xrange`.** Counterintuitively, a longer visible history
500
+ can be cheaper than a short one because the browser ring-buffers the
501
+ data and only replaces the tail on each push.
502
+
503
+ ---
504
+
505
+ ## CLI reference
506
+
507
+ `python -m rtplot.server_browser` accepts:
508
+
509
+ | Flag | Default | Meaning |
510
+ |---|---|---|
511
+ | `-p HOST[:PORT]` | (bind) | Connect to a sender at this address instead of binding |
512
+ | `--host HOST` | `0.0.0.0` | HTTP bind interface |
513
+ | `--port N` | `8050` | HTTP port |
514
+ | `--no-browser` | off | Don't try to open a browser on startup |
515
+ | `--rate N` | `1000` | Max WebSocket push rate (Hz) |
516
+ | `-n N` / `--skip N` | `1` | Push every Nth sample batch |
517
+ | `-a` / `--adaptable` | off | Auto-tune skip rate to data rate |
518
+ | `-c` / `--column` | row | Lay plots out in columns instead of rows |
519
+ | `-d` / `--debug` | off | Extra debug logging |
520
+ | `-sd DIR` / `--save-dir DIR` | cwd | Where to write `.parquet` saves |
521
+ | `-sn NAME` / `--save-name NAME` | — | Prefix for saved filenames |
522
+
523
+ ---
524
+
525
+ ## Examples
526
+
527
+ - [`rtplot/example_code.py`](rtplot/example_code.py) — a walk through
528
+ every `initialize_plots` signature, plus a controls demo at the bottom.
529
+ - [`rtplot/interactive_test.py`](rtplot/interactive_test.py) — a guided
530
+ end-to-end test that walks you through clicking buttons, dragging
531
+ sliders, typing into the number input, using the ± nudge arrows, and
532
+ spinning the dial. Good for smoke-testing a fresh install.
533
+
534
+ ```bash
535
+ python -m rtplot.server_browser &
536
+ python -m rtplot.interactive_test
537
+ ```
538
+