No project description provided
Project description
rtplot — real-time plotting over ZMQ
rtplot lets a Python script push live data to a plot window — locally, or across the network — with a few lines of code on the sender side. The plot window runs in any modern browser and supports interactive controls (buttons, sliders, dials, text and numeric displays) that feed values back into the sending script in real time.
Typical use: a robot or data-acquisition script runs on a Raspberry Pi or microcontroller host, and you watch live signals and tweak gains from a laptop on the same network.
Table of contents
- Highlights
- Install
- 60-second quickstart
- Interactive controls
- Plot configuration
- Sending data
- Saving data
- Networking modes
- Viewing the plot from another device
- Performance tuning
- CLI reference
- Examples
Highlights
- Fast. Binary WebSocket deltas push data at up to 1 kHz. The
browser coalesces incoming samples into a single repaint per
requestAnimationFrame, so rendering runs at your monitor's refresh rate (typically 60 Hz, 120 Hz on higher-refresh displays) regardless of how fast samples arrive. - Browser-based. The plot window is served by aiohttp and rendered by uPlot in any modern browser. No desktop GUI toolkit to install, works over SSH port forwarding out of the box.
- Remote-friendly. Either the sender or the plot host can bind — pick whichever fits your network. Works across LAN, WSL, and SSH tunnels.
- Plot config lives with the data. The sender declares the plot layout, so a Pi running your experiment owns the look of its own dashboards.
- Interactive controls. Declare buttons, sliders, dials,
numeric/text displays in the same
initialize_plotscall. Poll from your tight loop; no threads, no callbacks. - Save to Parquet with a single button click or
client.save_plot()call.
Install
Install rtplot with the server bundle — this is the normal path and gets you everything:
pip install "better-rtplot[browser]"
This pulls aiohttp (for serving the plot UI) plus pandas + pyarrow
(for saving runs to Parquet). If you only need the sender side — your
script pushes data to someone else's plot host and you don't run a
server locally — you can install the client-only minimum instead:
pip install better-rtplot
In that case, if you later try to launch a server locally you'll get a
clear error telling you to add the [browser] extra.
WSL users: nothing extra needed. The plot window is served by HTTP, so just open the URL rtplot prints in your Windows browser.
60-second quickstart
Terminal 1 — start the plot server:
python -m rtplot.server_browser
It prints a URL like http://localhost:8050 — open that in your
browser. The page stays blank until a client sends a plot config.
Terminal 2 — send data:
from rtplot import client
import numpy as np, time
client.local_plot() # send to the server on 127.0.0.1
client.initialize_plots(["sin", "cos"]) # one plot with two named traces
for i in range(10000):
t = i * 0.01
client.send_array([np.sin(t), np.cos(t)])
time.sleep(0.01)
That's it. The browser tab you opened will start drawing the two traces in real time.
Interactive controls
Declare a control row inline in your plot layout:
from rtplot import client
import numpy as np, time
client.local_plot()
client.initialize_plots([
{"names": ["signal"], "yrange": [-6, 6]},
{"controls": [
{"type": "button", "id": "reset", "label": "Reset"},
{"type": "button", "id": "pause", "label": "Pause"},
{"type": "slider", "id": "gain", "label": "Gain",
"min": 0, "max": 5, "value": 1.0, "step": 0.1, "format": "{:.2f}"},
]},
{"controls": [
{"type": "dial", "id": "freq", "label": "Freq (Hz)",
"min": 0.1, "max": 5.0, "value": 1.0, "step": 0.05,
"sensitivity": 0.5, "format": "{:.2f}"},
{"type": "display", "id": "t", "label": "t (s)", "format": "{:.2f}"},
{"type": "text", "id": "msg", "label": "Status",
"value": "running"},
]},
])
running = True
t0 = time.time()
while True:
ctrl = client.poll_controls()
for btn in ctrl.buttons:
if btn == "reset": t0 = time.time()
if btn == "pause": running = not running
gain = ctrl.values.get("gain", 1.0)
freq = ctrl.values.get("freq", 1.0)
t = time.time() - t0
amp = gain * np.sin(2 * np.pi * freq * t) if running else 0.0
client.set_display("t", t)
client.set_display("msg", "paused" if not running else "running")
client.send_array(amp)
time.sleep(0.01)
Reading controls from Python
ctrl = client.poll_controls() # non-blocking, cheap to call every loop
gain = ctrl.values.get("gain", 1.0) # latest slider/dial value
for btn_id in ctrl.buttons: # list of buttons fired since last poll
handle(btn_id)
poll_controls() returns a ControlState(values, buttons) namedtuple:
values— adictof{element_id: float}for every slider and dial the server has told the client about. Defaults declared ininitialize_plotsare pre-seeded so the first call already sees them.buttons— alistof button ids fired since the previous poll, in order. The list is cleared on return, so each event is delivered exactly once.
Call it from your tight loop before computing the next sample. No threads, no callbacks, no missed events.
Pushing values into displays
client.set_display("t", 12.34) # numeric display box
client.set_display("msg", "running") # text field
set_display() accepts either a number (for type: "display" elements)
or a string (for type: "text" elements). Updates are coalesced on the
server and rebroadcast to every connected browser at ~30 Hz.
Element reference
| Type | Purpose | Notable fields |
|---|---|---|
button |
Fires a discrete event when clicked | id, label |
slider |
Scalar input via horizontal range | id, label, min, max, value, step, format |
dial |
Scalar input via rotational drag | same as slider, plus sensitivity (full turns per range sweep; default 1.0) |
display |
Read-only numeric readout | id, label, format |
text |
Read-only text field (prompts, status) | id, label, value |
Slider and dial widgets both render as [widget] [−] [number input] [+],
so you can drag, type a value directly, or nudge by step. The dial
accepts "round and round" circular drag — each full rotation walks the
value through (max − min) × sensitivity, so sensitivity: 0.25 gives
you four rotations per sweep for fine control.
The format field accepts Python-style {:.Nf} strings (e.g. "{:.2f}").
Plot configuration
Each entry in initialize_plots is one of:
- an integer —
client.initialize_plots(3)→ one plot with 3 anonymous traces - a string —
client.initialize_plots("torque")→ one plot with one named trace - a list of strings — one plot, one trace per name
- a list of lists of strings — one plot per sublist
- a dict — one plot, with full styling options (below)
- a list of dicts — multiple plots with full styling
A styled plot dict accepts any of:
| Key | Meaning |
|---|---|
names |
Required. List of trace names. |
colors |
List of per-trace colors. Single letter (r g b c m y k w) or any CSS color string. |
line_style |
"-" for dashed, "" (or anything else) for solid, per trace. |
line_width |
Per-trace line width in pixels. |
title |
Plot title. |
xlabel / ylabel |
Axis labels. |
yrange |
[ymin, ymax] — pins the Y axis and significantly speeds up rendering. |
xrange |
Integer number of samples visible at once (default 200). |
Special row entries (not plots themselves):
{"controls": [...]}— a row of interactive controls (see Interactive controls){"non_plot_labels": ["name1", "name2"]}— extra scalar names that ride along withsend_arrayand get saved into the output Parquet file, but aren't rendered as traces
Sending data
client.send_array(scalar) # float
client.send_array([a, b, c]) # 1-D list: one sample per trace
client.send_array(np.array([...])) # 1-D numpy array: one sample per trace
client.send_array(np.array([[...]]))# 2-D (num_traces, N): N samples at once
Passing a 2-D array with N > 1 lets you push a batch of samples per
send_array call, which is the fastest way to get many samples through
without dropping frames.
Saving data
The server saves every sample it has received since the latest
initialize_plots call to a Parquet file, including any
non_plot_labels data that rode along with your normal data.
Trigger a save from either side:
- Browser UI: click the Save Plot button.
- Python:
client.save_plot("my_run")
Control where things get written:
python -m rtplot.server_browser -sd ./saved_plots -sn experiment1
-sd/--save-dir— target directory-sn/--save-name— filename prefix (a timestamp is always appended)
Save non-plot signals alongside the plotted ones
client.initialize_plots([
{"names": ["hip_angle", "knee_angle"]},
{"non_plot_labels": ["battery", "cpu_temp", "loop_latency"]},
])
Send battery, cpu_temp and loop_latency as extra rows after the
plotted traces in each send_array call; they won't be drawn but they
will land in the Parquet file.
Networking modes
rtplot uses ZMQ, so either the sender or the plot host can be the one that binds a socket. Pick whichever works for your network and firewalls.
Mode A — plot host binds, sender connects (typical for lab laptops)
# on the plot host (e.g. your laptop)
python -m rtplot.server_browser
# on the sender (e.g. the Pi)
from rtplot import client
client.configure_ip("192.168.1.42") # the laptop's LAN IP
Mode B — sender binds, plot host connects (typical when the sender has a static IP and the viewer roams around)
# on the plot host
python -m rtplot.server_browser -p 192.168.1.50 # the sender's IP
# on the sender
from rtplot import client
# no configure_ip call needed — the default behavior binds
If you pass -p host:port to the server, rtplot also derives the control
return-channel endpoint from that same host/port (it uses port+1). This
means sliders, buttons, and dials work transparently in both modes with
no extra config.
Viewing the plot from another device
The section above is about the link between your sender script and the
plot host (the machine running rtplot.server_browser). This section
is about the other relationship: the link between the plot host and a
separate viewer device — a phone, tablet, or another laptop that just
wants to open the browser UI.
You don't need SSH for this. The plot host already runs a plain HTTP
server on port 8050, bound to every interface, and the viewer device
is only a web browser. All you need to do is get traffic from the
viewer to port 8050 on the plot host.
On the same LAN (phone, tablet, another laptop on the same Wi-Fi)
-
Find the plot host's LAN IP:
ipconfig | findstr IPv4 # Windows
ip -4 addr | grep inet # Linux/WSL
-
Open
http://<lan_ip>:8050in the browser on the viewer device. -
If Windows, allow inbound connections on port
8050through Windows Defender Firewall. The very first time you runpython -m rtplot.server_browser, Windows pops up an "Allow Python to receive connections" dialog — tick Private networks and click Allow. If you missed the dialog, add the rule manually from an elevated PowerShell:# PowerShell as Administrator New-NetFirewallRule -DisplayName "rtplot" ` -Direction Inbound -LocalPort 8050 -Protocol TCP ` -Action Allow -Profile Private
Only allow on Private (home / trusted Wi-Fi), not Public, unless you know what you're doing. To remove the rule later:
Remove-NetFirewallRule -DisplayName "rtplot"
No router configuration, no SSH tunneling, no external accounts. Just a firewall exception.
WSL2 wrinkle
If you run the server inside WSL2 instead of native Windows, WSL2's
localhost auto-forward lets you reach it from your Windows browser,
but does not forward traffic from the LAN. To expose a WSL2-hosted
server to other devices you need one extra hop — a Windows-side port
proxy that forwards incoming LAN traffic into WSL2:
# PowerShell as Administrator
$wslIp = (wsl hostname -I).Trim().Split()[0]
netsh interface portproxy add v4tov4 `
listenport=8050 listenaddress=0.0.0.0 `
connectport=8050 connectaddress=$wslIp
New-NetFirewallRule -DisplayName "rtplot wsl" `
-Direction Inbound -LocalPort 8050 -Protocol TCP `
-Action Allow -Profile Private
WSL2's IP changes on every reboot, so rerun the netsh line after a
restart (or just run rtplot.server_browser from native Windows and
skip this whole step).
To undo:
netsh interface portproxy delete v4tov4 listenport=8050 listenaddress=0.0.0.0
Remove-NetFirewallRule -DisplayName "rtplot wsl"
Across the internet (viewer on cellular, another network, etc.)
Two easy options, neither of which requires touching your router:
Cloudflare Tunnel (free, one-shot URL):
winget install --id Cloudflare.cloudflared
cloudflared tunnel --url http://localhost:8050
Prints an https://<random>.trycloudflare.com URL valid for the
lifetime of the command — paste it into the viewer's browser. Kill the
command when you're done.
Tailscale (private mesh VPN, best for recurring setups):
Install Tailscale on both the plot host and
every viewer device. Each device gets a stable 100.x.y.z IP that
works from any network. Open http://100.x.y.z:8050 on the viewer.
Both tunnel paths forward the HTTP + WebSocket traffic that the browser needs; neither involves ZMQ, since the viewer is browser-only. Your sender script keeps talking to the plot host locally as usual.
Ports at a glance
| Port | What it's for | Who actually needs it open |
|---|---|---|
8050 (TCP) |
HTTP + WebSocket to the browser UI | the plot host, inbound from viewers |
5555 (TCP) |
ZMQ data (sender → server) | only the sender and the plot host |
5556 (TCP) |
ZMQ control return channel (server → sender) | only the sender and the plot host |
For the "other device is a viewer" case, you only need to expose 8050.
5555 / 5556 are between the sender script and the plot host — they
do not need to be reachable from the viewer device at all.
Performance tuning
If you start running out of frames, try these, in roughly this order:
- Pin the Y range.
{"yrange": [-2, 2]}on each plot lets the renderer skip autoscaling work and gives the single biggest win. - Batch your samples. Pass a 2-D numpy array to
send_arrayso N samples ship per call. - Shrink the window. Fewer pixels to redraw per frame.
- Reduce
line_width. Thicker lines cost more to rasterize. - Use the
-s N/--skip Nserver flag to push every Nth sample batch to the browser instead of every one. Add-a/--adaptableto let the server tuneNto your data rate automatically. - Increase
xrange. Counterintuitively, a longer visible history can be cheaper than a short one because the browser ring-buffers the data and only replaces the tail on each push.
CLI reference
python -m rtplot.server_browser accepts:
| Flag | Default | Meaning |
|---|---|---|
-p HOST[:PORT] |
(bind) | Connect to a sender at this address instead of binding |
--host HOST |
0.0.0.0 |
HTTP bind interface |
--port N |
8050 |
HTTP port |
--no-browser |
off | Don't try to open a browser on startup |
--rate N |
1000 |
Max WebSocket push rate (Hz) |
-n N / --skip N |
1 |
Push every Nth sample batch |
-a / --adaptable |
off | Auto-tune skip rate to data rate |
-c / --column |
row | Lay plots out in columns instead of rows |
-d / --debug |
off | Extra debug logging |
-sd DIR / --save-dir DIR |
cwd | Where to write .parquet saves |
-sn NAME / --save-name NAME |
— | Prefix for saved filenames |
Examples
-
rtplot/example_code.py— a walk through everyinitialize_plotssignature, plus a controls demo at the bottom. -
rtplot/interactive_test.py— a guided end-to-end test that walks you through clicking buttons, dragging sliders, typing into the number input, using the ± nudge arrows, and spinning the dial. Good for smoke-testing a fresh install.python -m rtplot.server_browser & python -m rtplot.interactive_test
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file better_rtplot-0.2.2.tar.gz.
File metadata
- Download URL: better_rtplot-0.2.2.tar.gz
- Upload date:
- Size: 81.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: poetry/1.8.2 CPython/3.12.3 Linux/6.6.87.2-microsoft-standard-WSL2
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e2220300dcdbbd90ebfda132c8537c42580b1e3fd2e4856cf118b153e6593cad
|
|
| MD5 |
5d33707542e6fa79cdc6adc9f941e22f
|
|
| BLAKE2b-256 |
9165bac1fa3251ffe4d521506a2c142b64d064a659e31f97835fe98cc083a76c
|
File details
Details for the file better_rtplot-0.2.2-py3-none-any.whl.
File metadata
- Download URL: better_rtplot-0.2.2-py3-none-any.whl
- Upload date:
- Size: 78.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: poetry/1.8.2 CPython/3.12.3 Linux/6.6.87.2-microsoft-standard-WSL2
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
5473936eeca9dcc3bda591b57d7da36d6a8f194279e556e56a3909c3b7317d79
|
|
| MD5 |
9026fea116877708d162308a9f313700
|
|
| BLAKE2b-256 |
7f6a5c55273a693446621459bfad7834697e1c0db01dcdf9eef2a126878ff727
|