diff --git a/pyproject.toml b/pyproject.toml index ae854a1..774e543 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ dev = [ "pytest-asyncio>=0.23", "httpx>=0.27", "matplotlib>=3.8", + "tornado>=6", # required by matplotlib's WebAgg interactive backend ] [tool.uv] diff --git a/tools/plot_envelope.py b/tools/plot_envelope.py index b785471..fd68900 100644 --- a/tools/plot_envelope.py +++ b/tools/plot_envelope.py @@ -1,8 +1,15 @@ """Plot a single episode's envelope. 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 -underneath. Saves to ``/envelope.png``. +and renders a 3-panel chart: CPU%, RSS, IO write rate, with phase bands +underneath. + +Two modes: + +- Default: render to ``/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 @@ -13,12 +20,6 @@ import os import sys from pathlib import Path -import matplotlib - -matplotlib.use("Agg") -import matplotlib.pyplot as plt -from matplotlib.patches import Patch - PHASE_COLORS = { "clean": "#9bd09b", @@ -38,8 +39,33 @@ def main() -> int: parser = argparse.ArgumentParser(prog="plot_envelope") parser.add_argument("episode_dir", type=Path) 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() + # 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 if not d.exists(): print(f"no such directory: {d}", file=sys.stderr) @@ -120,6 +146,13 @@ def main() -> int: ) 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") fig.savefig(out, dpi=120) print(f"wrote {out}") diff --git a/tools/show_envelope.sh b/tools/show_envelope.sh new file mode 100755 index 0000000..144000b --- /dev/null +++ b/tools/show_envelope.sh @@ -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 [--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 "$@" diff --git a/uv.lock b/uv.lock index b3b8296..43554b0 100644 --- a/uv.lock +++ b/uv.lock @@ -39,6 +39,7 @@ dev = [ { name = "matplotlib" }, { name = "pytest" }, { name = "pytest-asyncio" }, + { name = "tornado" }, ] [package.metadata] @@ -53,6 +54,7 @@ dev = [ { name = "matplotlib", specifier = ">=3.8" }, { name = "pytest", specifier = ">=8" }, { name = "pytest-asyncio", specifier = ">=0.23" }, + { name = "tornado", specifier = ">=6" }, ] [[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" }, ] +[[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]] name = "typing-extensions" version = "4.15.0"