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
@@ -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