simscope 0.1.1__py3-none-any.whl

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.
Files changed (45) hide show
  1. simscope/__init__.py +6 -0
  2. simscope/__main__.py +8 -0
  3. simscope/_assets/simscope-app.css +2 -0
  4. simscope/_assets/simscope-app.js +4311 -0
  5. simscope/_assets/simscope-player.js +4325 -0
  6. simscope/_assets/simscope-web.LICENSES.txt +407 -0
  7. simscope/_icon.py +22 -0
  8. simscope/_mjviser.py +203 -0
  9. simscope/annotations.py +1132 -0
  10. simscope/cli.py +482 -0
  11. simscope/core.py +257 -0
  12. simscope/derived.py +697 -0
  13. simscope/export.py +799 -0
  14. simscope/highlights.py +947 -0
  15. simscope/importers.py +874 -0
  16. simscope/index.py +579 -0
  17. simscope/io/__init__.py +45 -0
  18. simscope/io/blockfile.py +938 -0
  19. simscope/io/cas.py +294 -0
  20. simscope/io/codecs.py +566 -0
  21. simscope/io/errors.py +9 -0
  22. simscope/io/manifest.py +358 -0
  23. simscope/io/pack.py +563 -0
  24. simscope/io/scene.py +239 -0
  25. simscope/isaaclab.py +1460 -0
  26. simscope/library.py +705 -0
  27. simscope/mujoco.py +578 -0
  28. simscope/py.typed +0 -0
  29. simscope/recorder.py +784 -0
  30. simscope/server/__init__.py +9 -0
  31. simscope/server/app.py +149 -0
  32. simscope/server/blocks.py +191 -0
  33. simscope/server/jobs.py +166 -0
  34. simscope/server/routes.py +707 -0
  35. simscope/server/security.py +218 -0
  36. simscope/server/state.py +751 -0
  37. simscope/server/static.py +84 -0
  38. simscope/transforms.py +147 -0
  39. simscope-0.1.1.dist-info/METADATA +132 -0
  40. simscope-0.1.1.dist-info/RECORD +45 -0
  41. simscope-0.1.1.dist-info/WHEEL +4 -0
  42. simscope-0.1.1.dist-info/entry_points.txt +3 -0
  43. simscope-0.1.1.dist-info/licenses/LICENSE.md +201 -0
  44. simscope-0.1.1.dist-info/licenses/THIRD_PARTY_NOTICES.md +267 -0
  45. simscope-0.1.1.dist-info/licenses/src/simscope/_assets/simscope-web.LICENSES.txt +407 -0
simscope/cli.py ADDED
@@ -0,0 +1,482 @@
1
+ """The ``simscope`` command line.
2
+
3
+ Commands are ``serve``, ``ls``, ``export``, ``rename``, ``pack``, ``import``,
4
+ ``recover``, and ``info``. Everything except ``serve`` works without the
5
+ viewer extra. ``serve`` imports the server lazily, so the other commands
6
+ never load starlette, uvicorn, or watchfiles.
7
+ """
8
+
9
+ import argparse
10
+ import dataclasses
11
+ import importlib
12
+ import json
13
+ import logging
14
+ import pathlib
15
+ import sys
16
+ import time
17
+ from collections.abc import Callable, Sequence
18
+
19
+ from simscope import export, importers, library
20
+ from simscope.io import errors, manifest
21
+
22
+ _SORTS = ("created", "name", "n_frames", "rating")
23
+ _RIGHT = frozenset({"FRAMES", "ENVS", "SECONDS", "RATING"})
24
+ _SERVER_DEPS = ("starlette", "uvicorn", "watchfiles")
25
+ _VIEWER_HINT = "the viewer is not installed: pip install 'simscope[viewer]'"
26
+
27
+
28
+ class UserError(Exception):
29
+ """A problem the user can fix; ``main`` prints it and exits with 1."""
30
+
31
+
32
+ # -- helpers --
33
+
34
+
35
+ def _library_root(path: str) -> pathlib.Path:
36
+ """Returns the library folder, which must exist.
37
+
38
+ Raises:
39
+ UserError: If ``path`` is not a directory.
40
+ """
41
+ root = pathlib.Path(path)
42
+ if not root.is_dir():
43
+ raise UserError(f"no library folder at {root}")
44
+ return root
45
+
46
+
47
+ def _check_runs(root: pathlib.Path, names: Sequence[str]) -> None:
48
+ """Checks that every name is a valid, existing run of the library.
49
+
50
+ Raises:
51
+ UserError: On a bad name, a missing run, or a duplicate.
52
+ """
53
+ if len(set(names)) != len(names):
54
+ raise UserError("duplicate run names")
55
+ for name in names:
56
+ try:
57
+ manifest.validate_run_name(name)
58
+ except ValueError as exc:
59
+ raise UserError(str(exc)) from exc
60
+ run_dir = root / "runs" / name
61
+ if not any(
62
+ (run_dir / f).is_file()
63
+ for f in (manifest.MANIFEST_NAME, manifest.PARTIAL_NAME)
64
+ ):
65
+ raise UserError(f"no run {name!r} in {root}")
66
+
67
+
68
+ def _human(n: float) -> str:
69
+ """Formats a byte count, for example ``1.5 MB``."""
70
+ for unit in ("B", "KB", "MB", "GB"):
71
+ if n < 1024 or unit == "GB":
72
+ return f"{n:.0f} {unit}" if unit == "B" else f"{n:.1f} {unit}"
73
+ n /= 1024
74
+ raise AssertionError("unreachable") # pragma: no cover
75
+
76
+
77
+ def _table(header: Sequence[str], rows: Sequence[Sequence[str]]) -> str:
78
+ """Formats rows as an aligned table; columns named in ``_RIGHT`` right."""
79
+ cols = list(zip(header, *rows, strict=False))
80
+ widths = [max(len(cell) for cell in col) for col in cols]
81
+ right = {i for i, h in enumerate(header) if h in _RIGHT}
82
+
83
+ def line(cells: Sequence[str]) -> str:
84
+ parts = [
85
+ c.rjust(w) if i in right else c.ljust(w)
86
+ for i, (c, w) in enumerate(zip(cells, widths, strict=True))
87
+ ]
88
+ return " ".join(parts).rstrip()
89
+
90
+ return "\n".join([line(header), *(line(r) for r in rows)])
91
+
92
+
93
+ # -- commands --
94
+
95
+
96
+ def _cmd_serve(args: argparse.Namespace) -> int:
97
+ root = _library_root(args.dir)
98
+ try:
99
+ backend = importlib.import_module("simscope.server")
100
+ except ModuleNotFoundError as exc:
101
+ if (exc.name or "").split(".")[0] not in _SERVER_DEPS:
102
+ raise
103
+ raise UserError(_VIEWER_HINT) from exc
104
+ logging.basicConfig(level=logging.INFO, format="%(message)s")
105
+ print(
106
+ f"serving {root} on http://{args.host}:{args.port} (Ctrl-C to stop)",
107
+ flush=True,
108
+ )
109
+ backend.serve(root, host=args.host, port=args.port, author=args.author)
110
+ return 0
111
+
112
+
113
+ def _cmd_ls(args: argparse.Namespace) -> int:
114
+ root = _library_root(args.dir)
115
+ lib = library.Library(root)
116
+ try:
117
+ rows = lib.query(
118
+ tags=args.tag or (),
119
+ favorite=True if args.favorite else None,
120
+ status=args.status,
121
+ sort=args.sort,
122
+ descending=args.sort != "name",
123
+ limit=args.limit,
124
+ )
125
+ finally:
126
+ lib.close()
127
+ if args.json:
128
+ for info in rows:
129
+ print(json.dumps(dataclasses.asdict(info), sort_keys=True))
130
+ return 0
131
+ if not rows:
132
+ print("no rollouts", file=sys.stderr)
133
+ return 0
134
+ header = (
135
+ "NAME",
136
+ "STATUS",
137
+ "FRAMES",
138
+ "ENVS",
139
+ "SECONDS",
140
+ "CREATED",
141
+ "FAV",
142
+ "RATING",
143
+ "TAGS",
144
+ )
145
+ body = [
146
+ (
147
+ r.name,
148
+ r.status,
149
+ str(r.n_frames),
150
+ str(r.n_envs),
151
+ f"{r.n_frames * r.dt:.2f}",
152
+ r.created,
153
+ "*" if r.favorite else "",
154
+ "-" if r.rating is None else f"{r.rating:.1f}",
155
+ ",".join(r.tags),
156
+ )
157
+ for r in rows
158
+ ]
159
+ print(_table(header, body))
160
+ return 0
161
+
162
+
163
+ def _cmd_export(args: argparse.Namespace) -> int:
164
+ root = _library_root(args.dir)
165
+ _check_runs(root, args.runs)
166
+ layout = args.layout or ("single" if len(args.runs) == 1 else "grid")
167
+ if args.arrange and layout != "compare":
168
+ raise UserError("--arrange needs --layout compare")
169
+ try:
170
+ out = export.export_html(
171
+ root,
172
+ args.runs,
173
+ args.output,
174
+ title=args.title,
175
+ layout=layout,
176
+ arrange=args.arrange,
177
+ transcode=not args.no_transcode,
178
+ annotations=not args.no_annotations,
179
+ ui=args.ui,
180
+ envs=args.envs,
181
+ )
182
+ except ValueError as exc: # still recording, bad layout or envs
183
+ raise UserError(str(exc)) from exc
184
+ print(f"wrote {out} ({_human(out.stat().st_size)})")
185
+ return 0
186
+
187
+
188
+ def _cmd_rename(args: argparse.Namespace) -> int:
189
+ root = _library_root(args.dir)
190
+ try:
191
+ library.Library(root).rename(args.old, args.new)
192
+ except (ValueError, FileNotFoundError, FileExistsError, OSError) as exc:
193
+ raise UserError(str(exc)) from exc
194
+ print(f"renamed {args.old} to {args.new}")
195
+ return 0
196
+
197
+
198
+ def _cmd_pack(args: argparse.Namespace) -> int:
199
+ root = _library_root(args.dir)
200
+ _check_runs(root, args.runs)
201
+ try:
202
+ out = export.export_pack(
203
+ root,
204
+ args.runs,
205
+ args.output,
206
+ transcode=not args.no_transcode,
207
+ annotations=not args.no_annotations,
208
+ )
209
+ except ValueError as exc: # still recording
210
+ raise UserError(str(exc)) from exc
211
+ print(f"wrote {out} ({_human(out.stat().st_size)})")
212
+ return 0
213
+
214
+
215
+ def _tree_bytes(root: pathlib.Path) -> int:
216
+ """Total size of the data folders of a library (not the index cache)."""
217
+ total = 0
218
+ for sub in ("assets", "scenes", "runs"):
219
+ for path in (root / sub).rglob("*"):
220
+ if path.is_file():
221
+ total += path.stat().st_size
222
+ return total
223
+
224
+
225
+ def _cmd_import(args: argparse.Namespace) -> int:
226
+ root = pathlib.Path(args.dir)
227
+ if root.exists() and not root.is_dir():
228
+ raise UserError(f"{root} is not a folder")
229
+ files = importers.find_importable(args.paths)
230
+ if not files:
231
+ raise UserError("nothing to import: no .rbundle or Brax HTML files")
232
+ lib = library.Library(root)
233
+ before = _tree_bytes(root)
234
+ started = time.perf_counter()
235
+ imported = failed = 0
236
+ try:
237
+ for res in importers.import_files(
238
+ lib,
239
+ files,
240
+ tags=args.tag or (),
241
+ overwrite=args.overwrite,
242
+ jobs=args.jobs,
243
+ ):
244
+ if res.name is None:
245
+ failed += 1
246
+ print(f"skipped {res.path}: {res.error}", file=sys.stderr)
247
+ continue
248
+ imported += 1
249
+ print(f"{res.name} {res.n_frames} frames <- {res.path}")
250
+ lib.refresh()
251
+ finally:
252
+ lib.close()
253
+ if not imported:
254
+ raise UserError("nothing imported")
255
+ src_bytes = sum(p.stat().st_size for p, _ in files)
256
+ added = _tree_bytes(root) - before
257
+ print(
258
+ f"imported {imported} rollouts"
259
+ + (f", skipped {failed}" if failed else "")
260
+ + f" in {time.perf_counter() - started:.1f} s: input"
261
+ f" {_human(src_bytes)}, library grew by {_human(added)}"
262
+ f" ({added / max(src_bytes, 1):.0%})"
263
+ )
264
+ return 0
265
+
266
+
267
+ def _cmd_recover(args: argparse.Namespace) -> int:
268
+ root = _library_root(args.dir)
269
+ _check_runs(root, [args.run])
270
+ lib = library.Library(root)
271
+ try:
272
+ report = lib.recover(args.run)
273
+ finally:
274
+ lib.close()
275
+ if report.already_complete:
276
+ print(f"{report.name}: already complete, {report.n_frames} frames")
277
+ else:
278
+ print(
279
+ f"{report.name}: recovered {report.n_frames} frames"
280
+ f" (dropped {report.dropped_frames} from longer streams)"
281
+ )
282
+ return 0
283
+
284
+
285
+ def _cmd_info(args: argparse.Namespace) -> int:
286
+ root = _library_root(args.dir)
287
+ _check_runs(root, [args.run])
288
+ lib = library.Library(root)
289
+ try:
290
+ with lib.open(args.run) as run:
291
+ if run.is_recording:
292
+ run.refresh()
293
+ print(_describe(run))
294
+ finally:
295
+ lib.close()
296
+ return 0
297
+
298
+
299
+ def _describe(run: library.Rollout) -> str:
300
+ """Formats the manifest summary of a run for ``info``."""
301
+ m = run.manifest
302
+ frames = run.n_frames
303
+ status = m.status
304
+ if run.is_recording:
305
+ status += f" ({frames} frames written so far)"
306
+ files = [p for p in run.path.rglob("*") if p.is_file()]
307
+ lines = [
308
+ ("name", m.name),
309
+ ("id", m.id),
310
+ ("status", status),
311
+ ("created", m.created),
312
+ ("frames", str(frames)),
313
+ ("envs", str(m.n_envs)),
314
+ ("bodies", str(m.n_bodies)),
315
+ ("dt", f"{m.dt:g} s ({1 / m.dt:.4g} Hz)"),
316
+ ("duration", f"{frames * m.dt:.2f} s"),
317
+ ("scene", m.scene.sha256),
318
+ ("source", ", ".join(f"{k} {v}" for k, v in m.source.items()) or "-"),
319
+ ("tags", ", ".join(m.tags) or "-"),
320
+ ("on disk", _human(sum(p.stat().st_size for p in files))),
321
+ ]
322
+ width = max(len(k) for k, _ in lines)
323
+ out = [f"{k.ljust(width)} {v}" for k, v in lines]
324
+ header = ("STREAM", "KIND", "SHAPE", "FILE", "SIZE")
325
+ rows = []
326
+ for name, info in m.streams.items():
327
+ path = run.path / info.file
328
+ size = path.stat().st_size if path.exists() else 0
329
+ rows.append(
330
+ (
331
+ name,
332
+ info.kind,
333
+ "x".join(map(str, info.item_shape)) or "scalar",
334
+ info.file,
335
+ _human(size),
336
+ )
337
+ )
338
+ return "\n".join([*out, "", _table(header, rows)])
339
+
340
+
341
+ # -- parser --
342
+
343
+
344
+ def _port(text: str) -> int:
345
+ """Parses a TCP port for argparse."""
346
+ value = int(text)
347
+ if not 0 <= value <= 65535:
348
+ raise argparse.ArgumentTypeError(f"port out of range: {text}")
349
+ return value
350
+
351
+
352
+ def _positive(text: str) -> int:
353
+ """Parses a positive integer for argparse."""
354
+ value = int(text)
355
+ if value < 1:
356
+ raise argparse.ArgumentTypeError(f"must be at least 1: {text}")
357
+ return value
358
+
359
+
360
+ def _env_list(text: str) -> list[int]:
361
+ """Parses comma-separated env indices for argparse."""
362
+ try:
363
+ envs = [int(part) for part in text.split(",")]
364
+ except ValueError:
365
+ raise argparse.ArgumentTypeError(
366
+ f"expected comma-separated env indices: {text}"
367
+ ) from None
368
+ if any(e < 0 for e in envs):
369
+ raise argparse.ArgumentTypeError(f"env indices are >= 0: {text}")
370
+ return envs
371
+
372
+
373
+ def _build_parser() -> argparse.ArgumentParser:
374
+ """Builds the argument parser with one subparser per command."""
375
+ parser = argparse.ArgumentParser(
376
+ prog="simscope",
377
+ description="Record and replay robot simulation rollouts.",
378
+ )
379
+ sub = parser.add_subparsers(dest="command", required=True, metavar="CMD")
380
+
381
+ def add(name: str, func: Callable, help_: str) -> argparse.ArgumentParser:
382
+ p = sub.add_parser(name, help=help_, description=help_)
383
+ p.set_defaults(func=func)
384
+ p.add_argument("dir", metavar="DIR", help="the library folder")
385
+ return p
386
+
387
+ p = add("serve", _cmd_serve, "browse and replay rollouts in the browser")
388
+ p.add_argument("--host", default="127.0.0.1")
389
+ p.add_argument("--port", type=_port, default=8080)
390
+ p.add_argument("--author", help="author of new annotations")
391
+
392
+ p = add("ls", _cmd_ls, "list rollouts")
393
+ p.add_argument("--tag", action="append", help="require a tag (repeatable)")
394
+ p.add_argument(
395
+ "--favorite", action="store_true", help="only pinned rollouts"
396
+ )
397
+ p.add_argument("--status", help="recording, complete, or a curation status")
398
+ p.add_argument("--sort", choices=_SORTS, default="created")
399
+ p.add_argument("--limit", type=_positive, help="at most N rollouts")
400
+ p.add_argument("--json", action="store_true", help="one object per line")
401
+
402
+ p = add(
403
+ "export", _cmd_export, "export rollouts as one self-contained HTML file"
404
+ )
405
+ p.add_argument("runs", nargs="+", metavar="RUN")
406
+ p.add_argument("-o", "--output", required=True, metavar="OUT.html")
407
+ p.add_argument("--layout", choices=("single", "grid", "compare"))
408
+ p.add_argument(
409
+ "--arrange",
410
+ choices=export.ARRANGEMENTS,
411
+ help="compare panes: side (default for 2 rollouts), stack, or grid "
412
+ "(default for 3 or 4); needs --layout compare",
413
+ )
414
+ p.add_argument("--title")
415
+ p.add_argument(
416
+ "--ui",
417
+ choices=("lean", "full"),
418
+ default="lean",
419
+ help="lean: player elements; full: the whole app (default lean)",
420
+ )
421
+ p.add_argument(
422
+ "--envs",
423
+ type=_env_list,
424
+ metavar="I,J,...",
425
+ help="export only these envs (comma-separated indices)",
426
+ )
427
+ p.add_argument("--no-transcode", action="store_true")
428
+ p.add_argument("--no-annotations", action="store_true")
429
+
430
+ p = add("rename", _cmd_rename, "rename a rollout")
431
+ p.add_argument("old", metavar="OLD", help="the rollout's current name")
432
+ p.add_argument("new", metavar="NEW", help="its new name")
433
+
434
+ p = add("pack", _cmd_pack, "write rollouts to a .simscope pack")
435
+ p.add_argument("runs", nargs="+", metavar="RUN")
436
+ p.add_argument("-o", "--output", required=True, metavar="OUT.simscope")
437
+ p.add_argument("--no-transcode", action="store_true")
438
+ p.add_argument("--no-annotations", action="store_true")
439
+
440
+ p = add("import", _cmd_import, "import .rbundle files and Brax HTML pages")
441
+ p.add_argument("paths", nargs="+", metavar="PATH", help="files or folders")
442
+ p.add_argument("--tag", action="append", help="add a tag (repeatable)")
443
+ p.add_argument(
444
+ "--overwrite", action="store_true", help="replace an existing rollout"
445
+ )
446
+ p.add_argument("--jobs", type=_positive, help="worker processes")
447
+
448
+ p = add("recover", _cmd_recover, "repair a rollout whose recording stopped")
449
+ p.add_argument("run", metavar="RUN")
450
+
451
+ p = add("info", _cmd_info, "summarize a rollout")
452
+ p.add_argument("run", metavar="RUN")
453
+ return parser
454
+
455
+
456
+ def main(argv: Sequence[str] | None = None) -> int:
457
+ """Runs the command line.
458
+
459
+ Args:
460
+ argv: Arguments without the program name; ``sys.argv[1:]`` if
461
+ ``None``.
462
+
463
+ Returns:
464
+ The exit status: 0 on success, 1 for a problem the user can fix (a
465
+ missing run, a bad name; one line on stderr), 2 for a usage error,
466
+ 130 when interrupted.
467
+ """
468
+ parser = _build_parser()
469
+ try:
470
+ args = parser.parse_args(argv)
471
+ except SystemExit as exc: # argparse exits on --help and usage errors
472
+ return exc.code if isinstance(exc.code, int) else 2
473
+ try:
474
+ return args.func(args)
475
+ except UserError as exc:
476
+ print(f"simscope: error: {exc}", file=sys.stderr)
477
+ return 1
478
+ except (FileNotFoundError, errors.FormatError) as exc:
479
+ print(f"simscope: error: {exc}", file=sys.stderr)
480
+ return 1
481
+ except KeyboardInterrupt:
482
+ return 130