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/library.py
ADDED
|
@@ -0,0 +1,705 @@
|
|
|
1
|
+
"""A simscope library: a folder of recorded rollouts.
|
|
2
|
+
|
|
3
|
+
``Library(root)`` is a cheap handle. The folder layout is
|
|
4
|
+
created lazily, by the first recording. ``lib.record`` returns a
|
|
5
|
+
:class:`~simscope.recorder.Recorder`, ``lib.open`` a :class:`Rollout`, and
|
|
6
|
+
``lib.query`` searches the SQLite index cache.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import dataclasses
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import pathlib
|
|
13
|
+
import threading
|
|
14
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
15
|
+
from types import TracebackType
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
import numpy.typing as npt
|
|
20
|
+
|
|
21
|
+
from simscope import annotations, core, index, recorder
|
|
22
|
+
from simscope.io import blockfile, cas, errors, manifest
|
|
23
|
+
from simscope.io import scene as scene_io
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
RunInfo = index.RunInfo
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class _PoseFrameSource:
|
|
31
|
+
"""Pose frames of one run, satisfying the viewer's ``FrameSource``.
|
|
32
|
+
|
|
33
|
+
For a run that is still recording, ``n_frames`` follows the rollout's
|
|
34
|
+
latest :meth:`Rollout.refresh`, and ``read`` is valid below it.
|
|
35
|
+
|
|
36
|
+
Attributes:
|
|
37
|
+
env_origins: World offset of every env, ``[n_envs, 3]``. Viewers add
|
|
38
|
+
it to positions; ``read`` returns the stored poses unchanged.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
rollout: "Rollout",
|
|
44
|
+
reader: blockfile.BlockReader,
|
|
45
|
+
dt: float,
|
|
46
|
+
env_origins: npt.NDArray[np.float64],
|
|
47
|
+
) -> None:
|
|
48
|
+
self._rollout = rollout
|
|
49
|
+
self._reader = reader
|
|
50
|
+
self._dt = dt
|
|
51
|
+
self.env_origins = env_origins
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def n_frames(self) -> int:
|
|
55
|
+
"""Frames readable as of the rollout's latest refresh."""
|
|
56
|
+
return self._rollout.n_frames
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def n_envs(self) -> int:
|
|
60
|
+
"""Number of envs."""
|
|
61
|
+
return self._reader.n_envs
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def dt(self) -> float:
|
|
65
|
+
"""Seconds between frames."""
|
|
66
|
+
return self._dt
|
|
67
|
+
|
|
68
|
+
def read(self, t0: int, t1: int) -> npt.NDArray[np.float32]:
|
|
69
|
+
"""Reads frames ``t0 <= t < t1``.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
t0: First frame.
|
|
73
|
+
t1: One past the last frame.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
A new array ``[t1 - t0, n_envs, n_bodies, 7]``.
|
|
77
|
+
|
|
78
|
+
Raises:
|
|
79
|
+
IndexError: If the range is outside the run.
|
|
80
|
+
"""
|
|
81
|
+
return self._reader.read(t0, t1)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class Rollout:
|
|
85
|
+
"""One recorded run, opened for reading.
|
|
86
|
+
|
|
87
|
+
Block files open lazily and are memory-mapped; nothing is decoded until
|
|
88
|
+
a window is read. Close the rollout (or use ``with``) to release them.
|
|
89
|
+
|
|
90
|
+
A run that is still recording (``rollout.json.partial``) can be tailed:
|
|
91
|
+
call :meth:`refresh` to pick up the windows the recorder has finished
|
|
92
|
+
since. Only complete windows count, so ``n_frames`` never includes a
|
|
93
|
+
window that is half written. When the recorder finishes, ``refresh``
|
|
94
|
+
switches to the final manifest and ``is_recording`` becomes false.
|
|
95
|
+
|
|
96
|
+
Attributes:
|
|
97
|
+
name: Run name.
|
|
98
|
+
path: Run directory.
|
|
99
|
+
manifest: The parsed ``rollout.json`` (or ``.partial``).
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
def __init__(self, root: pathlib.Path, name: str) -> None:
|
|
103
|
+
"""Reads the manifest.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
root: The library root.
|
|
107
|
+
name: Run name.
|
|
108
|
+
|
|
109
|
+
Raises:
|
|
110
|
+
FileNotFoundError: If the run does not exist.
|
|
111
|
+
errors.FormatError: If the manifest is invalid.
|
|
112
|
+
"""
|
|
113
|
+
self._root = pathlib.Path(root)
|
|
114
|
+
self.name = manifest.validate_run_name(name)
|
|
115
|
+
self.path = self._root / "runs" / name
|
|
116
|
+
if not self.path.is_dir():
|
|
117
|
+
raise FileNotFoundError(f"no run {name!r} in {self._root}")
|
|
118
|
+
self.manifest = manifest.read_manifest(self.path)
|
|
119
|
+
self._readers: dict[str, blockfile.BlockReader] = {}
|
|
120
|
+
self._scene: core.Scene | None = None
|
|
121
|
+
self._annotations: annotations.Annotations | None = None
|
|
122
|
+
self._lock = threading.RLock()
|
|
123
|
+
self._tailing = False
|
|
124
|
+
self._live_frames = 0
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def is_recording(self) -> bool:
|
|
128
|
+
"""True while the recorder has not finished the run.
|
|
129
|
+
|
|
130
|
+
It reflects the manifest as of the last :meth:`refresh`. A run whose
|
|
131
|
+
recorder crashed stays "recording" until :meth:`Library.recover`.
|
|
132
|
+
"""
|
|
133
|
+
return self.manifest.status == "recording"
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def dt(self) -> float:
|
|
137
|
+
"""Seconds between frames."""
|
|
138
|
+
return self.manifest.dt
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def n_frames(self) -> int:
|
|
142
|
+
"""Frames per stream.
|
|
143
|
+
|
|
144
|
+
For a recording run this is the count found by the latest
|
|
145
|
+
:meth:`refresh` (0 before the first one).
|
|
146
|
+
"""
|
|
147
|
+
if self.is_recording:
|
|
148
|
+
return self._live_frames
|
|
149
|
+
return self.manifest.n_frames
|
|
150
|
+
|
|
151
|
+
@property
|
|
152
|
+
def n_envs(self) -> int:
|
|
153
|
+
"""Envs per stream."""
|
|
154
|
+
return self.manifest.n_envs
|
|
155
|
+
|
|
156
|
+
@property
|
|
157
|
+
def env_origins(self) -> npt.NDArray[np.float64]:
|
|
158
|
+
"""World offset of each env, ``[n_envs, 3]`` (zeros if unset)."""
|
|
159
|
+
origins = self.manifest.env_origins
|
|
160
|
+
if origins is None:
|
|
161
|
+
return np.zeros((self.n_envs, 3))
|
|
162
|
+
return np.asarray(origins, dtype=np.float64)
|
|
163
|
+
|
|
164
|
+
@property
|
|
165
|
+
def scene(self) -> core.Scene:
|
|
166
|
+
"""The scene, loaded from the content store on first use."""
|
|
167
|
+
if self._scene is None:
|
|
168
|
+
store = cas.ContentStore(self._root)
|
|
169
|
+
self._scene = scene_io.load_scene(store, self.manifest.scene)
|
|
170
|
+
return self._scene
|
|
171
|
+
|
|
172
|
+
def stream(self, name: str, *, live: bool = False) -> blockfile.BlockReader:
|
|
173
|
+
"""Returns the reader of a stream, opening it on first use.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
name: Stream name from the manifest.
|
|
177
|
+
live: Allow a stream of a recording run, tailing its file. It is
|
|
178
|
+
implied once :meth:`refresh` or :meth:`frame_source` has been
|
|
179
|
+
called. Without it, a run that is not complete raises, since
|
|
180
|
+
it may have crashed.
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
A shared ``BlockReader``; it is closed with the rollout. For a
|
|
184
|
+
recording run its ``n_frames`` only advances in :meth:`refresh`.
|
|
185
|
+
|
|
186
|
+
Raises:
|
|
187
|
+
KeyError: If the run has no such stream.
|
|
188
|
+
errors.FormatError: If the run is unfinished and not read with
|
|
189
|
+
``live`` (see :meth:`Library.recover`), or a file is corrupt.
|
|
190
|
+
"""
|
|
191
|
+
info = self.manifest.streams.get(name)
|
|
192
|
+
if info is None:
|
|
193
|
+
raise KeyError(
|
|
194
|
+
f"run {self.name!r} has no stream {name!r}; "
|
|
195
|
+
f"it has {sorted(self.manifest.streams)}"
|
|
196
|
+
)
|
|
197
|
+
with self._lock:
|
|
198
|
+
reader = self._readers.get(name)
|
|
199
|
+
if reader is None:
|
|
200
|
+
partial = self.is_recording
|
|
201
|
+
if partial and not (live or self._tailing):
|
|
202
|
+
raise errors.FormatError(
|
|
203
|
+
f"run {self.name!r} is unfinished "
|
|
204
|
+
f"(status {self.manifest.status!r}); "
|
|
205
|
+
"call Library.recover() first"
|
|
206
|
+
)
|
|
207
|
+
reader = blockfile.BlockReader(
|
|
208
|
+
self.path / info.file, kind=info.kind, partial=partial
|
|
209
|
+
)
|
|
210
|
+
self._readers[name] = reader
|
|
211
|
+
return reader
|
|
212
|
+
|
|
213
|
+
def refresh(self) -> int:
|
|
214
|
+
"""Picks up what the recorder has written since the last call.
|
|
215
|
+
|
|
216
|
+
Re-reads the manifest (``rollout.json.partial``, or the final
|
|
217
|
+
``rollout.json`` once the run is complete) and refreshes every stream
|
|
218
|
+
reader. The cost is proportional to the new data. On a run that was
|
|
219
|
+
complete when opened this does nothing.
|
|
220
|
+
|
|
221
|
+
Returns:
|
|
222
|
+
Frames readable in every stream: the minimum across streams while
|
|
223
|
+
recording, and the manifest's count once complete.
|
|
224
|
+
|
|
225
|
+
Raises:
|
|
226
|
+
errors.FormatError: If the manifest or a block file is corrupt.
|
|
227
|
+
"""
|
|
228
|
+
with self._lock:
|
|
229
|
+
if self.manifest.status == "complete":
|
|
230
|
+
return self.manifest.n_frames
|
|
231
|
+
self._tailing = True
|
|
232
|
+
self.manifest = self._read_manifest()
|
|
233
|
+
counts = []
|
|
234
|
+
for name in self.manifest.streams:
|
|
235
|
+
reader = self._live_reader(name)
|
|
236
|
+
counts.append(0 if reader is None else reader.refresh())
|
|
237
|
+
self._live_frames = min(counts, default=0)
|
|
238
|
+
if self._annotations is not None:
|
|
239
|
+
self._annotations.n_frames = self.n_frames
|
|
240
|
+
return self.n_frames
|
|
241
|
+
|
|
242
|
+
def _read_manifest(self) -> manifest.RolloutManifest:
|
|
243
|
+
"""Reads the manifest, tolerating the partial-to-final handover."""
|
|
244
|
+
try:
|
|
245
|
+
return manifest.read_manifest(self.path)
|
|
246
|
+
except FileNotFoundError:
|
|
247
|
+
# The recorder wrote rollout.json and removed the partial file
|
|
248
|
+
# between our two lookups; the final file exists now.
|
|
249
|
+
return manifest.read_manifest(self.path)
|
|
250
|
+
|
|
251
|
+
def _live_reader(self, name: str) -> blockfile.BlockReader | None:
|
|
252
|
+
"""Opens a tailing reader, or ``None`` if its file has no header."""
|
|
253
|
+
try:
|
|
254
|
+
return self.stream(name, live=True)
|
|
255
|
+
except errors.FormatError:
|
|
256
|
+
file = self.path / self.manifest.streams[name].file
|
|
257
|
+
if file.stat().st_size >= blockfile.HEADER_SIZE:
|
|
258
|
+
raise
|
|
259
|
+
return None # the recorder has created the file, not written it
|
|
260
|
+
|
|
261
|
+
def frame_source(self, stream: str = "body_pose") -> _PoseFrameSource:
|
|
262
|
+
"""Returns a viewer-ready source of pose frames.
|
|
263
|
+
|
|
264
|
+
For a recording run this refreshes the rollout first, and the
|
|
265
|
+
source's ``n_frames`` then follows each :meth:`refresh`.
|
|
266
|
+
|
|
267
|
+
Args:
|
|
268
|
+
stream: A pose stream; ``body_pose`` by default.
|
|
269
|
+
|
|
270
|
+
Returns:
|
|
271
|
+
An object with ``n_frames``, ``n_envs``, ``dt`` and
|
|
272
|
+
``read(t0, t1) -> [t1 - t0, E, B, 7]``.
|
|
273
|
+
|
|
274
|
+
Raises:
|
|
275
|
+
ValueError: If the stream is not a pose stream.
|
|
276
|
+
"""
|
|
277
|
+
info = self.manifest.streams.get(stream)
|
|
278
|
+
if info is not None and info.kind != "pose":
|
|
279
|
+
raise ValueError(f"stream {stream!r} is {info.kind}, not pose")
|
|
280
|
+
if self.is_recording:
|
|
281
|
+
self.refresh()
|
|
282
|
+
return _PoseFrameSource(
|
|
283
|
+
self, self.stream(stream), self.dt, self.env_origins
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
def reload_annotations(self) -> annotations.Annotations:
|
|
287
|
+
"""Re-reads the curation sidecar from disk, dropping unsaved edits.
|
|
288
|
+
|
|
289
|
+
Returns:
|
|
290
|
+
The freshly loaded annotations, which :attr:`annotations` returns
|
|
291
|
+
from then on.
|
|
292
|
+
"""
|
|
293
|
+
self._annotations = None
|
|
294
|
+
return self.annotations
|
|
295
|
+
|
|
296
|
+
@property
|
|
297
|
+
def annotations(self) -> annotations.Annotations:
|
|
298
|
+
"""The run's curation sidecar (loaded once; call ``save()``)."""
|
|
299
|
+
if self._annotations is None:
|
|
300
|
+
self._annotations = annotations.Annotations.load(
|
|
301
|
+
self.path, self.manifest.id, self.dt, self.n_frames
|
|
302
|
+
)
|
|
303
|
+
return self._annotations
|
|
304
|
+
|
|
305
|
+
def close(self) -> None:
|
|
306
|
+
"""Releases every open block file."""
|
|
307
|
+
with self._lock:
|
|
308
|
+
for reader in self._readers.values():
|
|
309
|
+
reader.close()
|
|
310
|
+
self._readers.clear()
|
|
311
|
+
|
|
312
|
+
def __enter__(self) -> "Rollout":
|
|
313
|
+
"""Returns the rollout."""
|
|
314
|
+
return self
|
|
315
|
+
|
|
316
|
+
def __exit__(
|
|
317
|
+
self,
|
|
318
|
+
exc_type: type[BaseException] | None,
|
|
319
|
+
exc: BaseException | None,
|
|
320
|
+
tb: TracebackType | None,
|
|
321
|
+
) -> None:
|
|
322
|
+
"""Closes the rollout."""
|
|
323
|
+
self.close()
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
@dataclasses.dataclass(frozen=True)
|
|
327
|
+
class RecoverReport:
|
|
328
|
+
"""What :meth:`Library.recover` did.
|
|
329
|
+
|
|
330
|
+
Attributes:
|
|
331
|
+
name: Run name.
|
|
332
|
+
n_frames: Frames in the recovered run.
|
|
333
|
+
already_complete: True if the run was finished and left untouched.
|
|
334
|
+
streams: Frames each stream held before they were aligned.
|
|
335
|
+
dropped_frames: Frames removed from streams that were ahead of the
|
|
336
|
+
shortest one (a crash between two streams' writes).
|
|
337
|
+
"""
|
|
338
|
+
|
|
339
|
+
name: str
|
|
340
|
+
n_frames: int
|
|
341
|
+
already_complete: bool
|
|
342
|
+
streams: Mapping[str, int]
|
|
343
|
+
dropped_frames: int
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
class Library:
|
|
347
|
+
"""A folder of rollouts.
|
|
348
|
+
|
|
349
|
+
Attributes:
|
|
350
|
+
root: The library folder.
|
|
351
|
+
"""
|
|
352
|
+
|
|
353
|
+
def __init__(self, root: os.PathLike[str] | str) -> None:
|
|
354
|
+
"""Creates a handle. Nothing is created on disk yet.
|
|
355
|
+
|
|
356
|
+
Args:
|
|
357
|
+
root: The library folder; it and its layout are created by the
|
|
358
|
+
first recording.
|
|
359
|
+
"""
|
|
360
|
+
self.root = pathlib.Path(root)
|
|
361
|
+
self._index = index.Index(self.root)
|
|
362
|
+
|
|
363
|
+
@property
|
|
364
|
+
def runs_dir(self) -> pathlib.Path:
|
|
365
|
+
"""The ``runs/`` folder."""
|
|
366
|
+
return self.root / "runs"
|
|
367
|
+
|
|
368
|
+
def run_dir(self, name: str) -> pathlib.Path:
|
|
369
|
+
"""Returns a run's folder (which may not exist).
|
|
370
|
+
|
|
371
|
+
Raises:
|
|
372
|
+
ValueError: If the name is invalid.
|
|
373
|
+
"""
|
|
374
|
+
return self.runs_dir / manifest.validate_run_name(name)
|
|
375
|
+
|
|
376
|
+
def record(
|
|
377
|
+
self,
|
|
378
|
+
name: str,
|
|
379
|
+
*,
|
|
380
|
+
scene: core.Scene,
|
|
381
|
+
dt: float,
|
|
382
|
+
n_envs: int = 1,
|
|
383
|
+
env_origins: npt.ArrayLike | None = None,
|
|
384
|
+
source: Mapping[str, Any] | None = None,
|
|
385
|
+
tags: Sequence[str] = (),
|
|
386
|
+
meta: Mapping[str, Any] | None = None,
|
|
387
|
+
codec: str = "f32s",
|
|
388
|
+
block_frames: int = blockfile.DEFAULT_BLOCK_FRAMES,
|
|
389
|
+
overwrite: bool = False,
|
|
390
|
+
encode_threads: int | None = None,
|
|
391
|
+
created: str | None = None,
|
|
392
|
+
) -> recorder.Recorder:
|
|
393
|
+
"""Starts recording a run.
|
|
394
|
+
|
|
395
|
+
Use the result as a context manager, calling ``add_stream`` for
|
|
396
|
+
extra streams and ``log`` once per step.
|
|
397
|
+
|
|
398
|
+
Args:
|
|
399
|
+
name: Run name, ``[A-Za-z0-9][A-Za-z0-9._-]{0,127}``.
|
|
400
|
+
scene: The static scene.
|
|
401
|
+
dt: Seconds between frames.
|
|
402
|
+
n_envs: Number of parallel envs.
|
|
403
|
+
env_origins: World offset per env, ``[n_envs, 3]``.
|
|
404
|
+
source: Provenance, for example from ``mujoco.source_info()``.
|
|
405
|
+
tags: Record-time tags.
|
|
406
|
+
meta: Free-form JSON metadata.
|
|
407
|
+
codec: Pose codec, ``"f32s"`` (lossless) or ``"q16d"``.
|
|
408
|
+
block_frames: Frames per block.
|
|
409
|
+
overwrite: Replace an existing run (deletes its folder).
|
|
410
|
+
encode_threads: Threads the writer spreads per-env encoding over;
|
|
411
|
+
``None`` chooses from the env count.
|
|
412
|
+
created: Recording time (UTC, ``YYYY-MM-DDTHH:MM:SSZ``);
|
|
413
|
+
``None`` means now. Importers pass the original time.
|
|
414
|
+
|
|
415
|
+
Returns:
|
|
416
|
+
The recorder.
|
|
417
|
+
|
|
418
|
+
Raises:
|
|
419
|
+
ValueError: If an argument is invalid.
|
|
420
|
+
FileExistsError: If the run exists and ``overwrite`` is false.
|
|
421
|
+
"""
|
|
422
|
+
return recorder.Recorder(
|
|
423
|
+
self.root,
|
|
424
|
+
name,
|
|
425
|
+
scene=scene,
|
|
426
|
+
dt=dt,
|
|
427
|
+
n_envs=n_envs,
|
|
428
|
+
env_origins=env_origins,
|
|
429
|
+
source=source,
|
|
430
|
+
tags=tags,
|
|
431
|
+
meta=meta,
|
|
432
|
+
codec=codec,
|
|
433
|
+
block_frames=block_frames,
|
|
434
|
+
overwrite=overwrite,
|
|
435
|
+
encode_threads=encode_threads,
|
|
436
|
+
created=created,
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
def open(self, name: str) -> Rollout:
|
|
440
|
+
"""Opens a run for reading.
|
|
441
|
+
|
|
442
|
+
Args:
|
|
443
|
+
name: Run name.
|
|
444
|
+
|
|
445
|
+
Returns:
|
|
446
|
+
The rollout.
|
|
447
|
+
|
|
448
|
+
Raises:
|
|
449
|
+
FileNotFoundError: If there is no such run.
|
|
450
|
+
errors.FormatError: If its manifest is invalid.
|
|
451
|
+
"""
|
|
452
|
+
return Rollout(self.root, name)
|
|
453
|
+
|
|
454
|
+
def rename(self, old: str, new: str) -> None:
|
|
455
|
+
"""Renames a run.
|
|
456
|
+
|
|
457
|
+
A run is known by its folder name and the ``name`` in its manifest;
|
|
458
|
+
its ``id`` never changes, so annotations, groups, the derived cache
|
|
459
|
+
and exports keep belonging to it. Only ``rollout.json`` is
|
|
460
|
+
rewritten (atomically); streams, scenes and assets are untouched.
|
|
461
|
+
If the folder cannot be renamed, the manifest is put back.
|
|
462
|
+
|
|
463
|
+
:class:`Rollout` objects opened before the rename keep pointing at
|
|
464
|
+
the old folder; reopen the run with :meth:`open`.
|
|
465
|
+
|
|
466
|
+
Args:
|
|
467
|
+
old: The run's current name.
|
|
468
|
+
new: The new name, ``[A-Za-z0-9][A-Za-z0-9._-]{0,127}``.
|
|
469
|
+
|
|
470
|
+
Raises:
|
|
471
|
+
ValueError: If a name is invalid or the run is still recording.
|
|
472
|
+
FileNotFoundError: If there is no run ``old``.
|
|
473
|
+
FileExistsError: If a run ``new`` exists.
|
|
474
|
+
OSError: If the folder cannot be renamed (for example on
|
|
475
|
+
Windows while a file in it is open).
|
|
476
|
+
errors.FormatError: If the manifest is invalid.
|
|
477
|
+
"""
|
|
478
|
+
old_dir, new_dir = self.run_dir(old), self.run_dir(new)
|
|
479
|
+
if not old_dir.is_dir():
|
|
480
|
+
raise FileNotFoundError(f"no run {old!r} in {self.root}")
|
|
481
|
+
if not (old_dir / manifest.MANIFEST_NAME).is_file():
|
|
482
|
+
raise ValueError(f"run {old!r} is still recording")
|
|
483
|
+
# On a case-insensitive filesystem a case-only rename names the same
|
|
484
|
+
# folder; that is a rename, not a clash.
|
|
485
|
+
if os.path.lexists(new_dir) and (
|
|
486
|
+
old == new or not old_dir.samefile(new_dir)
|
|
487
|
+
):
|
|
488
|
+
raise FileExistsError(f"run {new!r} already exists")
|
|
489
|
+
from simscope import derived # circular at module level
|
|
490
|
+
|
|
491
|
+
m = manifest.read_manifest(old_dir)
|
|
492
|
+
with Rollout(self.root, old) as run:
|
|
493
|
+
before = derived.fingerprint(run)
|
|
494
|
+
m.name = new
|
|
495
|
+
m.validate()
|
|
496
|
+
cas.atomic_write(
|
|
497
|
+
old_dir / manifest.MANIFEST_NAME, manifest.manifest_bytes(m)
|
|
498
|
+
)
|
|
499
|
+
try:
|
|
500
|
+
os.rename(old_dir, new_dir)
|
|
501
|
+
except OSError:
|
|
502
|
+
m.name = old
|
|
503
|
+
cas.atomic_write(
|
|
504
|
+
old_dir / manifest.MANIFEST_NAME, manifest.manifest_bytes(m)
|
|
505
|
+
)
|
|
506
|
+
raise
|
|
507
|
+
try:
|
|
508
|
+
with Rollout(self.root, new) as run:
|
|
509
|
+
derived.carry_over(self.root, run, before)
|
|
510
|
+
except Exception: # isolation point: a cold cache is only slower
|
|
511
|
+
logger.warning("derived cache of %r not carried over", new)
|
|
512
|
+
self._index.refresh()
|
|
513
|
+
logger.info("renamed run %r to %r", old, new)
|
|
514
|
+
|
|
515
|
+
# -- browsing --
|
|
516
|
+
|
|
517
|
+
def refresh(self) -> index.RefreshStats:
|
|
518
|
+
"""Rescans ``runs/`` and updates the index (incrementally)."""
|
|
519
|
+
return self._index.refresh()
|
|
520
|
+
|
|
521
|
+
def runs(self, refresh: bool = True) -> list[RunInfo]:
|
|
522
|
+
"""Lists every run, newest first.
|
|
523
|
+
|
|
524
|
+
Args:
|
|
525
|
+
refresh: Update the index first (cheap when nothing changed).
|
|
526
|
+
|
|
527
|
+
Returns:
|
|
528
|
+
One summary per run.
|
|
529
|
+
"""
|
|
530
|
+
return self.query(limit=None, refresh=refresh)
|
|
531
|
+
|
|
532
|
+
def query(
|
|
533
|
+
self,
|
|
534
|
+
text: str | None = None,
|
|
535
|
+
tags: Iterable[str] = (),
|
|
536
|
+
favorite: bool | None = None,
|
|
537
|
+
status: str | None = None,
|
|
538
|
+
sort: str = "created",
|
|
539
|
+
descending: bool = True,
|
|
540
|
+
limit: int | None = 50,
|
|
541
|
+
offset: int = 0,
|
|
542
|
+
*,
|
|
543
|
+
refresh: bool = True,
|
|
544
|
+
) -> list[RunInfo]:
|
|
545
|
+
"""Searches the index. See :meth:`simscope.index.Index.query`.
|
|
546
|
+
|
|
547
|
+
Args:
|
|
548
|
+
text: Terms that must all appear in name, tags, group or notes.
|
|
549
|
+
tags: Record-time tags a run must have (all of them).
|
|
550
|
+
favorite: Only runs with this favorite mark.
|
|
551
|
+
status: Manifest status to match (``"recording"``,
|
|
552
|
+
``"complete"``).
|
|
553
|
+
sort: ``"created"``, ``"name"``, ``"n_frames"`` or ``"rating"``.
|
|
554
|
+
descending: Sort direction.
|
|
555
|
+
limit: Page size, or ``None`` for all.
|
|
556
|
+
offset: Rows to skip.
|
|
557
|
+
refresh: Update the index first.
|
|
558
|
+
|
|
559
|
+
Returns:
|
|
560
|
+
The matching page of run summaries. The ``n_frames`` of a run
|
|
561
|
+
that is still recording counts the windows written so far, since
|
|
562
|
+
the recorder rewrites its partial manifest after each window.
|
|
563
|
+
"""
|
|
564
|
+
if refresh:
|
|
565
|
+
self._index.refresh()
|
|
566
|
+
rows = self._index.query(
|
|
567
|
+
text, tags, favorite, status, sort, descending, limit, offset
|
|
568
|
+
)
|
|
569
|
+
return rows
|
|
570
|
+
|
|
571
|
+
def count(
|
|
572
|
+
self,
|
|
573
|
+
text: str | None = None,
|
|
574
|
+
tags: Iterable[str] = (),
|
|
575
|
+
favorite: bool | None = None,
|
|
576
|
+
status: str | None = None,
|
|
577
|
+
*,
|
|
578
|
+
refresh: bool = True,
|
|
579
|
+
) -> int:
|
|
580
|
+
"""Counts runs matching the filters of :meth:`query`."""
|
|
581
|
+
if refresh:
|
|
582
|
+
self._index.refresh()
|
|
583
|
+
return self._index.count(text, tags, favorite, status)
|
|
584
|
+
|
|
585
|
+
def close(self) -> None:
|
|
586
|
+
"""Closes the index connection (it reopens on demand)."""
|
|
587
|
+
self._index.close()
|
|
588
|
+
|
|
589
|
+
# -- event types --
|
|
590
|
+
|
|
591
|
+
def event_types(self) -> dict[str, annotations.EventType]:
|
|
592
|
+
"""Returns the event vocabulary (the built-in one if unset)."""
|
|
593
|
+
return annotations.load_event_types(self.root)[0]
|
|
594
|
+
|
|
595
|
+
def set_event_types(
|
|
596
|
+
self,
|
|
597
|
+
types: Mapping[str, annotations.EventType]
|
|
598
|
+
| Iterable[annotations.EventType],
|
|
599
|
+
) -> None:
|
|
600
|
+
"""Writes ``.simscope/event_types.json``.
|
|
601
|
+
|
|
602
|
+
Args:
|
|
603
|
+
types: Entries by id, or an iterable of entries (keyed by their
|
|
604
|
+
``type_id``). Unknown fields already in the file are kept.
|
|
605
|
+
"""
|
|
606
|
+
if isinstance(types, Mapping):
|
|
607
|
+
entries = dict(types)
|
|
608
|
+
else:
|
|
609
|
+
entries = {t.type_id: t for t in types}
|
|
610
|
+
_, extra = annotations.load_event_types(self.root)
|
|
611
|
+
annotations.save_event_types(self.root, entries, extra)
|
|
612
|
+
|
|
613
|
+
# -- recovery --
|
|
614
|
+
|
|
615
|
+
def recover(self, name: str) -> RecoverReport:
|
|
616
|
+
"""Repairs a run whose recording crashed.
|
|
617
|
+
|
|
618
|
+
Every block file is recovered (complete windows kept, the rest cut
|
|
619
|
+
off), streams are aligned to the shortest one, and the manifest is
|
|
620
|
+
rewritten as ``"complete"``. A finished run is left untouched.
|
|
621
|
+
Do not call it while a recorder is still writing the run.
|
|
622
|
+
|
|
623
|
+
Args:
|
|
624
|
+
name: Run name.
|
|
625
|
+
|
|
626
|
+
Returns:
|
|
627
|
+
What was kept.
|
|
628
|
+
|
|
629
|
+
Raises:
|
|
630
|
+
FileNotFoundError: If the run or its manifest is missing.
|
|
631
|
+
errors.FormatError: If the manifest is invalid.
|
|
632
|
+
"""
|
|
633
|
+
run_dir = self.run_dir(name)
|
|
634
|
+
if (run_dir / manifest.MANIFEST_NAME).exists():
|
|
635
|
+
cas.remove_file(run_dir / manifest.PARTIAL_NAME)
|
|
636
|
+
m = manifest.read_manifest(run_dir)
|
|
637
|
+
return RecoverReport(name, m.n_frames, True, {}, 0)
|
|
638
|
+
m = manifest.read_manifest(run_dir)
|
|
639
|
+
frames: dict[str, int] = {}
|
|
640
|
+
for sname, info in m.streams.items():
|
|
641
|
+
frames[sname] = _recover_stream(run_dir / info.file)
|
|
642
|
+
# An unusable stream (-1) has no frames, so nothing can be kept.
|
|
643
|
+
common = max(0, min(frames.values(), default=0))
|
|
644
|
+
dropped = 0
|
|
645
|
+
for sname, info in m.streams.items():
|
|
646
|
+
path = run_dir / info.file
|
|
647
|
+
if frames[sname] < 0:
|
|
648
|
+
_write_empty(path, info, m.n_envs)
|
|
649
|
+
frames[sname] = 0
|
|
650
|
+
elif frames[sname] > common:
|
|
651
|
+
dropped += frames[sname] - common
|
|
652
|
+
_truncate_stream(path, common)
|
|
653
|
+
m.n_frames = common
|
|
654
|
+
m.status = "complete"
|
|
655
|
+
manifest.write_manifest(run_dir, m, partial=False)
|
|
656
|
+
logger.info("recovered run %r: %d frames", name, common)
|
|
657
|
+
return RecoverReport(name, common, False, frames, dropped)
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def _recover_stream(path: pathlib.Path) -> int:
|
|
661
|
+
"""Recovers one block file.
|
|
662
|
+
|
|
663
|
+
Returns:
|
|
664
|
+
Frames kept, or -1 if the file is missing or has no valid header.
|
|
665
|
+
"""
|
|
666
|
+
try:
|
|
667
|
+
return blockfile.recover(path).n_frames
|
|
668
|
+
except (FileNotFoundError, errors.FormatError) as exc:
|
|
669
|
+
logger.warning("cannot recover %s: %s", path, exc)
|
|
670
|
+
return -1
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def _write_empty(
|
|
674
|
+
path: pathlib.Path, info: manifest.StreamInfo, n_envs: int
|
|
675
|
+
) -> None:
|
|
676
|
+
"""Replaces an unusable block file with an empty finished one."""
|
|
677
|
+
with blockfile.BlockWriter(
|
|
678
|
+
path, item_shape=info.item_shape, n_envs=n_envs, kind=info.kind
|
|
679
|
+
):
|
|
680
|
+
pass # leaving the block finalizes an empty stream
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def _truncate_stream(path: pathlib.Path, n_frames: int) -> None:
|
|
684
|
+
"""Cuts a finished block file back to its first ``n_frames`` frames.
|
|
685
|
+
|
|
686
|
+
``n_frames`` must fall on a window boundary (or the end of the file).
|
|
687
|
+
"""
|
|
688
|
+
with blockfile.BlockReader(path, verify=False) as reader:
|
|
689
|
+
directory = reader.directory
|
|
690
|
+
shape, n_envs = reader.item_shape, reader.n_envs
|
|
691
|
+
block_frames = reader.block_frames
|
|
692
|
+
keep = int(np.count_nonzero(directory["t0"] < n_frames))
|
|
693
|
+
end = int(directory["offset"][keep]) if keep < len(directory) else 0
|
|
694
|
+
if end == 0:
|
|
695
|
+
return
|
|
696
|
+
body = directory[:keep].tobytes()
|
|
697
|
+
header = blockfile.Header(
|
|
698
|
+
shape, n_envs, n_frames, block_frames, keep, end, len(body)
|
|
699
|
+
)
|
|
700
|
+
with open(path, "r+b") as f:
|
|
701
|
+
f.truncate(end)
|
|
702
|
+
f.seek(end)
|
|
703
|
+
f.write(body)
|
|
704
|
+
f.seek(0)
|
|
705
|
+
f.write(header.pack())
|