diff --git a/docs/_extensions/isaaclab_docs.py b/docs/_extensions/isaaclab_docs.py index 0f6a61a92084..2de7ac4e2b63 100644 --- a/docs/_extensions/isaaclab_docs.py +++ b/docs/_extensions/isaaclab_docs.py @@ -7,6 +7,7 @@ from __future__ import annotations +import csv import inspect import json import posixpath @@ -84,6 +85,22 @@ def _parse_rst(directive: SphinxDirective, content: str) -> list[nodes.Node]: return container.children +class IsaacLabBenchmarkData(SphinxDirective): + """Embed benchmark snapshots so downloaded HTML works without an HTTP server.""" + + def run(self) -> list[nodes.Node]: + snapshots = {} + for channel in ("release", "develop"): + path = Path(self.env.srcdir) / "source/_static/benchmarks" / f"environment-performance-{channel}.csv" + self.env.note_dependency(str(path)) + with path.open(encoding="utf-8", newline="") as stream: + snapshots[channel] = list(csv.DictReader(stream)) + # Prevent CSV text from closing the script element, even inside a JSON string. + payload = json.dumps(snapshots).replace("<", "\\u003c") + html = f'' + return [nodes.raw("", html, format="html")] + + class IsaacLabCloneCommands(SphinxDirective): """Render SSH/HTTPS clone tabs using copy-friendly ``code-block`` directives.""" @@ -283,6 +300,7 @@ def setup(app): app.add_config_value("torchvision_version", "", "env") app.add_config_value("ovrtx_spec", "", "env") app.add_role("isaaclab-source", IsaacLabSourceLink()) + app.add_directive("isaaclab-benchmark-data", IsaacLabBenchmarkData) app.add_directive("isaaclab-clone-commands", IsaacLabCloneCommands) app.add_directive("isaaclab-clone-https", IsaacLabCloneHttps) app.add_directive("isaaclab-uv-isaacsim-wheel-install", IsaacLabUvIsaacSimWheelInstall) diff --git a/docs/source/_static/css/environment-browser.js b/docs/source/_static/css/environment-browser.js index 567d87999296..fdbdb2a292ee 100644 --- a/docs/source/_static/css/environment-browser.js +++ b/docs/source/_static/css/environment-browser.js @@ -642,32 +642,6 @@ updateSelection(); }; - const parseCsv = (contents) => { - const parseLine = (line) => { - const values = []; - let value = ""; - let quoted = false; - for (let index = 0; index < line.length; index += 1) { - const character = line[index]; - if (character === '"' && quoted && line[index + 1] === '"') { - value += '"'; - index += 1; - } else if (character === '"') { - quoted = !quoted; - } else if (character === "," && !quoted) { - values.push(value); - value = ""; - } else { - value += character; - } - } - values.push(value); - return values; - }; - const [header, ...lines] = contents.trim().split(/\r?\n/).map(parseLine); - return lines.map((line) => Object.fromEntries(header.map((name, index) => [name, line[index]]))); - }; - const formatFps = (value) => { if (value >= 1_000_000) { return `${Number((value / 1_000_000).toFixed(2))}M`; @@ -931,18 +905,14 @@ chart.replaceChildren(...(rows.length ? [renderBenchmarkChart(rows, maximum)] : [])); }; - const renderBenchmarks = async () => { + const renderBenchmarks = () => { if (!benchmarks) { return; } - benchmarkRows = (await Promise.all(["release", "develop"].map(async (channel) => { + const snapshots = JSON.parse(document.querySelector("[data-environment-benchmark-rows]").textContent); + benchmarkRows = ["release", "develop"].flatMap((channel) => { try { - const source = new URL(benchmarks.getAttribute(`data-benchmark-${channel}-source`), window.location.href); - const response = await fetch(source); - if (!response.ok) { - throw new Error(`Benchmark request failed with ${response.status}`); - } - return parseCsv(await response.text()) + return snapshots[channel] .filter((row) => row.data_origin === "measured" && row.workload === "training") .filter((row) => [row.collection_fps_mean, row.total_fps_mean] .every((value) => Number.isFinite(Number(value)) && Number(value) > 0)) @@ -955,7 +925,7 @@ console.error(error); return []; } - }))).flat(); + }); updatePreview(); }; diff --git a/docs/source/setup/environments.rst b/docs/source/setup/environments.rst index c19f7f280062..400287c4ef9b 100644 --- a/docs/source/setup/environments.rst +++ b/docs/source/setup/environments.rst @@ -102,11 +102,11 @@ Task Preview Benchmarks ---------- +.. isaaclab-benchmark-data:: + .. raw:: html
diff --git a/source/isaaclab/changelog.d/fix-simulation-launcher-platform-check.fixed.rst b/source/isaaclab/changelog.d/fix-simulation-launcher-platform-check.fixed.rst new file mode 100644 index 000000000000..72e1231c0c05 --- /dev/null +++ b/source/isaaclab/changelog.d/fix-simulation-launcher-platform-check.fixed.rst @@ -0,0 +1,2 @@ +* Fixed simulation startup failing with a missing ``sys`` import in the platform-specific device check, + including demos launched through ``uvx``. diff --git a/source/isaaclab/isaaclab/app/sim_launcher.py b/source/isaaclab/isaaclab/app/sim_launcher.py index 6cf87ca8bb0f..955737e67ea0 100644 --- a/source/isaaclab/isaaclab/app/sim_launcher.py +++ b/source/isaaclab/isaaclab/app/sim_launcher.py @@ -15,6 +15,7 @@ import argparse import logging import os +import sys import traceback from collections.abc import Generator from contextlib import contextmanager diff --git a/tools/test/test_doc_benchmarks.py b/tools/test/test_doc_benchmarks.py new file mode 100644 index 000000000000..b876d14e5e0d --- /dev/null +++ b/tools/test/test_doc_benchmarks.py @@ -0,0 +1,57 @@ +# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md). +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Tests for self-contained benchmark snapshots in built documentation.""" + +import csv +import json +import re +import subprocess +import sys +from pathlib import Path + + +def test_benchmark_snapshots_are_embedded_and_refresh_on_csv_changes(tmp_path): + """HTML preserves both CSV snapshots safely and tracks changes during incremental builds.""" + extensions = Path(__file__).resolve().parents[2] / "docs/_extensions" + source = tmp_path / "docs" + snapshots = source / "source/_static/benchmarks" + snapshots.mkdir(parents=True) + (source / "conf.py").write_text( + f"import sys\nsys.path.insert(0, {str(extensions)!r})\nextensions = ['isaaclab_docs']\n", + encoding="utf-8", + ) + (source / "index.rst").write_text("Benchmarks\n==========\n\n.. isaaclab-benchmark-data::\n", encoding="utf-8") + rows = { + "release": [{"task": "Isaac-Cartpole", "collection_fps_mean": "100", "notes": '\nquoted "value",'}], + "develop": [{"task": "Isaac-Cartpole", "collection_fps_mean": "200", "notes": "historical snapshot"}], + } + + def write_snapshot(channel): + with (snapshots / f"environment-performance-{channel}.csv").open("w", encoding="utf-8", newline="") as stream: + writer = csv.DictWriter(stream, fieldnames=["task", "collection_fps_mean", "notes"]) + writer.writeheader() + writer.writerows(rows[channel]) + + def build_snapshots(): + output = tmp_path / "html" + subprocess.run( + [sys.executable, "-m", "sphinx", "-W", "-q", str(source), str(output)], + check=True, + capture_output=True, + text=True, + ) + html = (output / "index.html").read_text(encoding="utf-8") + payload = re.search(r'', html) + assert payload is not None + return json.loads(payload.group(1)) + + for channel in rows: + write_snapshot(channel) + assert build_snapshots() == rows + + rows["develop"][0]["collection_fps_mean"] = "300" + write_snapshot("develop") + assert build_snapshots() == rows