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.
- simscope/__init__.py +6 -0
- simscope/__main__.py +8 -0
- simscope/_assets/simscope-app.css +2 -0
- simscope/_assets/simscope-app.js +4311 -0
- simscope/_assets/simscope-player.js +4325 -0
- simscope/_assets/simscope-web.LICENSES.txt +407 -0
- simscope/_icon.py +22 -0
- simscope/_mjviser.py +203 -0
- simscope/annotations.py +1132 -0
- simscope/cli.py +482 -0
- simscope/core.py +257 -0
- simscope/derived.py +697 -0
- simscope/export.py +799 -0
- simscope/highlights.py +947 -0
- simscope/importers.py +874 -0
- simscope/index.py +579 -0
- simscope/io/__init__.py +45 -0
- simscope/io/blockfile.py +938 -0
- simscope/io/cas.py +294 -0
- simscope/io/codecs.py +566 -0
- simscope/io/errors.py +9 -0
- simscope/io/manifest.py +358 -0
- simscope/io/pack.py +563 -0
- simscope/io/scene.py +239 -0
- simscope/isaaclab.py +1460 -0
- simscope/library.py +705 -0
- simscope/mujoco.py +578 -0
- simscope/py.typed +0 -0
- simscope/recorder.py +784 -0
- simscope/server/__init__.py +9 -0
- simscope/server/app.py +149 -0
- simscope/server/blocks.py +191 -0
- simscope/server/jobs.py +166 -0
- simscope/server/routes.py +707 -0
- simscope/server/security.py +218 -0
- simscope/server/state.py +751 -0
- simscope/server/static.py +84 -0
- simscope/transforms.py +147 -0
- simscope-0.1.1.dist-info/METADATA +132 -0
- simscope-0.1.1.dist-info/RECORD +45 -0
- simscope-0.1.1.dist-info/WHEEL +4 -0
- simscope-0.1.1.dist-info/entry_points.txt +3 -0
- simscope-0.1.1.dist-info/licenses/LICENSE.md +201 -0
- simscope-0.1.1.dist-info/licenses/THIRD_PARTY_NOTICES.md +267 -0
- simscope-0.1.1.dist-info/licenses/src/simscope/_assets/simscope-web.LICENSES.txt +407 -0
simscope/io/manifest.py
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
"""Rollout manifests (``rollout.json``), ULIDs and run names."""
|
|
2
|
+
|
|
3
|
+
import dataclasses
|
|
4
|
+
import datetime
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import pathlib
|
|
8
|
+
import re
|
|
9
|
+
import time
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from simscope import core
|
|
13
|
+
from simscope.io import cas, errors
|
|
14
|
+
|
|
15
|
+
ROLLOUT_FORMAT = "simscope-rollout/1"
|
|
16
|
+
MANIFEST_NAME = "rollout.json"
|
|
17
|
+
PARTIAL_NAME = "rollout.json.partial"
|
|
18
|
+
BODY_POSE = "body_pose"
|
|
19
|
+
|
|
20
|
+
_RUN_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
|
21
|
+
_CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def validate_run_name(name: str) -> str:
|
|
25
|
+
"""Checks a run name against ``^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$``.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
name: Candidate run name.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
The name, unchanged.
|
|
32
|
+
|
|
33
|
+
Raises:
|
|
34
|
+
ValueError: If the name is not valid.
|
|
35
|
+
"""
|
|
36
|
+
if not isinstance(name, str) or not _RUN_NAME_RE.fullmatch(name):
|
|
37
|
+
raise ValueError(
|
|
38
|
+
f"invalid run name {name!r}: must match "
|
|
39
|
+
"[A-Za-z0-9][A-Za-z0-9._-]{0,127}"
|
|
40
|
+
)
|
|
41
|
+
return name
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def new_ulid(now_ms: int | None = None) -> str:
|
|
45
|
+
"""Generates a ULID: 48-bit ms timestamp plus 80 random bits.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
now_ms: Unix time in milliseconds, or ``None`` for the current time.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
26 Crockford base32 characters.
|
|
52
|
+
"""
|
|
53
|
+
ms = time.time_ns() // 1_000_000 if now_ms is None else now_ms
|
|
54
|
+
if not 0 <= ms < 1 << 48:
|
|
55
|
+
raise ValueError("timestamp does not fit in 48 bits")
|
|
56
|
+
value = (ms << 80) | int.from_bytes(os.urandom(10), "big")
|
|
57
|
+
return "".join(
|
|
58
|
+
_CROCKFORD[(value >> (5 * i)) & 31] for i in range(25, -1, -1)
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def utc_now() -> str:
|
|
63
|
+
"""Returns the current UTC time as ``YYYY-MM-DDTHH:MM:SSZ``."""
|
|
64
|
+
now = datetime.datetime.now(datetime.UTC).replace(microsecond=0)
|
|
65
|
+
return now.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _vec3(o: Any) -> core.Vec3:
|
|
69
|
+
"""Parses a 3-element sequence of numbers."""
|
|
70
|
+
if len(o) != 3:
|
|
71
|
+
raise ValueError("env origin must have 3 components")
|
|
72
|
+
return (float(o[0]), float(o[1]), float(o[2]))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclasses.dataclass
|
|
76
|
+
class StreamInfo:
|
|
77
|
+
"""One stream entry of a manifest.
|
|
78
|
+
|
|
79
|
+
Attributes:
|
|
80
|
+
file: Block file name inside the run directory.
|
|
81
|
+
kind: How viewers draw the stream.
|
|
82
|
+
item_shape: Shape of one env-frame item.
|
|
83
|
+
labels: Optional names for vector components.
|
|
84
|
+
units: Optional unit string.
|
|
85
|
+
scale: Optional metres drawn per unit of an ``arrows`` vector.
|
|
86
|
+
Viewers use 1 when it is ``None``.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
file: str
|
|
90
|
+
kind: core.StreamKind
|
|
91
|
+
item_shape: tuple[int, ...] = ()
|
|
92
|
+
labels: tuple[str, ...] | None = None
|
|
93
|
+
units: str | None = None
|
|
94
|
+
scale: float | None = None
|
|
95
|
+
|
|
96
|
+
def to_json(self) -> dict[str, Any]:
|
|
97
|
+
"""Returns the JSON object of this stream."""
|
|
98
|
+
out: dict[str, Any] = {
|
|
99
|
+
"file": self.file,
|
|
100
|
+
"kind": self.kind,
|
|
101
|
+
"item_shape": list(self.item_shape),
|
|
102
|
+
}
|
|
103
|
+
if self.labels is not None:
|
|
104
|
+
out["labels"] = list(self.labels)
|
|
105
|
+
if self.units is not None:
|
|
106
|
+
out["units"] = self.units
|
|
107
|
+
if self.scale is not None:
|
|
108
|
+
out["scale"] = self.scale
|
|
109
|
+
return out
|
|
110
|
+
|
|
111
|
+
@classmethod
|
|
112
|
+
def from_json(cls, obj: dict[str, Any]) -> "StreamInfo":
|
|
113
|
+
"""Parses a stream object, ignoring unknown fields.
|
|
114
|
+
|
|
115
|
+
Raises:
|
|
116
|
+
errors.FormatError: If a field is missing or invalid.
|
|
117
|
+
"""
|
|
118
|
+
try:
|
|
119
|
+
kind = obj["kind"]
|
|
120
|
+
if kind not in core.STREAM_KINDS:
|
|
121
|
+
raise errors.FormatError(f"unknown stream kind {kind!r}")
|
|
122
|
+
labels = obj.get("labels")
|
|
123
|
+
return cls(
|
|
124
|
+
str(obj["file"]),
|
|
125
|
+
kind,
|
|
126
|
+
tuple(int(d) for d in obj["item_shape"]),
|
|
127
|
+
None if labels is None else tuple(str(x) for x in labels),
|
|
128
|
+
obj.get("units"),
|
|
129
|
+
None if obj.get("scale") is None else float(obj["scale"]),
|
|
130
|
+
)
|
|
131
|
+
except errors.FormatError:
|
|
132
|
+
raise
|
|
133
|
+
except (KeyError, TypeError, ValueError) as exc:
|
|
134
|
+
raise errors.FormatError(f"bad stream entry: {obj!r}") from exc
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclasses.dataclass
|
|
138
|
+
class RolloutManifest:
|
|
139
|
+
"""The manifest of one rollout.
|
|
140
|
+
|
|
141
|
+
Attributes:
|
|
142
|
+
id: ULID, fixed at creation.
|
|
143
|
+
name: Run name, equal to the run directory name.
|
|
144
|
+
created: UTC creation time, ``YYYY-MM-DDTHH:MM:SSZ``.
|
|
145
|
+
status: ``"recording"`` or ``"complete"``.
|
|
146
|
+
dt: Seconds between frames.
|
|
147
|
+
n_frames: Frames per stream.
|
|
148
|
+
n_envs: Envs per stream.
|
|
149
|
+
n_bodies: Bodies per pose.
|
|
150
|
+
scene: Reference to the scene descriptor.
|
|
151
|
+
streams: Streams by name. ``body_pose`` is required.
|
|
152
|
+
env_scenes: Optional per-env scene references.
|
|
153
|
+
env_origins: World offset per env, ``n_envs`` triples.
|
|
154
|
+
source: Provenance (simulator and version).
|
|
155
|
+
tags: Free-form tags set at record time.
|
|
156
|
+
meta: Free-form metadata set at record time.
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
id: str
|
|
160
|
+
name: str
|
|
161
|
+
created: str
|
|
162
|
+
dt: float
|
|
163
|
+
n_frames: int
|
|
164
|
+
n_envs: int
|
|
165
|
+
n_bodies: int
|
|
166
|
+
scene: cas.Ref
|
|
167
|
+
streams: dict[str, StreamInfo]
|
|
168
|
+
status: str = "complete"
|
|
169
|
+
env_scenes: tuple[cas.Ref, ...] | None = None
|
|
170
|
+
env_origins: tuple[core.Vec3, ...] | None = None
|
|
171
|
+
source: dict[str, Any] = dataclasses.field(default_factory=dict)
|
|
172
|
+
tags: tuple[str, ...] = ()
|
|
173
|
+
meta: dict[str, Any] = dataclasses.field(default_factory=dict)
|
|
174
|
+
|
|
175
|
+
def to_json(self) -> dict[str, Any]:
|
|
176
|
+
"""Returns the JSON object of this manifest."""
|
|
177
|
+
origins = self.env_origins or ((0.0, 0.0, 0.0),) * self.n_envs
|
|
178
|
+
return {
|
|
179
|
+
"format": ROLLOUT_FORMAT,
|
|
180
|
+
"id": self.id,
|
|
181
|
+
"name": self.name,
|
|
182
|
+
"created": self.created,
|
|
183
|
+
"status": self.status,
|
|
184
|
+
"dt": self.dt,
|
|
185
|
+
"n_frames": self.n_frames,
|
|
186
|
+
"n_envs": self.n_envs,
|
|
187
|
+
"n_bodies": self.n_bodies,
|
|
188
|
+
"scene": self.scene.to_json(),
|
|
189
|
+
"env_scenes": (
|
|
190
|
+
None
|
|
191
|
+
if self.env_scenes is None
|
|
192
|
+
else [r.to_json() for r in self.env_scenes]
|
|
193
|
+
),
|
|
194
|
+
"env_origins": [list(o) for o in origins],
|
|
195
|
+
"streams": {k: v.to_json() for k, v in self.streams.items()},
|
|
196
|
+
"source": self.source,
|
|
197
|
+
"tags": list(self.tags),
|
|
198
|
+
"meta": self.meta,
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
@classmethod
|
|
202
|
+
def from_json(cls, obj: dict[str, Any]) -> "RolloutManifest":
|
|
203
|
+
"""Parses and validates a manifest, ignoring unknown fields.
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
obj: The parsed ``rollout.json``.
|
|
207
|
+
|
|
208
|
+
Returns:
|
|
209
|
+
The manifest.
|
|
210
|
+
|
|
211
|
+
Raises:
|
|
212
|
+
errors.FormatError: On an unknown format or major version, a
|
|
213
|
+
missing field, or an invalid ``body_pose`` stream.
|
|
214
|
+
"""
|
|
215
|
+
fmt = obj.get("format")
|
|
216
|
+
if not isinstance(fmt, str) or not fmt.startswith("simscope-rollout/"):
|
|
217
|
+
raise errors.FormatError(f"not a rollout manifest: {fmt!r}")
|
|
218
|
+
if fmt.split("/", 1)[1].split(".")[0] != "1":
|
|
219
|
+
raise errors.FormatError(f"unknown manifest version {fmt!r}")
|
|
220
|
+
try:
|
|
221
|
+
env_scenes = obj.get("env_scenes")
|
|
222
|
+
origins = obj.get("env_origins")
|
|
223
|
+
man = cls(
|
|
224
|
+
id=str(obj["id"]),
|
|
225
|
+
name=str(obj["name"]),
|
|
226
|
+
created=str(obj["created"]),
|
|
227
|
+
status=str(obj["status"]),
|
|
228
|
+
dt=float(obj["dt"]),
|
|
229
|
+
n_frames=int(obj["n_frames"]),
|
|
230
|
+
n_envs=int(obj["n_envs"]),
|
|
231
|
+
n_bodies=int(obj["n_bodies"]),
|
|
232
|
+
scene=cas.Ref.from_json(obj["scene"]),
|
|
233
|
+
streams={
|
|
234
|
+
str(k): StreamInfo.from_json(v)
|
|
235
|
+
for k, v in obj["streams"].items()
|
|
236
|
+
},
|
|
237
|
+
env_scenes=(
|
|
238
|
+
None
|
|
239
|
+
if env_scenes is None
|
|
240
|
+
else tuple(cas.Ref.from_json(r) for r in env_scenes)
|
|
241
|
+
),
|
|
242
|
+
env_origins=(
|
|
243
|
+
None
|
|
244
|
+
if origins is None
|
|
245
|
+
else tuple(_vec3(o) for o in origins)
|
|
246
|
+
),
|
|
247
|
+
source=dict(obj.get("source") or {}),
|
|
248
|
+
tags=tuple(str(t) for t in obj.get("tags") or ()),
|
|
249
|
+
meta=dict(obj.get("meta") or {}),
|
|
250
|
+
)
|
|
251
|
+
except (KeyError, TypeError, ValueError, AttributeError) as exc:
|
|
252
|
+
if isinstance(exc, errors.FormatError):
|
|
253
|
+
raise
|
|
254
|
+
raise errors.FormatError(f"malformed manifest: {exc!r}") from exc
|
|
255
|
+
man.validate()
|
|
256
|
+
return man
|
|
257
|
+
|
|
258
|
+
def validate(self) -> None:
|
|
259
|
+
"""Checks internal consistency.
|
|
260
|
+
|
|
261
|
+
Raises:
|
|
262
|
+
errors.FormatError: If the run name, status, env data or the
|
|
263
|
+
required ``body_pose`` stream is invalid.
|
|
264
|
+
"""
|
|
265
|
+
try:
|
|
266
|
+
validate_run_name(self.name)
|
|
267
|
+
except ValueError as exc:
|
|
268
|
+
raise errors.FormatError(str(exc)) from exc
|
|
269
|
+
if self.status not in ("recording", "complete"):
|
|
270
|
+
raise errors.FormatError(f"invalid status {self.status!r}")
|
|
271
|
+
pose = self.streams.get(BODY_POSE)
|
|
272
|
+
if pose is None:
|
|
273
|
+
raise errors.FormatError("manifest has no body_pose stream")
|
|
274
|
+
if pose.kind != "pose" or pose.item_shape != (
|
|
275
|
+
self.n_bodies,
|
|
276
|
+
core.POSE_DIM,
|
|
277
|
+
):
|
|
278
|
+
raise errors.FormatError("body_pose must be a pose [n_bodies, 7]")
|
|
279
|
+
if self.env_scenes is not None and len(self.env_scenes) != self.n_envs:
|
|
280
|
+
raise errors.FormatError("env_scenes must have n_envs entries")
|
|
281
|
+
if (
|
|
282
|
+
self.env_origins is not None
|
|
283
|
+
and len(self.env_origins) != self.n_envs
|
|
284
|
+
):
|
|
285
|
+
raise errors.FormatError("env_origins must have n_envs entries")
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def read_manifest(run_dir: os.PathLike[str] | str) -> RolloutManifest:
|
|
289
|
+
"""Reads a run's manifest.
|
|
290
|
+
|
|
291
|
+
Prefers ``rollout.json``; falls back to ``rollout.json.partial`` for a
|
|
292
|
+
run that is still recording (or crashed).
|
|
293
|
+
|
|
294
|
+
Args:
|
|
295
|
+
run_dir: The run directory.
|
|
296
|
+
|
|
297
|
+
Returns:
|
|
298
|
+
The manifest.
|
|
299
|
+
|
|
300
|
+
Raises:
|
|
301
|
+
FileNotFoundError: If neither file exists.
|
|
302
|
+
errors.FormatError: If the manifest is invalid.
|
|
303
|
+
"""
|
|
304
|
+
run_dir = pathlib.Path(run_dir)
|
|
305
|
+
path = run_dir / MANIFEST_NAME
|
|
306
|
+
if not path.exists():
|
|
307
|
+
path = run_dir / PARTIAL_NAME
|
|
308
|
+
try:
|
|
309
|
+
obj = json.loads(path.read_bytes())
|
|
310
|
+
except json.JSONDecodeError as exc:
|
|
311
|
+
raise errors.FormatError(f"{path}: invalid JSON: {exc}") from exc
|
|
312
|
+
return RolloutManifest.from_json(obj)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def manifest_bytes(manifest: RolloutManifest) -> bytes:
|
|
316
|
+
"""Serializes a manifest: ``indent=2``, sorted keys, trailing newline."""
|
|
317
|
+
text = json.dumps(manifest.to_json(), indent=2, sort_keys=True)
|
|
318
|
+
return (text + "\n").encode("utf-8")
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def write_manifest(
|
|
322
|
+
run_dir: os.PathLike[str] | str,
|
|
323
|
+
manifest: RolloutManifest,
|
|
324
|
+
*,
|
|
325
|
+
partial: bool,
|
|
326
|
+
) -> pathlib.Path:
|
|
327
|
+
"""Writes a manifest atomically.
|
|
328
|
+
|
|
329
|
+
With ``partial=True`` the file is ``rollout.json.partial`` and the status
|
|
330
|
+
must be ``"recording"``. With ``partial=False`` the file is
|
|
331
|
+
``rollout.json``, the status must be ``"complete"``, and any ``.partial``
|
|
332
|
+
file is deleted afterwards.
|
|
333
|
+
|
|
334
|
+
Args:
|
|
335
|
+
run_dir: The run directory (created if missing).
|
|
336
|
+
manifest: The manifest.
|
|
337
|
+
partial: Whether this is an in-progress manifest.
|
|
338
|
+
|
|
339
|
+
Returns:
|
|
340
|
+
The path written.
|
|
341
|
+
|
|
342
|
+
Raises:
|
|
343
|
+
ValueError: If the status does not match ``partial``.
|
|
344
|
+
errors.FormatError: If the manifest is inconsistent.
|
|
345
|
+
"""
|
|
346
|
+
expected = "recording" if partial else "complete"
|
|
347
|
+
if manifest.status != expected:
|
|
348
|
+
raise ValueError(
|
|
349
|
+
f"status is {manifest.status!r} but partial={partial} needs "
|
|
350
|
+
f"{expected!r}"
|
|
351
|
+
)
|
|
352
|
+
manifest.validate()
|
|
353
|
+
run_dir = pathlib.Path(run_dir)
|
|
354
|
+
path = run_dir / (PARTIAL_NAME if partial else MANIFEST_NAME)
|
|
355
|
+
cas.atomic_write(path, manifest_bytes(manifest))
|
|
356
|
+
if not partial:
|
|
357
|
+
cas.remove_file(run_dir / PARTIAL_NAME)
|
|
358
|
+
return path
|