Interactive envelope plot via WebAgg (browser-based)

plot_envelope.py grows a --show flag. With it, matplotlib's WebAgg backend
spins up a localhost server with a real interactive figure (zoom, pan,
hover, axes lock) — equivalent to a matlab plot window without needing
tkinter or Qt locally.

tools/show_envelope.sh is a NixOS-aware wrapper: it locates libstdc++.so.6
in /nix/store (numpy's prebuilt wheel needs it on LD_LIBRARY_PATH) and then
exec's the python script with --show. Default port 8988, override via
--port. Bound to 0.0.0.0 so the figure is reachable over WG too.

tornado is added to dev deps because WebAgg requires it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maximus Gorog 2026-04-29 00:06:22 -06:00
parent 69c09f4404
commit cc37fc6c4d
4 changed files with 93 additions and 8 deletions

View file

@ -14,6 +14,7 @@ dev = [
"pytest-asyncio>=0.23", "pytest-asyncio>=0.23",
"httpx>=0.27", "httpx>=0.27",
"matplotlib>=3.8", "matplotlib>=3.8",
"tornado>=6", # required by matplotlib's WebAgg interactive backend
] ]
[tool.uv] [tool.uv]

View file

@ -1,8 +1,15 @@
"""Plot a single episode's envelope. """Plot a single episode's envelope.
Reads ``telemetry-proc.jsonl`` and ``labels.jsonl`` from an episode directory Reads ``telemetry-proc.jsonl`` and ``labels.jsonl`` from an episode directory
and renders a 3-panel PNG: CPU%, RSS, IO write rate, with phase bands and renders a 3-panel chart: CPU%, RSS, IO write rate, with phase bands
underneath. Saves to ``<episode_dir>/envelope.png``. underneath.
Two modes:
- Default: render to ``<episode_dir>/envelope.png``.
- ``--show``: serve interactively via matplotlib's WebAgg backend
(zoom/pan/hover in the browser). On NixOS, run via
``tools/show_envelope.sh`` so libstdc++ is on LD_LIBRARY_PATH.
""" """
from __future__ import annotations from __future__ import annotations
@ -13,12 +20,6 @@ import os
import sys import sys
from pathlib import Path from pathlib import Path
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.patches import Patch
PHASE_COLORS = { PHASE_COLORS = {
"clean": "#9bd09b", "clean": "#9bd09b",
@ -38,8 +39,33 @@ def main() -> int:
parser = argparse.ArgumentParser(prog="plot_envelope") parser = argparse.ArgumentParser(prog="plot_envelope")
parser.add_argument("episode_dir", type=Path) parser.add_argument("episode_dir", type=Path)
parser.add_argument("--out", type=Path, default=None) parser.add_argument("--out", type=Path, default=None)
parser.add_argument(
"--show",
action="store_true",
help="open an interactive plot in your browser via WebAgg "
"(localhost server)",
)
parser.add_argument(
"--port",
type=int,
default=8988,
help="port for the WebAgg server (default 8988)",
)
args = parser.parse_args() args = parser.parse_args()
# Pick backend BEFORE importing pyplot.
import matplotlib
if args.show:
matplotlib.use("WebAgg")
# Bind to all interfaces so it works over the WG overlay too.
matplotlib.rcParams["webagg.address"] = "0.0.0.0"
matplotlib.rcParams["webagg.port"] = args.port
matplotlib.rcParams["webagg.open_in_browser"] = True
else:
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.patches import Patch
d: Path = args.episode_dir d: Path = args.episode_dir
if not d.exists(): if not d.exists():
print(f"no such directory: {d}", file=sys.stderr) print(f"no such directory: {d}", file=sys.stderr)
@ -120,6 +146,13 @@ def main() -> int:
) )
fig.tight_layout() fig.tight_layout()
if args.show:
print(f"WebAgg interactive plot starting on port {args.port}...")
print(f"open: http://127.0.0.1:{args.port}/")
print("(ctrl-C in this terminal to stop the server)")
plt.show()
return 0
out = args.out or (d / "envelope.png") out = args.out or (d / "envelope.png")
fig.savefig(out, dpi=120) fig.savefig(out, dpi=120)
print(f"wrote {out}") print(f"wrote {out}")

32
tools/show_envelope.sh Executable file
View file

@ -0,0 +1,32 @@
#!/usr/bin/env bash
# Open an interactive envelope plot in your browser (matplotlib WebAgg).
#
# On NixOS, the prebuilt numpy wheel needs libstdc++ from gcc-lib on
# LD_LIBRARY_PATH; this wrapper finds it automatically.
#
# Usage:
# tools/show_envelope.sh <episode_dir> [--port 8988]
#
# Then open the printed URL (or your browser may pop open automatically).
# Ctrl-C in this terminal to stop the server.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$REPO_ROOT"
# Locate libstdc++.so.6 in /nix/store. Prefer the gcc-lib derivation.
LIBSTDC=$(find /nix/store -maxdepth 4 -name 'libstdc++.so.6' -path '*gcc*-lib/lib/*' 2>/dev/null | head -1)
if [[ -z "${LIBSTDC:-}" ]]; then
# Fall back to any libstdc++ in /nix/store.
LIBSTDC=$(find /nix/store -maxdepth 4 -name 'libstdc++.so.6' 2>/dev/null | head -1)
fi
if [[ -z "${LIBSTDC:-}" ]]; then
echo "could not locate libstdc++.so.6 in /nix/store" >&2
echo "(numpy needs it; install via 'nix-shell -p stdenv.cc.cc.lib' or set LD_LIBRARY_PATH manually)" >&2
exit 1
fi
LIB_DIR="$(dirname "$LIBSTDC")"
LD_LIBRARY_PATH="$LIB_DIR${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" \
exec uv run python tools/plot_envelope.py --show "$@"

19
uv.lock generated
View file

@ -39,6 +39,7 @@ dev = [
{ name = "matplotlib" }, { name = "matplotlib" },
{ name = "pytest" }, { name = "pytest" },
{ name = "pytest-asyncio" }, { name = "pytest-asyncio" },
{ name = "tornado" },
] ]
[package.metadata] [package.metadata]
@ -53,6 +54,7 @@ dev = [
{ name = "matplotlib", specifier = ">=3.8" }, { name = "matplotlib", specifier = ">=3.8" },
{ name = "pytest", specifier = ">=8" }, { name = "pytest", specifier = ">=8" },
{ name = "pytest-asyncio", specifier = ">=0.23" }, { name = "pytest-asyncio", specifier = ">=0.23" },
{ name = "tornado", specifier = ">=6" },
] ]
[[package]] [[package]]
@ -806,6 +808,23 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" },
] ]
[[package]]
name = "tornado"
version = "6.5.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f8/f1/3173dfa4a18db4a9b03e5d55325559dab51ee653763bb8745a75af491286/tornado-6.5.5.tar.gz", hash = "sha256:192b8f3ea91bd7f1f50c06955416ed76c6b72f96779b962f07f911b91e8d30e9", size = 516006, upload-time = "2026-03-10T21:31:02.067Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/59/8c/77f5097695f4dd8255ecbd08b2a1ed8ba8b953d337804dd7080f199e12bf/tornado-6.5.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:487dc9cc380e29f58c7ab88f9e27cdeef04b2140862e5076a66fb6bb68bb1bfa", size = 445983, upload-time = "2026-03-10T21:30:44.28Z" },
{ url = "https://files.pythonhosted.org/packages/ab/5e/7625b76cd10f98f1516c36ce0346de62061156352353ef2da44e5c21523c/tornado-6.5.5-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:65a7f1d46d4bb41df1ac99f5fcb685fb25c7e61613742d5108b010975a9a6521", size = 444246, upload-time = "2026-03-10T21:30:46.571Z" },
{ url = "https://files.pythonhosted.org/packages/b2/04/7b5705d5b3c0fab088f434f9c83edac1573830ca49ccf29fb83bf7178eec/tornado-6.5.5-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e74c92e8e65086b338fd56333fb9a68b9f6f2fe7ad532645a290a464bcf46be5", size = 447229, upload-time = "2026-03-10T21:30:48.273Z" },
{ url = "https://files.pythonhosted.org/packages/34/01/74e034a30ef59afb4097ef8659515e96a39d910b712a89af76f5e4e1f93c/tornado-6.5.5-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:435319e9e340276428bbdb4e7fa732c2d399386d1de5686cb331ec8eee754f07", size = 448192, upload-time = "2026-03-10T21:30:51.22Z" },
{ url = "https://files.pythonhosted.org/packages/be/00/fe9e02c5a96429fce1a1d15a517f5d8444f9c412e0bb9eadfbe3b0fc55bf/tornado-6.5.5-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3f54aa540bdbfee7b9eb268ead60e7d199de5021facd276819c193c0fb28ea4e", size = 448039, upload-time = "2026-03-10T21:30:53.52Z" },
{ url = "https://files.pythonhosted.org/packages/82/9e/656ee4cec0398b1d18d0f1eb6372c41c6b889722641d84948351ae19556d/tornado-6.5.5-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:36abed1754faeb80fbd6e64db2758091e1320f6bba74a4cf8c09cd18ccce8aca", size = 447445, upload-time = "2026-03-10T21:30:55.541Z" },
{ url = "https://files.pythonhosted.org/packages/5a/76/4921c00511f88af86a33de770d64141170f1cfd9c00311aea689949e274e/tornado-6.5.5-cp39-abi3-win32.whl", hash = "sha256:dd3eafaaeec1c7f2f8fdcd5f964e8907ad788fe8a5a32c4426fbbdda621223b7", size = 448582, upload-time = "2026-03-10T21:30:57.142Z" },
{ url = "https://files.pythonhosted.org/packages/2c/23/f6c6112a04d28eed765e374435fb1a9198f73e1ec4b4024184f21faeb1ad/tornado-6.5.5-cp39-abi3-win_amd64.whl", hash = "sha256:6443a794ba961a9f619b1ae926a2e900ac20c34483eea67be4ed8f1e58d3ef7b", size = 448990, upload-time = "2026-03-10T21:30:58.857Z" },
{ url = "https://files.pythonhosted.org/packages/b7/c8/876602cbc96469911f0939f703453c1157b0c826ecb05bdd32e023397d4e/tornado-6.5.5-cp39-abi3-win_arm64.whl", hash = "sha256:2c9a876e094109333f888539ddb2de4361743e5d21eece20688e3e351e4990a6", size = 448016, upload-time = "2026-03-10T21:31:00.43Z" },
]
[[package]] [[package]]
name = "typing-extensions" name = "typing-extensions"
version = "4.15.0" version = "4.15.0"