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/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())