API reference

The getting started guide shows a recording, and the CLI reference lists the commands. This page is the Python API.

Library and recording

Library.rename(old, new) renames a rollout. The folder and the name in rollout.json change. The id, annotations, groups, and derived cache stay. It refuses a rollout that is still recording, and it refuses a name that already exists. Reopen a Rollout that was opened before the rename. The viewer does the same thing through POST /api/runs/<name>/rename.

simscope.library

A simscope library: a folder of recorded rollouts.

Library(root) is a cheap handle. The folder layout is created lazily, by the first recording. lib.record returns a :class:~simscope.recorder.Recorder, lib.open a :class:Rollout, and lib.query searches the SQLite index cache.

Library

A folder of rollouts.

Attributes:

Name Type Description
root

The library folder.

Source code in src/simscope/library.py
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
class Library:
    """A folder of rollouts.

    Attributes:
        root: The library folder.
    """

    def __init__(self, root: os.PathLike[str] | str) -> None:
        """Creates a handle. Nothing is created on disk yet.

        Args:
            root: The library folder; it and its layout are created by the
                first recording.
        """
        self.root = pathlib.Path(root)
        self._index = index.Index(self.root)

    @property
    def runs_dir(self) -> pathlib.Path:
        """The ``runs/`` folder."""
        return self.root / "runs"

    def run_dir(self, name: str) -> pathlib.Path:
        """Returns a run's folder (which may not exist).

        A run in ``runs/`` of this folder is that directory. Otherwise the
        folder is the one discovered under a nested library. When nothing
        matches, the path is ``runs/<name>`` of this folder, which is where
        a new recording would go.

        Raises:
            ValueError: If the name is invalid.
        """
        name = manifest.validate_run_name(name)
        direct = self.runs_dir / name
        if _has_manifest(direct):
            return direct
        owner = self._index.owner(name, scan=True)
        if owner is None:
            return direct
        return owner / "runs" / name

    def record(
        self,
        name: str,
        *,
        scene: core.Scene,
        dt: float,
        n_envs: int = 1,
        env_origins: npt.ArrayLike | None = None,
        source: Mapping[str, Any] | None = None,
        tags: Sequence[str] = (),
        meta: Mapping[str, Any] | None = None,
        codec: str = "f32s",
        block_frames: int = blockfile.DEFAULT_BLOCK_FRAMES,
        overwrite: bool = False,
        encode_threads: int | None = None,
        created: str | None = None,
    ) -> recorder.Recorder:
        """Starts recording a run.

        Use the result as a context manager, calling ``add_stream`` for
        extra streams and ``log`` once per step.

        Args:
            name: Run name, ``[A-Za-z0-9][A-Za-z0-9._-]{0,127}``.
            scene: The static scene.
            dt: Seconds between frames.
            n_envs: Number of parallel envs.
            env_origins: World offset per env, ``[n_envs, 3]``.
            source: Provenance, for example from ``mujoco.source_info()``.
            tags: Record-time tags.
            meta: Free-form JSON metadata.
            codec: Pose codec, ``"f32s"`` (lossless) or ``"q16d"``.
            block_frames: Frames per block.
            overwrite: Replace an existing run (deletes its folder).
            encode_threads: Threads the writer spreads per-env encoding over;
                ``None`` chooses from the env count.
            created: Recording time (UTC, ``YYYY-MM-DDTHH:MM:SSZ``);
                ``None`` means now. Importers pass the original time.

        Returns:
            The recorder.

        Raises:
            ValueError: If an argument is invalid.
            FileExistsError: If the run exists and ``overwrite`` is false.
        """
        return recorder.Recorder(
            self.root,
            name,
            scene=scene,
            dt=dt,
            n_envs=n_envs,
            env_origins=env_origins,
            source=source,
            tags=tags,
            meta=meta,
            codec=codec,
            block_frames=block_frames,
            overwrite=overwrite,
            encode_threads=encode_threads,
            created=created,
        )

    def open(self, name: str) -> Rollout:
        """Opens a run for reading.

        Args:
            name: Run name.

        Returns:
            The rollout.

        Raises:
            FileNotFoundError: If there is no such run.
            errors.FormatError: If its manifest is invalid.
            ValueError: If the name is invalid.
        """
        run_dir = self.run_dir(name)
        return Rollout(run_dir.parent.parent, name)

    def rename(self, old: str, new: str) -> None:
        """Renames a run.

        A run is known by its folder name and the ``name`` in its manifest;
        its ``id`` never changes, so annotations, groups, the derived cache
        and exports keep belonging to it. Only ``rollout.json`` is
        rewritten (atomically); streams, scenes and assets are untouched.
        If the folder cannot be renamed, the manifest is put back.

        :class:`Rollout` objects opened before the rename keep pointing at
        the old folder; reopen the run with :meth:`open`.

        Args:
            old: The run's current name.
            new: The new name, ``[A-Za-z0-9][A-Za-z0-9._-]{0,127}``.

        Raises:
            ValueError: If a name is invalid or the run is still recording.
            FileNotFoundError: If there is no run ``old``.
            FileExistsError: If a run ``new`` exists.
            OSError: If the folder cannot be renamed (for example on
                Windows while a file in it is open).
            errors.FormatError: If the manifest is invalid.
        """
        old_dir = self.run_dir(old)
        new_name = manifest.validate_run_name(new)
        if not old_dir.is_dir():
            raise FileNotFoundError(f"no run {old!r} in {self.root}")
        if not (old_dir / manifest.MANIFEST_NAME).is_file():
            raise ValueError(f"run {old!r} is still recording")
        owner = old_dir.parent.parent
        new_dir = owner / "runs" / new_name
        # On a case-insensitive filesystem a case-only rename names the same
        # folder; that is a rename, not a clash.
        if os.path.lexists(new_dir) and (
            old == new or not old_dir.samefile(new_dir)
        ):
            raise FileExistsError(f"run {new!r} already exists")
        from simscope import derived  # circular at module level

        m = manifest.read_manifest(old_dir)
        with Rollout(owner, old) as run:
            before = derived.fingerprint(run)
        m.name = new_name
        m.validate()
        cas.atomic_write(
            old_dir / manifest.MANIFEST_NAME, manifest.manifest_bytes(m)
        )
        try:
            os.rename(old_dir, new_dir)
        except OSError:
            m.name = old
            cas.atomic_write(
                old_dir / manifest.MANIFEST_NAME, manifest.manifest_bytes(m)
            )
            raise
        try:
            with Rollout(owner, new_name) as run:
                derived.carry_over(owner, run, before)
        except Exception:  # isolation point: a cold cache is only slower
            logger.warning("derived cache of %r not carried over", new_name)
        self._index.refresh()
        logger.info("renamed run %r to %r", old, new_name)

    # -- browsing --

    def refresh(self) -> index.RefreshStats:
        """Rescans rollout libraries under this folder and updates the index.

        A refresh is incremental: runs whose files are unchanged are not
        re-read. Libraries nested under this folder are included.
        """
        return self._index.refresh()

    def runs(self, refresh: bool = True) -> list[RunInfo]:
        """Lists every run, newest first.

        Args:
            refresh: Update the index first (cheap when nothing changed).

        Returns:
            One summary per run.
        """
        return self.query(limit=None, refresh=refresh)

    def query(
        self,
        text: str | None = None,
        tags: Iterable[str] = (),
        favorite: bool | None = None,
        status: str | None = None,
        sort: str = "created",
        descending: bool = True,
        limit: int | None = 50,
        offset: int = 0,
        *,
        refresh: bool = True,
    ) -> list[RunInfo]:
        """Searches the index. See :meth:`simscope.index.Index.query`.

        Args:
            text: Terms that must all appear in name, tags, group or notes.
            tags: Record-time tags a run must have (all of them).
            favorite: Only runs with this favorite mark.
            status: Manifest status to match (``"recording"``,
                ``"complete"``).
            sort: ``"created"``, ``"name"``, ``"n_frames"`` or ``"rating"``.
            descending: Sort direction.
            limit: Page size, or ``None`` for all.
            offset: Rows to skip.
            refresh: Update the index first.

        Returns:
            The matching page of run summaries. The ``n_frames`` of a run
            that is still recording counts the windows written so far, since
            the recorder rewrites its partial manifest after each window.
        """
        if refresh:
            self._index.refresh()
        rows = self._index.query(
            text, tags, favorite, status, sort, descending, limit, offset
        )
        return rows

    def count(
        self,
        text: str | None = None,
        tags: Iterable[str] = (),
        favorite: bool | None = None,
        status: str | None = None,
        *,
        refresh: bool = True,
    ) -> int:
        """Counts runs matching the filters of :meth:`query`."""
        if refresh:
            self._index.refresh()
        return self._index.count(text, tags, favorite, status)

    def close(self) -> None:
        """Closes the index connection (it reopens on demand)."""
        self._index.close()

    # -- event types --

    def event_types(self) -> dict[str, annotations.EventType]:
        """Returns the event vocabulary (the built-in one if unset)."""
        return annotations.load_event_types(self.root)[0]

    def set_event_types(
        self,
        types: Mapping[str, annotations.EventType]
        | Iterable[annotations.EventType],
    ) -> None:
        """Writes ``.simscope/event_types.json``.

        Args:
            types: Entries by id, or an iterable of entries (keyed by their
                ``type_id``). Unknown fields already in the file are kept.
        """
        if isinstance(types, Mapping):
            entries = dict(types)
        else:
            entries = {t.type_id: t for t in types}
        _, extra = annotations.load_event_types(self.root)
        annotations.save_event_types(self.root, entries, extra)

    # -- recovery --

    def recover(self, name: str) -> RecoverReport:
        """Repairs a run whose recording crashed.

        Every block file is recovered (complete windows kept, the rest cut
        off), streams are aligned to the shortest one, and the manifest is
        rewritten as ``"complete"``. A finished run is left untouched.
        Do not call it while a recorder is still writing the run.

        Args:
            name: Run name.

        Returns:
            What was kept.

        Raises:
            FileNotFoundError: If the run or its manifest is missing.
            errors.FormatError: If the manifest is invalid.
        """
        run_dir = self.run_dir(name)
        if (run_dir / manifest.MANIFEST_NAME).exists():
            cas.remove_file(run_dir / manifest.PARTIAL_NAME)
            m = manifest.read_manifest(run_dir)
            return RecoverReport(name, m.n_frames, True, {}, 0)
        m = manifest.read_manifest(run_dir)
        frames: dict[str, int] = {}
        for sname, info in m.streams.items():
            frames[sname] = _recover_stream(run_dir / info.file)
        # An unusable stream (-1) has no frames, so nothing can be kept.
        common = max(0, min(frames.values(), default=0))
        dropped = 0
        for sname, info in m.streams.items():
            path = run_dir / info.file
            if frames[sname] < 0:
                _write_empty(path, info, m.n_envs)
                frames[sname] = 0
            elif frames[sname] > common:
                dropped += frames[sname] - common
                _truncate_stream(path, common)
        m.n_frames = common
        m.status = "complete"
        manifest.write_manifest(run_dir, m, partial=False)
        logger.info("recovered run %r: %d frames", name, common)
        return RecoverReport(name, common, False, frames, dropped)

runs_dir: pathlib.Path property

The runs/ folder.

__init__(root: os.PathLike[str] | str) -> None

Creates a handle. Nothing is created on disk yet.

Parameters:

Name Type Description Default
root PathLike[str] | str

The library folder; it and its layout are created by the first recording.

required
Source code in src/simscope/library.py
353
354
355
356
357
358
359
360
361
def __init__(self, root: os.PathLike[str] | str) -> None:
    """Creates a handle. Nothing is created on disk yet.

    Args:
        root: The library folder; it and its layout are created by the
            first recording.
    """
    self.root = pathlib.Path(root)
    self._index = index.Index(self.root)

close() -> None

Closes the index connection (it reopens on demand).

Source code in src/simscope/library.py
606
607
608
def close(self) -> None:
    """Closes the index connection (it reopens on demand)."""
    self._index.close()

count(text: str | None = None, tags: Iterable[str] = (), favorite: bool | None = None, status: str | None = None, *, refresh: bool = True) -> int

Counts runs matching the filters of :meth:query.

Source code in src/simscope/library.py
592
593
594
595
596
597
598
599
600
601
602
603
604
def count(
    self,
    text: str | None = None,
    tags: Iterable[str] = (),
    favorite: bool | None = None,
    status: str | None = None,
    *,
    refresh: bool = True,
) -> int:
    """Counts runs matching the filters of :meth:`query`."""
    if refresh:
        self._index.refresh()
    return self._index.count(text, tags, favorite, status)

event_types() -> dict[str, annotations.EventType]

Returns the event vocabulary (the built-in one if unset).

Source code in src/simscope/library.py
612
613
614
def event_types(self) -> dict[str, annotations.EventType]:
    """Returns the event vocabulary (the built-in one if unset)."""
    return annotations.load_event_types(self.root)[0]

open(name: str) -> Rollout

Opens a run for reading.

Parameters:

Name Type Description Default
name str

Run name.

required

Returns:

Type Description
Rollout

The rollout.

Raises:

Type Description
FileNotFoundError

If there is no such run.

FormatError

If its manifest is invalid.

ValueError

If the name is invalid.

Source code in src/simscope/library.py
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
def open(self, name: str) -> Rollout:
    """Opens a run for reading.

    Args:
        name: Run name.

    Returns:
        The rollout.

    Raises:
        FileNotFoundError: If there is no such run.
        errors.FormatError: If its manifest is invalid.
        ValueError: If the name is invalid.
    """
    run_dir = self.run_dir(name)
    return Rollout(run_dir.parent.parent, name)

query(text: str | None = None, tags: Iterable[str] = (), favorite: bool | None = None, status: str | None = None, sort: str = 'created', descending: bool = True, limit: int | None = 50, offset: int = 0, *, refresh: bool = True) -> list[RunInfo]

Searches the index. See :meth:simscope.index.Index.query.

Parameters:

Name Type Description Default
text str | None

Terms that must all appear in name, tags, group or notes.

None
tags Iterable[str]

Record-time tags a run must have (all of them).

()
favorite bool | None

Only runs with this favorite mark.

None
status str | None

Manifest status to match ("recording", "complete").

None
sort str

"created", "name", "n_frames" or "rating".

'created'
descending bool

Sort direction.

True
limit int | None

Page size, or None for all.

50
offset int

Rows to skip.

0
refresh bool

Update the index first.

True

Returns:

Type Description
list[RunInfo]

The matching page of run summaries. The n_frames of a run

list[RunInfo]

that is still recording counts the windows written so far, since

list[RunInfo]

the recorder rewrites its partial manifest after each window.

Source code in src/simscope/library.py
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
def query(
    self,
    text: str | None = None,
    tags: Iterable[str] = (),
    favorite: bool | None = None,
    status: str | None = None,
    sort: str = "created",
    descending: bool = True,
    limit: int | None = 50,
    offset: int = 0,
    *,
    refresh: bool = True,
) -> list[RunInfo]:
    """Searches the index. See :meth:`simscope.index.Index.query`.

    Args:
        text: Terms that must all appear in name, tags, group or notes.
        tags: Record-time tags a run must have (all of them).
        favorite: Only runs with this favorite mark.
        status: Manifest status to match (``"recording"``,
            ``"complete"``).
        sort: ``"created"``, ``"name"``, ``"n_frames"`` or ``"rating"``.
        descending: Sort direction.
        limit: Page size, or ``None`` for all.
        offset: Rows to skip.
        refresh: Update the index first.

    Returns:
        The matching page of run summaries. The ``n_frames`` of a run
        that is still recording counts the windows written so far, since
        the recorder rewrites its partial manifest after each window.
    """
    if refresh:
        self._index.refresh()
    rows = self._index.query(
        text, tags, favorite, status, sort, descending, limit, offset
    )
    return rows

record(name: str, *, scene: core.Scene, dt: float, n_envs: int = 1, env_origins: npt.ArrayLike | None = None, source: Mapping[str, Any] | None = None, tags: Sequence[str] = (), meta: Mapping[str, Any] | None = None, codec: str = 'f32s', block_frames: int = blockfile.DEFAULT_BLOCK_FRAMES, overwrite: bool = False, encode_threads: int | None = None, created: str | None = None) -> recorder.Recorder

Starts recording a run.

Use the result as a context manager, calling add_stream for extra streams and log once per step.

Parameters:

Name Type Description Default
name str

Run name, [A-Za-z0-9][A-Za-z0-9._-]{0,127}.

required
scene Scene

The static scene.

required
dt float

Seconds between frames.

required
n_envs int

Number of parallel envs.

1
env_origins ArrayLike | None

World offset per env, [n_envs, 3].

None
source Mapping[str, Any] | None

Provenance, for example from mujoco.source_info().

None
tags Sequence[str]

Record-time tags.

()
meta Mapping[str, Any] | None

Free-form JSON metadata.

None
codec str

Pose codec, "f32s" (lossless) or "q16d".

'f32s'
block_frames int

Frames per block.

DEFAULT_BLOCK_FRAMES
overwrite bool

Replace an existing run (deletes its folder).

False
encode_threads int | None

Threads the writer spreads per-env encoding over; None chooses from the env count.

None
created str | None

Recording time (UTC, YYYY-MM-DDTHH:MM:SSZ); None means now. Importers pass the original time.

None

Returns:

Type Description
Recorder

The recorder.

Raises:

Type Description
ValueError

If an argument is invalid.

FileExistsError

If the run exists and overwrite is false.

Source code in src/simscope/library.py
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
def record(
    self,
    name: str,
    *,
    scene: core.Scene,
    dt: float,
    n_envs: int = 1,
    env_origins: npt.ArrayLike | None = None,
    source: Mapping[str, Any] | None = None,
    tags: Sequence[str] = (),
    meta: Mapping[str, Any] | None = None,
    codec: str = "f32s",
    block_frames: int = blockfile.DEFAULT_BLOCK_FRAMES,
    overwrite: bool = False,
    encode_threads: int | None = None,
    created: str | None = None,
) -> recorder.Recorder:
    """Starts recording a run.

    Use the result as a context manager, calling ``add_stream`` for
    extra streams and ``log`` once per step.

    Args:
        name: Run name, ``[A-Za-z0-9][A-Za-z0-9._-]{0,127}``.
        scene: The static scene.
        dt: Seconds between frames.
        n_envs: Number of parallel envs.
        env_origins: World offset per env, ``[n_envs, 3]``.
        source: Provenance, for example from ``mujoco.source_info()``.
        tags: Record-time tags.
        meta: Free-form JSON metadata.
        codec: Pose codec, ``"f32s"`` (lossless) or ``"q16d"``.
        block_frames: Frames per block.
        overwrite: Replace an existing run (deletes its folder).
        encode_threads: Threads the writer spreads per-env encoding over;
            ``None`` chooses from the env count.
        created: Recording time (UTC, ``YYYY-MM-DDTHH:MM:SSZ``);
            ``None`` means now. Importers pass the original time.

    Returns:
        The recorder.

    Raises:
        ValueError: If an argument is invalid.
        FileExistsError: If the run exists and ``overwrite`` is false.
    """
    return recorder.Recorder(
        self.root,
        name,
        scene=scene,
        dt=dt,
        n_envs=n_envs,
        env_origins=env_origins,
        source=source,
        tags=tags,
        meta=meta,
        codec=codec,
        block_frames=block_frames,
        overwrite=overwrite,
        encode_threads=encode_threads,
        created=created,
    )

recover(name: str) -> RecoverReport

Repairs a run whose recording crashed.

Every block file is recovered (complete windows kept, the rest cut off), streams are aligned to the shortest one, and the manifest is rewritten as "complete". A finished run is left untouched. Do not call it while a recorder is still writing the run.

Parameters:

Name Type Description Default
name str

Run name.

required

Returns:

Type Description
RecoverReport

What was kept.

Raises:

Type Description
FileNotFoundError

If the run or its manifest is missing.

FormatError

If the manifest is invalid.

Source code in src/simscope/library.py
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
def recover(self, name: str) -> RecoverReport:
    """Repairs a run whose recording crashed.

    Every block file is recovered (complete windows kept, the rest cut
    off), streams are aligned to the shortest one, and the manifest is
    rewritten as ``"complete"``. A finished run is left untouched.
    Do not call it while a recorder is still writing the run.

    Args:
        name: Run name.

    Returns:
        What was kept.

    Raises:
        FileNotFoundError: If the run or its manifest is missing.
        errors.FormatError: If the manifest is invalid.
    """
    run_dir = self.run_dir(name)
    if (run_dir / manifest.MANIFEST_NAME).exists():
        cas.remove_file(run_dir / manifest.PARTIAL_NAME)
        m = manifest.read_manifest(run_dir)
        return RecoverReport(name, m.n_frames, True, {}, 0)
    m = manifest.read_manifest(run_dir)
    frames: dict[str, int] = {}
    for sname, info in m.streams.items():
        frames[sname] = _recover_stream(run_dir / info.file)
    # An unusable stream (-1) has no frames, so nothing can be kept.
    common = max(0, min(frames.values(), default=0))
    dropped = 0
    for sname, info in m.streams.items():
        path = run_dir / info.file
        if frames[sname] < 0:
            _write_empty(path, info, m.n_envs)
            frames[sname] = 0
        elif frames[sname] > common:
            dropped += frames[sname] - common
            _truncate_stream(path, common)
    m.n_frames = common
    m.status = "complete"
    manifest.write_manifest(run_dir, m, partial=False)
    logger.info("recovered run %r: %d frames", name, common)
    return RecoverReport(name, common, False, frames, dropped)

refresh() -> index.RefreshStats

Rescans rollout libraries under this folder and updates the index.

A refresh is incremental: runs whose files are unchanged are not re-read. Libraries nested under this folder are included.

Source code in src/simscope/library.py
534
535
536
537
538
539
540
def refresh(self) -> index.RefreshStats:
    """Rescans rollout libraries under this folder and updates the index.

    A refresh is incremental: runs whose files are unchanged are not
    re-read. Libraries nested under this folder are included.
    """
    return self._index.refresh()

rename(old: str, new: str) -> None

Renames a run.

A run is known by its folder name and the name in its manifest; its id never changes, so annotations, groups, the derived cache and exports keep belonging to it. Only rollout.json is rewritten (atomically); streams, scenes and assets are untouched. If the folder cannot be renamed, the manifest is put back.

:class:Rollout objects opened before the rename keep pointing at the old folder; reopen the run with :meth:open.

Parameters:

Name Type Description Default
old str

The run's current name.

required
new str

The new name, [A-Za-z0-9][A-Za-z0-9._-]{0,127}.

required

Raises:

Type Description
ValueError

If a name is invalid or the run is still recording.

FileNotFoundError

If there is no run old.

FileExistsError

If a run new exists.

OSError

If the folder cannot be renamed (for example on Windows while a file in it is open).

FormatError

If the manifest is invalid.

Source code in src/simscope/library.py
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
def rename(self, old: str, new: str) -> None:
    """Renames a run.

    A run is known by its folder name and the ``name`` in its manifest;
    its ``id`` never changes, so annotations, groups, the derived cache
    and exports keep belonging to it. Only ``rollout.json`` is
    rewritten (atomically); streams, scenes and assets are untouched.
    If the folder cannot be renamed, the manifest is put back.

    :class:`Rollout` objects opened before the rename keep pointing at
    the old folder; reopen the run with :meth:`open`.

    Args:
        old: The run's current name.
        new: The new name, ``[A-Za-z0-9][A-Za-z0-9._-]{0,127}``.

    Raises:
        ValueError: If a name is invalid or the run is still recording.
        FileNotFoundError: If there is no run ``old``.
        FileExistsError: If a run ``new`` exists.
        OSError: If the folder cannot be renamed (for example on
            Windows while a file in it is open).
        errors.FormatError: If the manifest is invalid.
    """
    old_dir = self.run_dir(old)
    new_name = manifest.validate_run_name(new)
    if not old_dir.is_dir():
        raise FileNotFoundError(f"no run {old!r} in {self.root}")
    if not (old_dir / manifest.MANIFEST_NAME).is_file():
        raise ValueError(f"run {old!r} is still recording")
    owner = old_dir.parent.parent
    new_dir = owner / "runs" / new_name
    # On a case-insensitive filesystem a case-only rename names the same
    # folder; that is a rename, not a clash.
    if os.path.lexists(new_dir) and (
        old == new or not old_dir.samefile(new_dir)
    ):
        raise FileExistsError(f"run {new!r} already exists")
    from simscope import derived  # circular at module level

    m = manifest.read_manifest(old_dir)
    with Rollout(owner, old) as run:
        before = derived.fingerprint(run)
    m.name = new_name
    m.validate()
    cas.atomic_write(
        old_dir / manifest.MANIFEST_NAME, manifest.manifest_bytes(m)
    )
    try:
        os.rename(old_dir, new_dir)
    except OSError:
        m.name = old
        cas.atomic_write(
            old_dir / manifest.MANIFEST_NAME, manifest.manifest_bytes(m)
        )
        raise
    try:
        with Rollout(owner, new_name) as run:
            derived.carry_over(owner, run, before)
    except Exception:  # isolation point: a cold cache is only slower
        logger.warning("derived cache of %r not carried over", new_name)
    self._index.refresh()
    logger.info("renamed run %r to %r", old, new_name)

run_dir(name: str) -> pathlib.Path

Returns a run's folder (which may not exist).

A run in runs/ of this folder is that directory. Otherwise the folder is the one discovered under a nested library. When nothing matches, the path is runs/<name> of this folder, which is where a new recording would go.

Raises:

Type Description
ValueError

If the name is invalid.

Source code in src/simscope/library.py
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
def run_dir(self, name: str) -> pathlib.Path:
    """Returns a run's folder (which may not exist).

    A run in ``runs/`` of this folder is that directory. Otherwise the
    folder is the one discovered under a nested library. When nothing
    matches, the path is ``runs/<name>`` of this folder, which is where
    a new recording would go.

    Raises:
        ValueError: If the name is invalid.
    """
    name = manifest.validate_run_name(name)
    direct = self.runs_dir / name
    if _has_manifest(direct):
        return direct
    owner = self._index.owner(name, scan=True)
    if owner is None:
        return direct
    return owner / "runs" / name

runs(refresh: bool = True) -> list[RunInfo]

Lists every run, newest first.

Parameters:

Name Type Description Default
refresh bool

Update the index first (cheap when nothing changed).

True

Returns:

Type Description
list[RunInfo]

One summary per run.

Source code in src/simscope/library.py
542
543
544
545
546
547
548
549
550
551
def runs(self, refresh: bool = True) -> list[RunInfo]:
    """Lists every run, newest first.

    Args:
        refresh: Update the index first (cheap when nothing changed).

    Returns:
        One summary per run.
    """
    return self.query(limit=None, refresh=refresh)

set_event_types(types: Mapping[str, annotations.EventType] | Iterable[annotations.EventType]) -> None

Writes .simscope/event_types.json.

Parameters:

Name Type Description Default
types Mapping[str, EventType] | Iterable[EventType]

Entries by id, or an iterable of entries (keyed by their type_id). Unknown fields already in the file are kept.

required
Source code in src/simscope/library.py
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
def set_event_types(
    self,
    types: Mapping[str, annotations.EventType]
    | Iterable[annotations.EventType],
) -> None:
    """Writes ``.simscope/event_types.json``.

    Args:
        types: Entries by id, or an iterable of entries (keyed by their
            ``type_id``). Unknown fields already in the file are kept.
    """
    if isinstance(types, Mapping):
        entries = dict(types)
    else:
        entries = {t.type_id: t for t in types}
    _, extra = annotations.load_event_types(self.root)
    annotations.save_event_types(self.root, entries, extra)

RecoverReport dataclass

What :meth:Library.recover did.

Attributes:

Name Type Description
name str

Run name.

n_frames int

Frames in the recovered run.

already_complete bool

True if the run was finished and left untouched.

streams Mapping[str, int]

Frames each stream held before they were aligned.

dropped_frames int

Frames removed from streams that were ahead of the shortest one (a crash between two streams' writes).

Source code in src/simscope/library.py
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
@dataclasses.dataclass(frozen=True)
class RecoverReport:
    """What :meth:`Library.recover` did.

    Attributes:
        name: Run name.
        n_frames: Frames in the recovered run.
        already_complete: True if the run was finished and left untouched.
        streams: Frames each stream held before they were aligned.
        dropped_frames: Frames removed from streams that were ahead of the
            shortest one (a crash between two streams' writes).
    """

    name: str
    n_frames: int
    already_complete: bool
    streams: Mapping[str, int]
    dropped_frames: int

Rollout

One recorded run, opened for reading.

Block files open lazily and are memory-mapped; nothing is decoded until a window is read. Close the rollout (or use with) to release them.

A run that is still recording (rollout.json.partial) can be tailed: call :meth:refresh to pick up the windows the recorder has finished since. Only complete windows count, so n_frames never includes a window that is half written. When the recorder finishes, refresh switches to the final manifest and is_recording becomes false.

Attributes:

Name Type Description
name

Run name.

path

Run directory.

manifest

The parsed rollout.json (or .partial).

Source code in src/simscope/library.py
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
class Rollout:
    """One recorded run, opened for reading.

    Block files open lazily and are memory-mapped; nothing is decoded until
    a window is read. Close the rollout (or use ``with``) to release them.

    A run that is still recording (``rollout.json.partial``) can be tailed:
    call :meth:`refresh` to pick up the windows the recorder has finished
    since. Only complete windows count, so ``n_frames`` never includes a
    window that is half written. When the recorder finishes, ``refresh``
    switches to the final manifest and ``is_recording`` becomes false.

    Attributes:
        name: Run name.
        path: Run directory.
        manifest: The parsed ``rollout.json`` (or ``.partial``).
    """

    def __init__(self, root: pathlib.Path, name: str) -> None:
        """Reads the manifest.

        Args:
            root: The library root.
            name: Run name.

        Raises:
            FileNotFoundError: If the run does not exist.
            errors.FormatError: If the manifest is invalid.
        """
        self._root = pathlib.Path(root)
        self.name = manifest.validate_run_name(name)
        self.path = self._root / "runs" / name
        if not self.path.is_dir():
            raise FileNotFoundError(f"no run {name!r} in {self._root}")
        self.manifest = manifest.read_manifest(self.path)
        self._readers: dict[str, blockfile.BlockReader] = {}
        self._scene: core.Scene | None = None
        self._annotations: annotations.Annotations | None = None
        self._lock = threading.RLock()
        self._tailing = False
        self._live_frames = 0

    @property
    def is_recording(self) -> bool:
        """True while the recorder has not finished the run.

        It reflects the manifest as of the last :meth:`refresh`. A run whose
        recorder crashed stays "recording" until :meth:`Library.recover`.
        """
        return self.manifest.status == "recording"

    @property
    def dt(self) -> float:
        """Seconds between frames."""
        return self.manifest.dt

    @property
    def n_frames(self) -> int:
        """Frames per stream.

        For a recording run this is the count found by the latest
        :meth:`refresh` (0 before the first one).
        """
        if self.is_recording:
            return self._live_frames
        return self.manifest.n_frames

    @property
    def n_envs(self) -> int:
        """Envs per stream."""
        return self.manifest.n_envs

    @property
    def env_origins(self) -> npt.NDArray[np.float64]:
        """World offset of each env, ``[n_envs, 3]`` (zeros if unset)."""
        origins = self.manifest.env_origins
        if origins is None:
            return np.zeros((self.n_envs, 3))
        return np.asarray(origins, dtype=np.float64)

    @property
    def scene(self) -> core.Scene:
        """The scene, loaded from the content store on first use."""
        if self._scene is None:
            store = cas.ContentStore(self._root)
            self._scene = scene_io.load_scene(store, self.manifest.scene)
        return self._scene

    def stream(self, name: str, *, live: bool = False) -> blockfile.BlockReader:
        """Returns the reader of a stream, opening it on first use.

        Args:
            name: Stream name from the manifest.
            live: Allow a stream of a recording run, tailing its file. It is
                implied once :meth:`refresh` or :meth:`frame_source` has been
                called. Without it, a run that is not complete raises, since
                it may have crashed.

        Returns:
            A shared ``BlockReader``; it is closed with the rollout. For a
            recording run its ``n_frames`` only advances in :meth:`refresh`.

        Raises:
            KeyError: If the run has no such stream.
            errors.FormatError: If the run is unfinished and not read with
                ``live`` (see :meth:`Library.recover`), or a file is corrupt.
        """
        info = self.manifest.streams.get(name)
        if info is None:
            raise KeyError(
                f"run {self.name!r} has no stream {name!r}; "
                f"it has {sorted(self.manifest.streams)}"
            )
        with self._lock:
            reader = self._readers.get(name)
            if reader is None:
                partial = self.is_recording
                if partial and not (live or self._tailing):
                    raise errors.FormatError(
                        f"run {self.name!r} is unfinished "
                        f"(status {self.manifest.status!r}); "
                        "call Library.recover() first"
                    )
                reader = blockfile.BlockReader(
                    self.path / info.file, kind=info.kind, partial=partial
                )
                self._readers[name] = reader
            return reader

    def refresh(self) -> int:
        """Picks up what the recorder has written since the last call.

        Re-reads the manifest (``rollout.json.partial``, or the final
        ``rollout.json`` once the run is complete) and refreshes every stream
        reader. The cost is proportional to the new data. On a run that was
        complete when opened this does nothing.

        Returns:
            Frames readable in every stream: the minimum across streams while
            recording, and the manifest's count once complete.

        Raises:
            errors.FormatError: If the manifest or a block file is corrupt.
        """
        with self._lock:
            if self.manifest.status == "complete":
                return self.manifest.n_frames
            self._tailing = True
            self.manifest = self._read_manifest()
            counts = []
            for name in self.manifest.streams:
                reader = self._live_reader(name)
                counts.append(0 if reader is None else reader.refresh())
            self._live_frames = min(counts, default=0)
            if self._annotations is not None:
                self._annotations.n_frames = self.n_frames
            return self.n_frames

    def _read_manifest(self) -> manifest.RolloutManifest:
        """Reads the manifest, tolerating the partial-to-final handover."""
        try:
            return manifest.read_manifest(self.path)
        except FileNotFoundError:
            # The recorder wrote rollout.json and removed the partial file
            # between our two lookups; the final file exists now.
            return manifest.read_manifest(self.path)

    def _live_reader(self, name: str) -> blockfile.BlockReader | None:
        """Opens a tailing reader, or ``None`` if its file has no header."""
        try:
            return self.stream(name, live=True)
        except errors.FormatError:
            file = self.path / self.manifest.streams[name].file
            if file.stat().st_size >= blockfile.HEADER_SIZE:
                raise
            return None  # the recorder has created the file, not written it

    def frame_source(self, stream: str = "body_pose") -> _PoseFrameSource:
        """Returns a viewer-ready source of pose frames.

        For a recording run this refreshes the rollout first, and the
        source's ``n_frames`` then follows each :meth:`refresh`.

        Args:
            stream: A pose stream; ``body_pose`` by default.

        Returns:
            An object with ``n_frames``, ``n_envs``, ``dt`` and
            ``read(t0, t1) -> [t1 - t0, E, B, 7]``.

        Raises:
            ValueError: If the stream is not a pose stream.
        """
        info = self.manifest.streams.get(stream)
        if info is not None and info.kind != "pose":
            raise ValueError(f"stream {stream!r} is {info.kind}, not pose")
        if self.is_recording:
            self.refresh()
        return _PoseFrameSource(
            self, self.stream(stream), self.dt, self.env_origins
        )

    def reload_annotations(self) -> annotations.Annotations:
        """Re-reads the curation sidecar from disk, dropping unsaved edits.

        Returns:
            The freshly loaded annotations, which :attr:`annotations` returns
            from then on.
        """
        self._annotations = None
        return self.annotations

    @property
    def annotations(self) -> annotations.Annotations:
        """The run's curation sidecar (loaded once; call ``save()``)."""
        if self._annotations is None:
            self._annotations = annotations.Annotations.load(
                self.path, self.manifest.id, self.dt, self.n_frames
            )
        return self._annotations

    def close(self) -> None:
        """Releases every open block file."""
        with self._lock:
            for reader in self._readers.values():
                reader.close()
            self._readers.clear()

    def __enter__(self) -> "Rollout":
        """Returns the rollout."""
        return self

    def __exit__(
        self,
        exc_type: type[BaseException] | None,
        exc: BaseException | None,
        tb: TracebackType | None,
    ) -> None:
        """Closes the rollout."""
        self.close()

annotations: annotations.Annotations property

The run's curation sidecar (loaded once; call save()).

dt: float property

Seconds between frames.

env_origins: npt.NDArray[np.float64] property

World offset of each env, [n_envs, 3] (zeros if unset).

is_recording: bool property

True while the recorder has not finished the run.

It reflects the manifest as of the last :meth:refresh. A run whose recorder crashed stays "recording" until :meth:Library.recover.

n_envs: int property

Envs per stream.

n_frames: int property

Frames per stream.

For a recording run this is the count found by the latest :meth:refresh (0 before the first one).

scene: core.Scene property

The scene, loaded from the content store on first use.

__enter__() -> Rollout

Returns the rollout.

Source code in src/simscope/library.py
312
313
314
def __enter__(self) -> "Rollout":
    """Returns the rollout."""
    return self

__exit__(exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None) -> None

Closes the rollout.

Source code in src/simscope/library.py
316
317
318
319
320
321
322
323
def __exit__(
    self,
    exc_type: type[BaseException] | None,
    exc: BaseException | None,
    tb: TracebackType | None,
) -> None:
    """Closes the rollout."""
    self.close()

__init__(root: pathlib.Path, name: str) -> None

Reads the manifest.

Parameters:

Name Type Description Default
root Path

The library root.

required
name str

Run name.

required

Raises:

Type Description
FileNotFoundError

If the run does not exist.

FormatError

If the manifest is invalid.

Source code in src/simscope/library.py
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
def __init__(self, root: pathlib.Path, name: str) -> None:
    """Reads the manifest.

    Args:
        root: The library root.
        name: Run name.

    Raises:
        FileNotFoundError: If the run does not exist.
        errors.FormatError: If the manifest is invalid.
    """
    self._root = pathlib.Path(root)
    self.name = manifest.validate_run_name(name)
    self.path = self._root / "runs" / name
    if not self.path.is_dir():
        raise FileNotFoundError(f"no run {name!r} in {self._root}")
    self.manifest = manifest.read_manifest(self.path)
    self._readers: dict[str, blockfile.BlockReader] = {}
    self._scene: core.Scene | None = None
    self._annotations: annotations.Annotations | None = None
    self._lock = threading.RLock()
    self._tailing = False
    self._live_frames = 0

close() -> None

Releases every open block file.

Source code in src/simscope/library.py
305
306
307
308
309
310
def close(self) -> None:
    """Releases every open block file."""
    with self._lock:
        for reader in self._readers.values():
            reader.close()
        self._readers.clear()

frame_source(stream: str = 'body_pose') -> _PoseFrameSource

Returns a viewer-ready source of pose frames.

For a recording run this refreshes the rollout first, and the source's n_frames then follows each :meth:refresh.

Parameters:

Name Type Description Default
stream str

A pose stream; body_pose by default.

'body_pose'

Returns:

Type Description
_PoseFrameSource

An object with n_frames, n_envs, dt and

_PoseFrameSource

read(t0, t1) -> [t1 - t0, E, B, 7].

Raises:

Type Description
ValueError

If the stream is not a pose stream.

Source code in src/simscope/library.py
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
def frame_source(self, stream: str = "body_pose") -> _PoseFrameSource:
    """Returns a viewer-ready source of pose frames.

    For a recording run this refreshes the rollout first, and the
    source's ``n_frames`` then follows each :meth:`refresh`.

    Args:
        stream: A pose stream; ``body_pose`` by default.

    Returns:
        An object with ``n_frames``, ``n_envs``, ``dt`` and
        ``read(t0, t1) -> [t1 - t0, E, B, 7]``.

    Raises:
        ValueError: If the stream is not a pose stream.
    """
    info = self.manifest.streams.get(stream)
    if info is not None and info.kind != "pose":
        raise ValueError(f"stream {stream!r} is {info.kind}, not pose")
    if self.is_recording:
        self.refresh()
    return _PoseFrameSource(
        self, self.stream(stream), self.dt, self.env_origins
    )

refresh() -> int

Picks up what the recorder has written since the last call.

Re-reads the manifest (rollout.json.partial, or the final rollout.json once the run is complete) and refreshes every stream reader. The cost is proportional to the new data. On a run that was complete when opened this does nothing.

Returns:

Type Description
int

Frames readable in every stream: the minimum across streams while

int

recording, and the manifest's count once complete.

Raises:

Type Description
FormatError

If the manifest or a block file is corrupt.

Source code in src/simscope/library.py
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
def refresh(self) -> int:
    """Picks up what the recorder has written since the last call.

    Re-reads the manifest (``rollout.json.partial``, or the final
    ``rollout.json`` once the run is complete) and refreshes every stream
    reader. The cost is proportional to the new data. On a run that was
    complete when opened this does nothing.

    Returns:
        Frames readable in every stream: the minimum across streams while
        recording, and the manifest's count once complete.

    Raises:
        errors.FormatError: If the manifest or a block file is corrupt.
    """
    with self._lock:
        if self.manifest.status == "complete":
            return self.manifest.n_frames
        self._tailing = True
        self.manifest = self._read_manifest()
        counts = []
        for name in self.manifest.streams:
            reader = self._live_reader(name)
            counts.append(0 if reader is None else reader.refresh())
        self._live_frames = min(counts, default=0)
        if self._annotations is not None:
            self._annotations.n_frames = self.n_frames
        return self.n_frames

reload_annotations() -> annotations.Annotations

Re-reads the curation sidecar from disk, dropping unsaved edits.

Returns:

Type Description
Annotations

The freshly loaded annotations, which :attr:annotations returns

Annotations

from then on.

Source code in src/simscope/library.py
286
287
288
289
290
291
292
293
294
def reload_annotations(self) -> annotations.Annotations:
    """Re-reads the curation sidecar from disk, dropping unsaved edits.

    Returns:
        The freshly loaded annotations, which :attr:`annotations` returns
        from then on.
    """
    self._annotations = None
    return self.annotations

stream(name: str, *, live: bool = False) -> blockfile.BlockReader

Returns the reader of a stream, opening it on first use.

Parameters:

Name Type Description Default
name str

Stream name from the manifest.

required
live bool

Allow a stream of a recording run, tailing its file. It is implied once :meth:refresh or :meth:frame_source has been called. Without it, a run that is not complete raises, since it may have crashed.

False

Returns:

Type Description
BlockReader

A shared BlockReader; it is closed with the rollout. For a

BlockReader

recording run its n_frames only advances in :meth:refresh.

Raises:

Type Description
KeyError

If the run has no such stream.

FormatError

If the run is unfinished and not read with live (see :meth:Library.recover), or a file is corrupt.

Source code in src/simscope/library.py
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
def stream(self, name: str, *, live: bool = False) -> blockfile.BlockReader:
    """Returns the reader of a stream, opening it on first use.

    Args:
        name: Stream name from the manifest.
        live: Allow a stream of a recording run, tailing its file. It is
            implied once :meth:`refresh` or :meth:`frame_source` has been
            called. Without it, a run that is not complete raises, since
            it may have crashed.

    Returns:
        A shared ``BlockReader``; it is closed with the rollout. For a
        recording run its ``n_frames`` only advances in :meth:`refresh`.

    Raises:
        KeyError: If the run has no such stream.
        errors.FormatError: If the run is unfinished and not read with
            ``live`` (see :meth:`Library.recover`), or a file is corrupt.
    """
    info = self.manifest.streams.get(name)
    if info is None:
        raise KeyError(
            f"run {self.name!r} has no stream {name!r}; "
            f"it has {sorted(self.manifest.streams)}"
        )
    with self._lock:
        reader = self._readers.get(name)
        if reader is None:
            partial = self.is_recording
            if partial and not (live or self._tailing):
                raise errors.FormatError(
                    f"run {self.name!r} is unfinished "
                    f"(status {self.manifest.status!r}); "
                    "call Library.recover() first"
                )
            reader = blockfile.BlockReader(
                self.path / info.file, kind=info.kind, partial=partial
            )
            self._readers[name] = reader
        return reader

simscope.recorder

Crash-safe, low-overhead rollout recorder.

Recorder.log only validates cheaply and copies one frame into a preallocated window buffer [block_frames, E, K] per stream. When a window fills it is handed to one background thread that applies pose sign continuity, encodes (deflate releases the GIL, so this overlaps with the simulator) and appends the blocks to the .blk files. Every finished window is flushed to the OS, so a crash loses at most the window in flight; Library.recover restores the rest.

Recorder

Records one rollout into a library.

Create it with Library.record. Use it as a context manager: leaving the with block normally calls :meth:close (status "complete"); leaving it on an exception calls :meth:abort, which keeps the run recoverable (rollout.json.partial). Not thread-safe: call log from one thread.

Attributes:

Name Type Description
name

Run name.

path

Run directory.

dt

Seconds per frame.

n_envs

Envs per frame.

block_frames

Frames per block.

Source code in src/simscope/recorder.py
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
class Recorder:
    """Records one rollout into a library.

    Create it with ``Library.record``. Use it as a context manager: leaving
    the ``with`` block normally calls :meth:`close` (status ``"complete"``);
    leaving it on an exception calls :meth:`abort`, which keeps the run
    recoverable (``rollout.json.partial``). Not thread-safe: call ``log``
    from one thread.

    Attributes:
        name: Run name.
        path: Run directory.
        dt: Seconds per frame.
        n_envs: Envs per frame.
        block_frames: Frames per block.
    """

    def __init__(
        self,
        root: pathlib.Path,
        name: str,
        *,
        scene: core.Scene,
        dt: float,
        n_envs: int = 1,
        env_origins: npt.ArrayLike | None = None,
        source: Mapping[str, Any] | None = None,
        tags: Sequence[str] = (),
        meta: Mapping[str, Any] | None = None,
        codec: str = "f32s",
        block_frames: int = blockfile.DEFAULT_BLOCK_FRAMES,
        overwrite: bool = False,
        max_pending: int = 2,
        encode_threads: int | None = None,
        created: str | None = None,
    ) -> None:
        """Validates arguments. Nothing is written until the first ``log``.

        Args:
            root: The library root.
            name: Run name, ``[A-Za-z0-9][A-Za-z0-9._-]{0,127}``.
            scene: The static scene. Its body count fixes the pose shape.
            dt: Seconds between frames (positive).
            n_envs: Number of parallel envs.
            env_origins: World offset of each env, shape ``[n_envs, 3]``, or
                ``None`` for zeros.
            source: Provenance, such as ``{"simulator": "mujoco", ...}``.
            tags: Record-time tags.
            meta: Free-form JSON-serializable metadata.
            codec: Codec of the pose stream: ``"f32s"`` (lossless) or
                ``"q16d"``. Other streams use their own codec (default
                ``"f32s"``).
            block_frames: Frames per block.
            overwrite: Replace an existing run of this name. The whole run
                directory (including its annotations) is deleted when
                recording starts.
            created: When the run was recorded, ``YYYY-MM-DDTHH:MM:SSZ``
                (UTC). Importers pass the time of the original recording;
                ``None`` means now.
            max_pending: Full windows that may wait for the encoder before
                ``log`` blocks.
            encode_threads: Threads the background writer fans a window's
                per-env blocks out to (deflate releases the GIL, so this
                scales). ``None`` picks 1 for fewer than 4 envs, else up
                to 4.

        Raises:
            ValueError: If an argument is invalid.
            FileExistsError: If the run exists and ``overwrite`` is false.
        """
        self.name = manifest.validate_run_name(name)
        self._root = pathlib.Path(root)
        self.path = self._root / "runs" / name
        if not dt > 0 or not math.isfinite(dt):
            raise ValueError(f"dt must be positive and finite, got {dt!r}")
        if n_envs < 1 or block_frames < 1 or max_pending < 1:
            raise ValueError("n_envs, block_frames, max_pending must be >= 1")
        codecs.codec_id(codec)
        self.dt = float(dt)
        self.n_envs = int(n_envs)
        self.block_frames = int(block_frames)
        self._scene = scene
        self._codec = codec
        self._overwrite = overwrite
        self._max_pending = int(max_pending)
        if encode_threads is None:
            encode_threads = 1 if n_envs < 4 else min(4, os.cpu_count() or 1)
        if encode_threads < 1:
            raise ValueError("encode_threads must be at least 1")
        self._encode_threads = int(encode_threads)
        self._pool: concurrent.futures.ThreadPoolExecutor | None = None
        self._source = dict(source or {})
        self._tags = tuple(str(t) for t in tags)
        self._meta = dict(meta or {})
        json.dumps([self._source, self._meta], allow_nan=False)  # early check
        self._origins = self._parse_origins(env_origins)
        self._check_free()
        self._id = manifest.new_ulid()
        self._created = created or manifest.utc_now()
        self._specs: list[_Stream] = []
        self._by_name: dict[str, _Stream] = {}
        self._started = False
        self._closed = False
        self._error: BaseException | None = None
        self._stats = {"stalls": 0, "stall_seconds": 0.0, "close_seconds": 0.0}

    # -- setup --

    def _parse_origins(
        self, origins: npt.ArrayLike | None
    ) -> tuple[core.Vec3, ...] | None:
        """Validates env origins into a tuple of triples."""
        if origins is None:
            return None
        arr = np.asarray(origins, dtype=np.float64)
        if arr.shape != (self.n_envs, 3) or not np.isfinite(arr).all():
            raise ValueError(
                f"env_origins must be finite with shape ({self.n_envs}, 3)"
            )
        return tuple((float(x), float(y), float(z)) for x, y, z in arr)

    def _check_free(self) -> None:
        """Raises if a finished or in-progress run of this name exists."""
        if self._overwrite:
            return
        for fname in (manifest.MANIFEST_NAME, manifest.PARTIAL_NAME):
            if (self.path / fname).exists():
                raise FileExistsError(
                    f"run {self.name!r} exists in {self._root}; "
                    "pass overwrite=True to replace it"
                )

    def add_stream(
        self,
        name: str,
        kind: core.StreamKind,
        item_shape: Sequence[int] = (),
        *,
        labels: Sequence[str] | None = None,
        units: str | None = None,
        codec: str = "f32s",
        scale: float | None = None,
    ) -> None:
        """Declares an extra stream. Call before the first ``log``.

        Args:
            name: Stream name; also the keyword given to ``log``.
            kind: How viewers draw it (``"scalar"``, ``"vector"``,
                ``"arrows"``, ``"points"`` or ``"polyline"``).
            item_shape: Shape of one env-frame item (``()`` for a scalar,
                ``(K,)`` for a vector, ``(K, 6)`` for arrows, ...).
            labels: Optional component names.
            units: Optional unit string.
            codec: ``"f32s"`` (lossless, default) or ``"q16d"``.
            scale: For ``arrows`` streams, metres drawn per unit of vector.
                ``None`` means viewers use 1.

        Raises:
            RecorderError: If recording already started.
            ValueError: If the name is taken or invalid, or the kind and
                shape do not fit.
        """
        if self._started or self._closed:
            raise RecorderError("streams must be declared before the first log")
        try:
            manifest.validate_run_name(name)
        except ValueError as exc:
            raise ValueError(f"invalid stream name {name!r}") from exc
        if name == manifest.BODY_POSE or name in self._by_name:
            raise ValueError(f"stream {name!r} is already declared")
        if kind not in core.STREAM_KINDS or kind == "pose":
            raise ValueError(f"extra stream kind must be non-pose: {kind!r}")
        codecs.codec_id(codec)
        shape = tuple(int(d) for d in item_shape)
        if kind == "scalar" and shape != ():
            raise ValueError("scalar streams have item_shape ()")
        if kind == "vector" and len(shape) != 1:
            raise ValueError("vector streams need item_shape (K,)")
        if kind == "arrows" and (len(shape) != 2 or shape[1] != 6):
            raise ValueError("arrows streams need item_shape (K, 6)")
        if kind in ("points", "polyline") and (
            len(shape) != 2 or shape[1] != 3
        ):
            raise ValueError(f"{kind} streams need item_shape (K, 3)")
        if 0 in shape or len(shape) > 4:
            raise ValueError(f"invalid item_shape {shape}")
        if labels is not None and len(labels) != (shape[-1] if shape else 1):
            raise ValueError("labels must have one entry per component")
        spec = _Stream(
            len(self._specs) + 1,
            name,
            kind,
            shape,
            self.n_envs,
            codec,
            None if labels is None else tuple(labels),
            units,
        )
        if scale is not None:
            if kind != "arrows":
                raise ValueError("scale applies to arrows streams only")
            spec.info.scale = float(scale)
        self._register(spec)

    def _register(self, spec: _Stream) -> None:
        """Adds a stream description."""
        self._specs.append(spec)
        self._by_name[spec.name] = spec

    def _start(
        self,
        pose_shape: tuple[int, ...],
        extra_shapes: Mapping[str, tuple[int, ...]],
    ) -> None:
        """Validates the first frame, then creates files and the worker.

        Args:
            pose_shape: Shape of one frame of poses, ``[E, B, 7]`` or
                ``[B, 7]`` when ``n_envs == 1``.
            extra_shapes: Per-frame shape of every stream given to the
                first ``log``. Undeclared scalars are auto-declared.

        Raises:
            ValueError: If shapes disagree with the scene or declarations.
        """
        n_bodies = self._scene.n_bodies
        want = (self.n_envs, n_bodies, core.POSE_DIM)
        if pose_shape not in (want, want[1:]) or (
            pose_shape == want[1:] and self.n_envs != 1
        ):
            raise _frame_error("poses", want, pose_shape)
        pose = _Stream(
            0,
            manifest.BODY_POSE,
            "pose",
            (n_bodies, core.POSE_DIM),
            self.n_envs,
            self._codec,
        )
        specs = [pose, *self._specs]
        for name, shape in extra_shapes.items():
            if name in self._by_name:
                continue
            if shape not in ((), (self.n_envs,)):
                raise ValueError(
                    f"stream {name!r} was not declared; call add_stream() "
                    "before the first log (only scalars can be inferred)"
                )
            spec = _Stream(len(specs), name, "scalar", (), self.n_envs, "f32s")
            specs.append(spec)
            self._by_name[name] = spec
        self._specs = specs
        self._open_files()

    def _open_files(self) -> None:
        """Writes the scene and partial manifest, opens writers and worker."""
        self._check_free()
        if self._overwrite and self.path.exists():
            shutil.rmtree(self.path)
        self.path.mkdir(parents=True, exist_ok=True)
        ref = scene_io.put_scene(cas.ContentStore(self._root), self._scene)
        self._manifest = manifest.RolloutManifest(
            id=self._id,
            name=self.name,
            created=self._created,
            dt=self.dt,
            n_frames=0,
            n_envs=self.n_envs,
            n_bodies=self._scene.n_bodies,
            scene=ref,
            streams={s.name: s.info for s in self._specs},
            status="recording",
            env_origins=self._origins,
            source=self._source,
            tags=self._tags,
            meta=self._meta,
        )
        self._writers = [
            _StreamWriter(
                self.path / s.info.file,
                item_shape=s.item_shape,
                n_envs=self.n_envs,
                kind=s.kind,
                codec=s.codec,
                block_frames=self.block_frames,
            )
            for s in self._specs
        ]
        manifest.write_manifest(self.path, self._manifest, partial=True)
        self._n_extra = len(self._specs) - 1
        self._pose_shape = self._specs[0].frame_shape
        n_windows = self._max_pending + 1
        windows = [
            _Window(self._specs, self.block_frames, self.n_envs)
            for _ in range(n_windows)
        ]
        self._win = windows[0]
        self._free_q: queue.Queue[_Window] = queue.Queue()
        for w in windows[1:]:
            self._free_q.put(w)
        self._work_q: queue.Queue[_Window | None] = queue.Queue()
        self._fill = 0
        self._next_t0 = 0
        self._prev_quat: npt.NDArray[np.float32] | None = None
        if self._encode_threads > 1 and self.n_envs > 1:
            self._pool = concurrent.futures.ThreadPoolExecutor(
                self._encode_threads, thread_name_prefix="simscope-encode"
            )
        self._thread = threading.Thread(
            target=self._worker, name="simscope-recorder", daemon=True
        )
        self._thread.start()
        self._started = True

    # -- logging --

    @property
    def n_frames(self) -> int:
        """Frames logged so far."""
        return self._next_t0 + self._fill if self._started else 0

    @property
    def stats(self) -> dict[str, float]:
        """Backpressure counters.

        ``stalls`` counts the times ``log`` had to wait for the encoder,
        ``stall_seconds`` the total wait, and ``close_seconds`` how long the
        last ``close`` took.
        """
        return dict(self._stats)

    def log(self, poses: npt.ArrayLike, **streams: npt.ArrayLike) -> None:
        """Records one frame.

        Args:
            poses: Body poses ``[E, B, 7]`` (or ``[B, 7]`` when there is one
                env), xyzw quaternions.
            **streams: One value per declared stream, each ``[E, *item]``.
                Scalar streams also accept a Python float (used for every
                env) and, when there is one env, no leading axis. A name
                that was never declared is declared as a scalar on the
                first frame if its value is a float or has shape ``[E]``.

        Raises:
            RecorderError: If the recorder is closed.
            ValueError: If a shape is wrong or a stream is missing or
                undeclared. The frame is not recorded.
            Exception: A failure of the background writer, re-raised.
        """
        if self._error is not None or self._closed:
            self._raise_pending()
        if not self._started:
            self._start(
                np.shape(poses), {k: np.shape(v) for k, v in streams.items()}
            )
        elif len(streams) != self._n_extra:
            self._check_names(streams)
        i = self._fill
        frames = self._win.frames
        by_name = self._by_name
        shape = getattr(poses, "shape", None)
        if shape == self._pose_shape:
            np.copyto(frames[0][i], poses)
        else:
            frames[0][i] = self._coerce(self._specs[0], poses)
        for name, value in streams.items():
            spec = by_name.get(name)
            if spec is None:
                self._check_names(streams)
                raise AssertionError("unreachable")  # pragma: no cover
            if getattr(value, "shape", None) == spec.frame_shape:
                np.copyto(frames[spec.index][i], value)
            else:
                frames[spec.index][i] = self._coerce(spec, value)
        self._fill = i + 1
        if self._fill == self.block_frames:
            self._submit()

    def _check_names(self, streams: Mapping[str, Any]) -> None:
        """Raises a clear error for missing or undeclared streams."""
        extra = sorted(set(streams) - self._by_name.keys())
        missing = sorted(self._by_name.keys() - set(streams))
        if extra:
            raise ValueError(
                f"undeclared streams {extra}: call add_stream() before the "
                "first log"
            )
        if missing:
            raise ValueError(f"missing streams {missing} in this frame")

    def _coerce(self, spec: _Stream, value: Any) -> npt.NDArray[np.float32]:
        """Converts one frame value to an array that fits ``spec``.

        Returns:
            An array broadcastable to ``spec.frame_shape``: exactly that
            shape, that shape with the env axis added, or (scalars only) a
            0-d array.
        """
        a = np.asarray(value, dtype=np.float32)
        if a.shape == spec.frame_shape:
            return a
        if self.n_envs == 1 and a.shape == spec.frame_shape[1:]:
            return a[None]
        if spec.kind == "scalar" and a.ndim == 0:
            return a
        raise _frame_error(spec.name, spec.frame_shape, a.shape)

    def log_frames(
        self, poses: npt.ArrayLike, **streams: npt.ArrayLike
    ) -> None:
        """Records ``n`` frames at once, without a Python loop over frames.

        Args:
            poses: Body poses ``[n, E, B, 7]`` (or ``[n, B, 7]`` when there
                is one env), for example ``PoseBuffer.drain()`` output.
            **streams: One array per declared stream, each
                ``[n, E, *item]`` (scalars may be ``[n]`` when there is one
                env).

        Raises:
            RecorderError: If the recorder is closed.
            ValueError: If a shape is wrong or a stream is missing or
                undeclared. Nothing is recorded.
            Exception: A failure of the background writer, re-raised.
        """
        if self._error is not None or self._closed:
            self._raise_pending()
        arrays = {
            k: np.asarray(v, dtype=np.float32) for k, v in streams.items()
        }
        pose_arr = np.asarray(poses, dtype=np.float32)
        if pose_arr.ndim < 1:
            raise ValueError("poses must have a leading frame axis")
        n = pose_arr.shape[0]
        if not self._started:
            self._start(
                pose_arr.shape[1:], {k: a.shape[1:] for k, a in arrays.items()}
            )
        elif len(arrays) != self._n_extra:
            self._check_names(arrays)
        batch = [self._coerce_batch(self._specs[0], pose_arr, n)]
        for name, a in arrays.items():
            if name not in self._by_name:
                self._check_names(arrays)
            spec = self._by_name[name]
            batch.append((spec, self._coerce_batch(spec, a, n)[1]))
        pos = 0
        while pos < n:
            take = min(n - pos, self.block_frames - self._fill)
            lo, hi = self._fill, self._fill + take
            frames = self._win.frames
            for spec, a in batch:
                np.copyto(frames[spec.index][lo:hi], a[pos : pos + take])
            self._fill = hi
            pos += take
            if self._fill == self.block_frames:
                self._submit()

    def _coerce_batch(
        self, spec: _Stream, a: npt.NDArray[np.float32], n: int
    ) -> tuple[_Stream, npt.NDArray[np.float32]]:
        """Checks a ``[n, E, *item]`` array and adds the env axis if needed."""
        want = (n, *spec.frame_shape)
        if a.shape == want:
            return spec, a
        if self.n_envs == 1 and a.shape == (n, *spec.frame_shape[1:]):
            return spec, a[:, None]
        raise _frame_error(spec.name, want, a.shape)

    # -- window hand-off --

    def _submit(self) -> None:
        """Queues the full current window and switches to a free one."""
        win = self._win
        win.n = self._fill
        win.t0 = self._next_t0
        self._next_t0 += self._fill
        self._work_q.put(win)
        try:
            self._win = self._free_q.get_nowait()
        except queue.Empty:
            self._win = self._wait_for_window()
        self._fill = 0

    def _wait_for_window(self) -> _Window:
        """Blocks until the encoder frees a window (backpressure)."""
        start = time.perf_counter()
        while True:
            try:
                win = self._free_q.get(timeout=_POLL_S)
                break
            except queue.Empty:
                if not self._thread.is_alive():
                    raise RecorderError("recorder thread died") from None
        self._stats["stalls"] += 1
        self._stats["stall_seconds"] += time.perf_counter() - start
        return win

    def _worker(self) -> None:
        """Encodes and writes queued windows until it gets ``None``."""
        while True:
            win = self._work_q.get()
            try:
                if win is None:
                    return
                if self._error is None:
                    self._write_window(win)
            except BaseException as exc:
                logger.error("recording worker failed: %r", exc)
                self._error = exc
            finally:
                if win is not None:
                    self._free_q.put(win)
                self._work_q.task_done()

    def _write_window(self, win: _Window) -> None:
        """Encodes a window for every stream and appends it."""
        n = win.n
        for spec, arr, writer in zip(
            self._specs, win.arrays, self._writers, strict=True
        ):
            data = arr[:n]
            if spec.kind == "pose":
                self._fix_signs(data)
            blocks = self._encode(data, spec.codec)
            writer.write_window(win.t0, blocks, n)
        # Keep the partial manifest's frame count current, so the index (and
        # any `simscope serve` watching the folder) sees live progress.
        self._manifest.n_frames = win.t0 + n
        manifest.write_manifest(self.path, self._manifest, partial=True)

    def _encode(
        self, data: npt.NDArray[np.float32], codec: str
    ) -> Sequence[blockfile.EncodedBlock]:
        """Encodes a window, one block per env, using the thread pool."""
        if self._pool is None:
            return blockfile.encode_window(data, codec)
        n, n_envs, k = data.shape

        def encode_env(env: int) -> blockfile.EncodedBlock:
            cid, payload = codecs.encode_block_auto(data[:, env, :], codec)
            return blockfile.EncodedBlock(
                env, n, cid, codecs.payload_ulen(cid, n, k), payload
            )

        return list(self._pool.map(encode_env, range(n_envs)))

    def _fix_signs(self, data: npt.NDArray[np.float32]) -> None:
        """Applies quaternion sign continuity to a pose window in place."""
        n = data.shape[0]
        n_bodies = self._scene.n_bodies
        q = data.reshape(n, self.n_envs, n_bodies, core.POSE_DIM)[..., 3:]
        prev = self._prev_quat
        if prev is None:
            prev_full = None
            self._prev_quat = prev = np.empty(q.shape[1:], np.float32)
        else:
            prev_full = prev
        step = max(1, _SIGN_CHUNK // (n * n_bodies * 4))
        for lo in range(0, self.n_envs, step):
            sl = slice(lo, lo + step)
            fixed = transforms.enforce_sign_continuity(
                q[:, sl], None if prev_full is None else prev_full[sl]
            )
            q[:, sl] = fixed
            prev[sl] = fixed[-1]

    # -- lifecycle --

    def _raise_pending(self) -> None:
        """Raises the worker's error, or a closed-recorder error."""
        if self._error is not None:
            raise self._error
        raise RecorderError(f"recorder for {self.name!r} is closed")

    def flush(self) -> None:
        """Waits until every full window is on disk.

        Raises:
            Exception: A failure of the background writer, re-raised.
        """
        if not self._started or self._closed:
            return
        self._work_q.join()
        if self._error is not None:
            raise self._error

    def _drain(self) -> None:
        """Queues the partial window, then stops and joins the worker."""
        if self._fill:
            win = self._win
            win.n = self._fill
            win.t0 = self._next_t0
            self._next_t0 += self._fill
            self._fill = 0
            self._work_q.put(win)
        self._work_q.put(None)
        self._thread.join()
        if self._pool is not None:
            self._pool.shutdown()

    def close(self) -> None:
        """Finishes the run: flushes, writes directories and the manifest.

        Does nothing if no frame was logged (no run is created). Safe to
        call twice.

        Raises:
            Exception: A failure of the background writer. The files stay
                recoverable (``rollout.json.partial`` remains).
        """
        if self._closed:
            return
        self._closed = True
        if not self._started:
            return
        start = time.perf_counter()
        try:
            self._drain()
            if self._error is not None:
                raise self._error
            for writer in self._writers:
                writer.finalize()
            self._manifest.n_frames = self._next_t0
            self._manifest.status = "complete"
            manifest.write_manifest(self.path, self._manifest, partial=False)
        finally:
            for writer in self._writers:
                writer.close()
            self._stats["close_seconds"] = time.perf_counter() - start

    def abort(self) -> None:
        """Stops without finishing the run, keeping it recoverable.

        Flushes what it can and leaves ``rollout.json.partial`` in place.
        Never raises; use ``Library.recover`` to complete the run.
        """
        if self._closed:
            return
        self._closed = True
        if not self._started:
            return
        try:
            self._drain()
        finally:
            for writer in self._writers:
                writer.close()
        if self._error is not None:
            logger.error(
                "run %r aborted after a writer error: %r",
                self.name,
                self._error,
            )

    def __enter__(self) -> "Recorder":
        """Returns the recorder."""
        return self

    def __exit__(
        self,
        exc_type: type[BaseException] | None,
        exc: BaseException | None,
        tb: TracebackType | None,
    ) -> None:
        """Closes on success; aborts (keeping the run recoverable) on error."""
        if exc_type is None:
            self.close()
        else:
            self.abort()

n_frames: int property

Frames logged so far.

stats: dict[str, float] property

Backpressure counters.

stalls counts the times log had to wait for the encoder, stall_seconds the total wait, and close_seconds how long the last close took.

__enter__() -> Recorder

Returns the recorder.

Source code in src/simscope/recorder.py
770
771
772
def __enter__(self) -> "Recorder":
    """Returns the recorder."""
    return self

__exit__(exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None) -> None

Closes on success; aborts (keeping the run recoverable) on error.

Source code in src/simscope/recorder.py
774
775
776
777
778
779
780
781
782
783
784
def __exit__(
    self,
    exc_type: type[BaseException] | None,
    exc: BaseException | None,
    tb: TracebackType | None,
) -> None:
    """Closes on success; aborts (keeping the run recoverable) on error."""
    if exc_type is None:
        self.close()
    else:
        self.abort()

__init__(root: pathlib.Path, name: str, *, scene: core.Scene, dt: float, n_envs: int = 1, env_origins: npt.ArrayLike | None = None, source: Mapping[str, Any] | None = None, tags: Sequence[str] = (), meta: Mapping[str, Any] | None = None, codec: str = 'f32s', block_frames: int = blockfile.DEFAULT_BLOCK_FRAMES, overwrite: bool = False, max_pending: int = 2, encode_threads: int | None = None, created: str | None = None) -> None

Validates arguments. Nothing is written until the first log.

Parameters:

Name Type Description Default
root Path

The library root.

required
name str

Run name, [A-Za-z0-9][A-Za-z0-9._-]{0,127}.

required
scene Scene

The static scene. Its body count fixes the pose shape.

required
dt float

Seconds between frames (positive).

required
n_envs int

Number of parallel envs.

1
env_origins ArrayLike | None

World offset of each env, shape [n_envs, 3], or None for zeros.

None
source Mapping[str, Any] | None

Provenance, such as {"simulator": "mujoco", ...}.

None
tags Sequence[str]

Record-time tags.

()
meta Mapping[str, Any] | None

Free-form JSON-serializable metadata.

None
codec str

Codec of the pose stream: "f32s" (lossless) or "q16d". Other streams use their own codec (default "f32s").

'f32s'
block_frames int

Frames per block.

DEFAULT_BLOCK_FRAMES
overwrite bool

Replace an existing run of this name. The whole run directory (including its annotations) is deleted when recording starts.

False
created str | None

When the run was recorded, YYYY-MM-DDTHH:MM:SSZ (UTC). Importers pass the time of the original recording; None means now.

None
max_pending int

Full windows that may wait for the encoder before log blocks.

2
encode_threads int | None

Threads the background writer fans a window's per-env blocks out to (deflate releases the GIL, so this scales). None picks 1 for fewer than 4 envs, else up to 4.

None

Raises:

Type Description
ValueError

If an argument is invalid.

FileExistsError

If the run exists and overwrite is false.

Source code in src/simscope/recorder.py
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
def __init__(
    self,
    root: pathlib.Path,
    name: str,
    *,
    scene: core.Scene,
    dt: float,
    n_envs: int = 1,
    env_origins: npt.ArrayLike | None = None,
    source: Mapping[str, Any] | None = None,
    tags: Sequence[str] = (),
    meta: Mapping[str, Any] | None = None,
    codec: str = "f32s",
    block_frames: int = blockfile.DEFAULT_BLOCK_FRAMES,
    overwrite: bool = False,
    max_pending: int = 2,
    encode_threads: int | None = None,
    created: str | None = None,
) -> None:
    """Validates arguments. Nothing is written until the first ``log``.

    Args:
        root: The library root.
        name: Run name, ``[A-Za-z0-9][A-Za-z0-9._-]{0,127}``.
        scene: The static scene. Its body count fixes the pose shape.
        dt: Seconds between frames (positive).
        n_envs: Number of parallel envs.
        env_origins: World offset of each env, shape ``[n_envs, 3]``, or
            ``None`` for zeros.
        source: Provenance, such as ``{"simulator": "mujoco", ...}``.
        tags: Record-time tags.
        meta: Free-form JSON-serializable metadata.
        codec: Codec of the pose stream: ``"f32s"`` (lossless) or
            ``"q16d"``. Other streams use their own codec (default
            ``"f32s"``).
        block_frames: Frames per block.
        overwrite: Replace an existing run of this name. The whole run
            directory (including its annotations) is deleted when
            recording starts.
        created: When the run was recorded, ``YYYY-MM-DDTHH:MM:SSZ``
            (UTC). Importers pass the time of the original recording;
            ``None`` means now.
        max_pending: Full windows that may wait for the encoder before
            ``log`` blocks.
        encode_threads: Threads the background writer fans a window's
            per-env blocks out to (deflate releases the GIL, so this
            scales). ``None`` picks 1 for fewer than 4 envs, else up
            to 4.

    Raises:
        ValueError: If an argument is invalid.
        FileExistsError: If the run exists and ``overwrite`` is false.
    """
    self.name = manifest.validate_run_name(name)
    self._root = pathlib.Path(root)
    self.path = self._root / "runs" / name
    if not dt > 0 or not math.isfinite(dt):
        raise ValueError(f"dt must be positive and finite, got {dt!r}")
    if n_envs < 1 or block_frames < 1 or max_pending < 1:
        raise ValueError("n_envs, block_frames, max_pending must be >= 1")
    codecs.codec_id(codec)
    self.dt = float(dt)
    self.n_envs = int(n_envs)
    self.block_frames = int(block_frames)
    self._scene = scene
    self._codec = codec
    self._overwrite = overwrite
    self._max_pending = int(max_pending)
    if encode_threads is None:
        encode_threads = 1 if n_envs < 4 else min(4, os.cpu_count() or 1)
    if encode_threads < 1:
        raise ValueError("encode_threads must be at least 1")
    self._encode_threads = int(encode_threads)
    self._pool: concurrent.futures.ThreadPoolExecutor | None = None
    self._source = dict(source or {})
    self._tags = tuple(str(t) for t in tags)
    self._meta = dict(meta or {})
    json.dumps([self._source, self._meta], allow_nan=False)  # early check
    self._origins = self._parse_origins(env_origins)
    self._check_free()
    self._id = manifest.new_ulid()
    self._created = created or manifest.utc_now()
    self._specs: list[_Stream] = []
    self._by_name: dict[str, _Stream] = {}
    self._started = False
    self._closed = False
    self._error: BaseException | None = None
    self._stats = {"stalls": 0, "stall_seconds": 0.0, "close_seconds": 0.0}

abort() -> None

Stops without finishing the run, keeping it recoverable.

Flushes what it can and leaves rollout.json.partial in place. Never raises; use Library.recover to complete the run.

Source code in src/simscope/recorder.py
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
def abort(self) -> None:
    """Stops without finishing the run, keeping it recoverable.

    Flushes what it can and leaves ``rollout.json.partial`` in place.
    Never raises; use ``Library.recover`` to complete the run.
    """
    if self._closed:
        return
    self._closed = True
    if not self._started:
        return
    try:
        self._drain()
    finally:
        for writer in self._writers:
            writer.close()
    if self._error is not None:
        logger.error(
            "run %r aborted after a writer error: %r",
            self.name,
            self._error,
        )

add_stream(name: str, kind: core.StreamKind, item_shape: Sequence[int] = (), *, labels: Sequence[str] | None = None, units: str | None = None, codec: str = 'f32s', scale: float | None = None) -> None

Declares an extra stream. Call before the first log.

Parameters:

Name Type Description Default
name str

Stream name; also the keyword given to log.

required
kind StreamKind

How viewers draw it ("scalar", "vector", "arrows", "points" or "polyline").

required
item_shape Sequence[int]

Shape of one env-frame item (() for a scalar, (K,) for a vector, (K, 6) for arrows, ...).

()
labels Sequence[str] | None

Optional component names.

None
units str | None

Optional unit string.

None
codec str

"f32s" (lossless, default) or "q16d".

'f32s'
scale float | None

For arrows streams, metres drawn per unit of vector. None means viewers use 1.

None

Raises:

Type Description
RecorderError

If recording already started.

ValueError

If the name is taken or invalid, or the kind and shape do not fit.

Source code in src/simscope/recorder.py
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
def add_stream(
    self,
    name: str,
    kind: core.StreamKind,
    item_shape: Sequence[int] = (),
    *,
    labels: Sequence[str] | None = None,
    units: str | None = None,
    codec: str = "f32s",
    scale: float | None = None,
) -> None:
    """Declares an extra stream. Call before the first ``log``.

    Args:
        name: Stream name; also the keyword given to ``log``.
        kind: How viewers draw it (``"scalar"``, ``"vector"``,
            ``"arrows"``, ``"points"`` or ``"polyline"``).
        item_shape: Shape of one env-frame item (``()`` for a scalar,
            ``(K,)`` for a vector, ``(K, 6)`` for arrows, ...).
        labels: Optional component names.
        units: Optional unit string.
        codec: ``"f32s"`` (lossless, default) or ``"q16d"``.
        scale: For ``arrows`` streams, metres drawn per unit of vector.
            ``None`` means viewers use 1.

    Raises:
        RecorderError: If recording already started.
        ValueError: If the name is taken or invalid, or the kind and
            shape do not fit.
    """
    if self._started or self._closed:
        raise RecorderError("streams must be declared before the first log")
    try:
        manifest.validate_run_name(name)
    except ValueError as exc:
        raise ValueError(f"invalid stream name {name!r}") from exc
    if name == manifest.BODY_POSE or name in self._by_name:
        raise ValueError(f"stream {name!r} is already declared")
    if kind not in core.STREAM_KINDS or kind == "pose":
        raise ValueError(f"extra stream kind must be non-pose: {kind!r}")
    codecs.codec_id(codec)
    shape = tuple(int(d) for d in item_shape)
    if kind == "scalar" and shape != ():
        raise ValueError("scalar streams have item_shape ()")
    if kind == "vector" and len(shape) != 1:
        raise ValueError("vector streams need item_shape (K,)")
    if kind == "arrows" and (len(shape) != 2 or shape[1] != 6):
        raise ValueError("arrows streams need item_shape (K, 6)")
    if kind in ("points", "polyline") and (
        len(shape) != 2 or shape[1] != 3
    ):
        raise ValueError(f"{kind} streams need item_shape (K, 3)")
    if 0 in shape or len(shape) > 4:
        raise ValueError(f"invalid item_shape {shape}")
    if labels is not None and len(labels) != (shape[-1] if shape else 1):
        raise ValueError("labels must have one entry per component")
    spec = _Stream(
        len(self._specs) + 1,
        name,
        kind,
        shape,
        self.n_envs,
        codec,
        None if labels is None else tuple(labels),
        units,
    )
    if scale is not None:
        if kind != "arrows":
            raise ValueError("scale applies to arrows streams only")
        spec.info.scale = float(scale)
    self._register(spec)

close() -> None

Finishes the run: flushes, writes directories and the manifest.

Does nothing if no frame was logged (no run is created). Safe to call twice.

Raises:

Type Description
Exception

A failure of the background writer. The files stay recoverable (rollout.json.partial remains).

Source code in src/simscope/recorder.py
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
def close(self) -> None:
    """Finishes the run: flushes, writes directories and the manifest.

    Does nothing if no frame was logged (no run is created). Safe to
    call twice.

    Raises:
        Exception: A failure of the background writer. The files stay
            recoverable (``rollout.json.partial`` remains).
    """
    if self._closed:
        return
    self._closed = True
    if not self._started:
        return
    start = time.perf_counter()
    try:
        self._drain()
        if self._error is not None:
            raise self._error
        for writer in self._writers:
            writer.finalize()
        self._manifest.n_frames = self._next_t0
        self._manifest.status = "complete"
        manifest.write_manifest(self.path, self._manifest, partial=False)
    finally:
        for writer in self._writers:
            writer.close()
        self._stats["close_seconds"] = time.perf_counter() - start

flush() -> None

Waits until every full window is on disk.

Raises:

Type Description
Exception

A failure of the background writer, re-raised.

Source code in src/simscope/recorder.py
691
692
693
694
695
696
697
698
699
700
701
def flush(self) -> None:
    """Waits until every full window is on disk.

    Raises:
        Exception: A failure of the background writer, re-raised.
    """
    if not self._started or self._closed:
        return
    self._work_q.join()
    if self._error is not None:
        raise self._error

log(poses: npt.ArrayLike, **streams: npt.ArrayLike) -> None

Records one frame.

Parameters:

Name Type Description Default
poses ArrayLike

Body poses [E, B, 7] (or [B, 7] when there is one env), xyzw quaternions.

required
**streams ArrayLike

One value per declared stream, each [E, *item]. Scalar streams also accept a Python float (used for every env) and, when there is one env, no leading axis. A name that was never declared is declared as a scalar on the first frame if its value is a float or has shape [E].

{}

Raises:

Type Description
RecorderError

If the recorder is closed.

ValueError

If a shape is wrong or a stream is missing or undeclared. The frame is not recorded.

Exception

A failure of the background writer, re-raised.

Source code in src/simscope/recorder.py
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
def log(self, poses: npt.ArrayLike, **streams: npt.ArrayLike) -> None:
    """Records one frame.

    Args:
        poses: Body poses ``[E, B, 7]`` (or ``[B, 7]`` when there is one
            env), xyzw quaternions.
        **streams: One value per declared stream, each ``[E, *item]``.
            Scalar streams also accept a Python float (used for every
            env) and, when there is one env, no leading axis. A name
            that was never declared is declared as a scalar on the
            first frame if its value is a float or has shape ``[E]``.

    Raises:
        RecorderError: If the recorder is closed.
        ValueError: If a shape is wrong or a stream is missing or
            undeclared. The frame is not recorded.
        Exception: A failure of the background writer, re-raised.
    """
    if self._error is not None or self._closed:
        self._raise_pending()
    if not self._started:
        self._start(
            np.shape(poses), {k: np.shape(v) for k, v in streams.items()}
        )
    elif len(streams) != self._n_extra:
        self._check_names(streams)
    i = self._fill
    frames = self._win.frames
    by_name = self._by_name
    shape = getattr(poses, "shape", None)
    if shape == self._pose_shape:
        np.copyto(frames[0][i], poses)
    else:
        frames[0][i] = self._coerce(self._specs[0], poses)
    for name, value in streams.items():
        spec = by_name.get(name)
        if spec is None:
            self._check_names(streams)
            raise AssertionError("unreachable")  # pragma: no cover
        if getattr(value, "shape", None) == spec.frame_shape:
            np.copyto(frames[spec.index][i], value)
        else:
            frames[spec.index][i] = self._coerce(spec, value)
    self._fill = i + 1
    if self._fill == self.block_frames:
        self._submit()

log_frames(poses: npt.ArrayLike, **streams: npt.ArrayLike) -> None

Records n frames at once, without a Python loop over frames.

Parameters:

Name Type Description Default
poses ArrayLike

Body poses [n, E, B, 7] (or [n, B, 7] when there is one env), for example PoseBuffer.drain() output.

required
**streams ArrayLike

One array per declared stream, each [n, E, *item] (scalars may be [n] when there is one env).

{}

Raises:

Type Description
RecorderError

If the recorder is closed.

ValueError

If a shape is wrong or a stream is missing or undeclared. Nothing is recorded.

Exception

A failure of the background writer, re-raised.

Source code in src/simscope/recorder.py
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
def log_frames(
    self, poses: npt.ArrayLike, **streams: npt.ArrayLike
) -> None:
    """Records ``n`` frames at once, without a Python loop over frames.

    Args:
        poses: Body poses ``[n, E, B, 7]`` (or ``[n, B, 7]`` when there
            is one env), for example ``PoseBuffer.drain()`` output.
        **streams: One array per declared stream, each
            ``[n, E, *item]`` (scalars may be ``[n]`` when there is one
            env).

    Raises:
        RecorderError: If the recorder is closed.
        ValueError: If a shape is wrong or a stream is missing or
            undeclared. Nothing is recorded.
        Exception: A failure of the background writer, re-raised.
    """
    if self._error is not None or self._closed:
        self._raise_pending()
    arrays = {
        k: np.asarray(v, dtype=np.float32) for k, v in streams.items()
    }
    pose_arr = np.asarray(poses, dtype=np.float32)
    if pose_arr.ndim < 1:
        raise ValueError("poses must have a leading frame axis")
    n = pose_arr.shape[0]
    if not self._started:
        self._start(
            pose_arr.shape[1:], {k: a.shape[1:] for k, a in arrays.items()}
        )
    elif len(arrays) != self._n_extra:
        self._check_names(arrays)
    batch = [self._coerce_batch(self._specs[0], pose_arr, n)]
    for name, a in arrays.items():
        if name not in self._by_name:
            self._check_names(arrays)
        spec = self._by_name[name]
        batch.append((spec, self._coerce_batch(spec, a, n)[1]))
    pos = 0
    while pos < n:
        take = min(n - pos, self.block_frames - self._fill)
        lo, hi = self._fill, self._fill + take
        frames = self._win.frames
        for spec, a in batch:
            np.copyto(frames[spec.index][lo:hi], a[pos : pos + take])
        self._fill = hi
        pos += take
        if self._fill == self.block_frames:
            self._submit()

RecorderError

Bases: RuntimeError

Raised for lifecycle errors, such as logging to a closed recorder.

Source code in src/simscope/recorder.py
40
41
class RecorderError(RuntimeError):
    """Raised for lifecycle errors, such as logging to a closed recorder."""

simscope.index

SQLite index cache over rollout.json and annotations.json.

The index lives at <library>/.simscope/index.sqlite. It is a disposable cache (decision D8): the sidecars stay the source of truth, and a missing, corrupt or out-of-date-schema file is dropped and rebuilt on the next scan.

Index.refresh finds every rollout library under the root, a direct runs/ folder or one nested further down, and re-reads only runs whose files changed.

INVALID = 'invalid' module-attribute

Status shown for a run whose rollout.json could not be read.

Signature = tuple[int, int, int, int, int] module-attribute

(m_mtime, m_size, m_partial, a_mtime, a_size) of a run.

Index

The scan cache of one library.

Thread-safe: a lock serializes access to the SQLite connection. If the cache cannot be created on disk (for example a read-only library) it falls back to an in-memory database.

Attributes:

Name Type Description
root

The library root.

path

The index file.

Source code in src/simscope/index.py
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
class Index:
    """The scan cache of one library.

    Thread-safe: a lock serializes access to the SQLite connection. If the
    cache cannot be created on disk (for example a read-only library) it
    falls back to an in-memory database.

    Attributes:
        root: The library root.
        path: The index file.
    """

    def __init__(self, root: os.PathLike[str] | str) -> None:
        """Creates a handle. The database opens lazily.

        Args:
            root: The library root.
        """
        self.root = pathlib.Path(root)
        self.path = self.root / ".simscope" / "index.sqlite"
        self._conn: sqlite3.Connection | None = None
        self._lock = threading.RLock()
        self._looked = False

    # -- connection management --

    def _connect(self) -> sqlite3.Connection:
        """Returns the open connection, creating or rebuilding as needed."""
        if self._conn is not None:
            return self._conn
        try:
            self.path.parent.mkdir(parents=True, exist_ok=True)
            conn = self._open(str(self.path))
        except sqlite3.DatabaseError:
            logger.warning("dropping corrupt index %s", self.path)
            self._drop_files()
            conn = self._open(str(self.path))
        except OSError as exc:
            logger.warning("index unavailable (%s); using memory", exc)
            conn = self._open(":memory:")
        self._conn = conn
        return conn

    def _open(self, target: str) -> sqlite3.Connection:
        """Opens a database and makes sure the schema is current."""
        conn = sqlite3.connect(
            target, check_same_thread=False, isolation_level=None, timeout=30
        )
        try:
            if target != ":memory:":
                conn.execute("PRAGMA journal_mode=WAL")
            conn.execute("PRAGMA synchronous=NORMAL")
            version = conn.execute("PRAGMA user_version").fetchone()[0]
            if version != SCHEMA_VERSION:
                if version != 0:
                    logger.info("index schema %d is stale; rebuilding", version)
                self._reset_schema(conn)
            else:
                conn.execute("SELECT COUNT(*) FROM runs").fetchone()
        except sqlite3.DatabaseError:
            conn.close()
            raise
        return conn

    @staticmethod
    def _reset_schema(conn: sqlite3.Connection) -> None:
        """Drops every table and creates the current schema."""
        conn.execute("BEGIN IMMEDIATE")
        try:
            conn.execute("DROP TABLE IF EXISTS runs")
            conn.execute("DROP TABLE IF EXISTS run_tags")
            for stmt in _SCHEMA.split(";"):
                if stmt.strip():
                    conn.execute(stmt)
            conn.execute(f"PRAGMA user_version={SCHEMA_VERSION}")
            conn.execute("COMMIT")
        except BaseException:
            conn.execute("ROLLBACK")
            raise

    def _absent(self) -> bool:
        """True if nothing was ever indexed (so queries need not create it)."""
        return self._conn is None and not self.path.exists()

    def _drop_files(self) -> None:
        """Deletes the database file and its WAL companions."""
        for suffix in ("", "-wal", "-shm"):
            pathlib.Path(str(self.path) + suffix).unlink(missing_ok=True)

    def close(self) -> None:
        """Closes the connection. The index reopens on the next use."""
        with self._lock:
            if self._conn is not None:
                self._conn.close()
                self._conn = None

    def rebuild(self) -> RefreshStats:
        """Drops every row and rescans all runs."""
        with self._lock:
            self._reset_schema(self._connect())
            return self.refresh()

    # -- refresh --

    def refresh(self) -> RefreshStats:
        """Brings the index up to date with the rollouts under ``root``.

        Finds every library under the root, not only ``root/runs``, then
        stats each run's manifest and annotations file. Only runs whose
        mtime, size or library changed are re-read. The database update
        happens in one transaction.

        Returns:
            Counts of added, updated, removed and unchanged runs.
        """
        self._looked = True
        found = discover.find_runs(self.root)
        if not found and self._conn is None and not self.path.exists():
            return RefreshStats()
        with self._lock:
            return self._refresh(found)

    def _refresh(self, found: list[discover.FoundRun]) -> RefreshStats:
        """Implements :meth:`refresh` with the lock held."""
        conn = self._connect()
        known = {
            row[0]: (tuple(row[1:6]), row[6])
            for row in conn.execute(
                "SELECT name, m_mtime, m_size, m_partial, a_mtime, a_size, "
                "lib_rel FROM runs"
            )
        }
        seen: set[str] = set()
        changed: list[tuple[str, str, Signature, str, str]] = []
        added = unchanged = 0
        for run in found:
            lib_rel = discover.rel_library(self.root, run.library)
            d = str(run.run_dir) + os.sep
            m_path = d + manifest.MANIFEST_NAME
            m_mtime, m_size = _stat(m_path)
            partial = 0
            if m_size < 0:
                m_path = d + manifest.PARTIAL_NAME
                m_mtime, m_size = _stat(m_path)
                if m_size < 0:
                    continue  # not a run (yet)
                partial = 1
            a_path = d + "annotations.json"
            a_mtime, a_size = _stat(a_path)
            sig: Signature = (m_mtime, m_size, partial, a_mtime, a_size)
            seen.add(run.name)
            old = known.get(run.name)
            if old == (sig, lib_rel):
                unchanged += 1
                continue
            added += old is None
            changed.append((run.name, m_path, sig, a_path, lib_rel))
        removed = [n for n in known if n not in seen]
        if not changed and not removed:
            return RefreshStats(unchanged=unchanged)
        rows, tag_rows = [], []
        for name, m_path, sig, a_path, lib_rel in changed:
            row, tags = _row(name, m_path, sig, a_path)
            rows.append((*row, lib_rel))
            tag_rows += [(t, name) for t in tags]
        conn.execute("BEGIN IMMEDIATE")
        try:
            if removed:
                _delete_names(conn, removed)
            if changed:
                _delete_names(conn, [c[0] for c in changed], runs=False)
                conn.executemany(_UPSERT, rows)
                conn.executemany("INSERT INTO run_tags VALUES (?, ?)", tag_rows)
            conn.execute("COMMIT")
        except BaseException:
            conn.execute("ROLLBACK")
            raise
        return RefreshStats(
            added, len(changed) - added, len(removed), unchanged
        )

    # -- queries --

    def query(
        self,
        text: str | None = None,
        tags: Iterable[str] = (),
        favorite: bool | None = None,
        status: str | None = None,
        sort: str = "created",
        descending: bool = True,
        limit: int | None = 50,
        offset: int = 0,
    ) -> list[RunInfo]:
        """Returns matching runs.

        Args:
            text: Whitespace-separated terms; a run must contain every term
                (case-insensitive) in its name, tags, group or note text.
            tags: Record-time tags a run must have (all of them).
            favorite: If set, only runs with this favorite mark.
            status: If set, only runs whose manifest status
                (``"recording"``, ``"complete"``) equals it.
            sort: ``"created"``, ``"name"``, ``"n_frames"`` or ``"rating"``
                (runs without a rating sort last).
            descending: Sort direction.
            limit: Maximum rows, or ``None`` for all.
            offset: Rows to skip.

        Returns:
            The page of runs.

        Raises:
            ValueError: If ``sort`` is unknown.
        """
        if sort not in _SORTS:
            raise ValueError(f"sort must be one of {sorted(_SORTS)}")
        where, params = _filters(text, tags, favorite, status)
        order = _SORTS[sort].format(d="DESC" if descending else "ASC")
        sql = (
            f"SELECT {', '.join(_INFO_COLUMNS)} FROM runs{where} "
            f"ORDER BY {order} LIMIT ? OFFSET ?"
        )
        params += [-1 if limit is None else int(limit), max(0, int(offset))]
        with self._lock:
            if self._absent():
                return []
            rows = self._connect().execute(sql, params).fetchall()
        return [_info(r) for r in rows]

    def count(
        self,
        text: str | None = None,
        tags: Iterable[str] = (),
        favorite: bool | None = None,
        status: str | None = None,
    ) -> int:
        """Returns how many runs match the filters of :meth:`query`."""
        where, params = _filters(text, tags, favorite, status)
        with self._lock:
            if self._absent():
                return 0
            sql = f"SELECT COUNT(*) FROM runs{where}"
            return self._connect().execute(sql, params).fetchone()[0]

    def get(self, name: str) -> RunInfo | None:
        """Returns one run's summary, or ``None`` if it is not indexed."""
        with self._lock:
            if self._absent():
                return None
            row = (
                self._connect()
                .execute(
                    f"SELECT {', '.join(_INFO_COLUMNS)} FROM runs "
                    "WHERE name = ?",
                    (name,),
                )
                .fetchone()
            )
        return None if row is None else _info(row)

    def owner(self, name: str, *, scan: bool = False) -> pathlib.Path | None:
        """Returns the library root that holds ``name``, or ``None``.

        Args:
            name: Run name.
            scan: When the run is missing from the index, scan once. Further
                calls do not scan again until :meth:`refresh`.

        Returns:
            The library folder recorded for that run. ``None`` when the run
            is not indexed. This does not create an index just to answer,
            unless ``scan`` finds runs and therefore writes the cache.
        """
        rel = self._lib_rel(name)
        if rel is None and scan and not self._looked:
            self.refresh()
            rel = self._lib_rel(name)
        if rel is None:
            return None
        return self.root if rel == "" else self.root.joinpath(rel)

    def owners(self) -> list[pathlib.Path]:
        """Returns nested library folders that hold indexed runs.

        The index root itself is omitted: its runs live in its ``runs/``.

        Returns:
            Those folders, in path order.
        """
        with self._lock:
            if self._absent():
                return []
            rows = (
                self._connect()
                .execute(
                    "SELECT DISTINCT lib_rel FROM runs WHERE lib_rel != ''"
                )
                .fetchall()
            )
        return [self.root.joinpath(rel) for rel in sorted(r[0] for r in rows)]

    def _lib_rel(self, name: str) -> str | None:
        """Returns the stored library path of ``name``, or ``None``."""
        with self._lock:
            if self._absent():
                return None
            row = (
                self._connect()
                .execute("SELECT lib_rel FROM runs WHERE name = ?", (name,))
                .fetchone()
            )
        return None if row is None else row[0]

__init__(root: os.PathLike[str] | str) -> None

Creates a handle. The database opens lazily.

Parameters:

Name Type Description Default
root PathLike[str] | str

The library root.

required
Source code in src/simscope/index.py
271
272
273
274
275
276
277
278
279
280
281
def __init__(self, root: os.PathLike[str] | str) -> None:
    """Creates a handle. The database opens lazily.

    Args:
        root: The library root.
    """
    self.root = pathlib.Path(root)
    self.path = self.root / ".simscope" / "index.sqlite"
    self._conn: sqlite3.Connection | None = None
    self._lock = threading.RLock()
    self._looked = False

close() -> None

Closes the connection. The index reopens on the next use.

Source code in src/simscope/index.py
348
349
350
351
352
353
def close(self) -> None:
    """Closes the connection. The index reopens on the next use."""
    with self._lock:
        if self._conn is not None:
            self._conn.close()
            self._conn = None

count(text: str | None = None, tags: Iterable[str] = (), favorite: bool | None = None, status: str | None = None) -> int

Returns how many runs match the filters of :meth:query.

Source code in src/simscope/index.py
489
490
491
492
493
494
495
496
497
498
499
500
501
502
def count(
    self,
    text: str | None = None,
    tags: Iterable[str] = (),
    favorite: bool | None = None,
    status: str | None = None,
) -> int:
    """Returns how many runs match the filters of :meth:`query`."""
    where, params = _filters(text, tags, favorite, status)
    with self._lock:
        if self._absent():
            return 0
        sql = f"SELECT COUNT(*) FROM runs{where}"
        return self._connect().execute(sql, params).fetchone()[0]

get(name: str) -> RunInfo | None

Returns one run's summary, or None if it is not indexed.

Source code in src/simscope/index.py
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
def get(self, name: str) -> RunInfo | None:
    """Returns one run's summary, or ``None`` if it is not indexed."""
    with self._lock:
        if self._absent():
            return None
        row = (
            self._connect()
            .execute(
                f"SELECT {', '.join(_INFO_COLUMNS)} FROM runs "
                "WHERE name = ?",
                (name,),
            )
            .fetchone()
        )
    return None if row is None else _info(row)

owner(name: str, *, scan: bool = False) -> pathlib.Path | None

Returns the library root that holds name, or None.

Parameters:

Name Type Description Default
name str

Run name.

required
scan bool

When the run is missing from the index, scan once. Further calls do not scan again until :meth:refresh.

False

Returns:

Type Description
Path | None

The library folder recorded for that run. None when the run

Path | None

is not indexed. This does not create an index just to answer,

Path | None

unless scan finds runs and therefore writes the cache.

Source code in src/simscope/index.py
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
def owner(self, name: str, *, scan: bool = False) -> pathlib.Path | None:
    """Returns the library root that holds ``name``, or ``None``.

    Args:
        name: Run name.
        scan: When the run is missing from the index, scan once. Further
            calls do not scan again until :meth:`refresh`.

    Returns:
        The library folder recorded for that run. ``None`` when the run
        is not indexed. This does not create an index just to answer,
        unless ``scan`` finds runs and therefore writes the cache.
    """
    rel = self._lib_rel(name)
    if rel is None and scan and not self._looked:
        self.refresh()
        rel = self._lib_rel(name)
    if rel is None:
        return None
    return self.root if rel == "" else self.root.joinpath(rel)

owners() -> list[pathlib.Path]

Returns nested library folders that hold indexed runs.

The index root itself is omitted: its runs live in its runs/.

Returns:

Type Description
list[Path]

Those folders, in path order.

Source code in src/simscope/index.py
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
def owners(self) -> list[pathlib.Path]:
    """Returns nested library folders that hold indexed runs.

    The index root itself is omitted: its runs live in its ``runs/``.

    Returns:
        Those folders, in path order.
    """
    with self._lock:
        if self._absent():
            return []
        rows = (
            self._connect()
            .execute(
                "SELECT DISTINCT lib_rel FROM runs WHERE lib_rel != ''"
            )
            .fetchall()
        )
    return [self.root.joinpath(rel) for rel in sorted(r[0] for r in rows)]

query(text: str | None = None, tags: Iterable[str] = (), favorite: bool | None = None, status: str | None = None, sort: str = 'created', descending: bool = True, limit: int | None = 50, offset: int = 0) -> list[RunInfo]

Returns matching runs.

Parameters:

Name Type Description Default
text str | None

Whitespace-separated terms; a run must contain every term (case-insensitive) in its name, tags, group or note text.

None
tags Iterable[str]

Record-time tags a run must have (all of them).

()
favorite bool | None

If set, only runs with this favorite mark.

None
status str | None

If set, only runs whose manifest status ("recording", "complete") equals it.

None
sort str

"created", "name", "n_frames" or "rating" (runs without a rating sort last).

'created'
descending bool

Sort direction.

True
limit int | None

Maximum rows, or None for all.

50
offset int

Rows to skip.

0

Returns:

Type Description
list[RunInfo]

The page of runs.

Raises:

Type Description
ValueError

If sort is unknown.

Source code in src/simscope/index.py
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
def query(
    self,
    text: str | None = None,
    tags: Iterable[str] = (),
    favorite: bool | None = None,
    status: str | None = None,
    sort: str = "created",
    descending: bool = True,
    limit: int | None = 50,
    offset: int = 0,
) -> list[RunInfo]:
    """Returns matching runs.

    Args:
        text: Whitespace-separated terms; a run must contain every term
            (case-insensitive) in its name, tags, group or note text.
        tags: Record-time tags a run must have (all of them).
        favorite: If set, only runs with this favorite mark.
        status: If set, only runs whose manifest status
            (``"recording"``, ``"complete"``) equals it.
        sort: ``"created"``, ``"name"``, ``"n_frames"`` or ``"rating"``
            (runs without a rating sort last).
        descending: Sort direction.
        limit: Maximum rows, or ``None`` for all.
        offset: Rows to skip.

    Returns:
        The page of runs.

    Raises:
        ValueError: If ``sort`` is unknown.
    """
    if sort not in _SORTS:
        raise ValueError(f"sort must be one of {sorted(_SORTS)}")
    where, params = _filters(text, tags, favorite, status)
    order = _SORTS[sort].format(d="DESC" if descending else "ASC")
    sql = (
        f"SELECT {', '.join(_INFO_COLUMNS)} FROM runs{where} "
        f"ORDER BY {order} LIMIT ? OFFSET ?"
    )
    params += [-1 if limit is None else int(limit), max(0, int(offset))]
    with self._lock:
        if self._absent():
            return []
        rows = self._connect().execute(sql, params).fetchall()
    return [_info(r) for r in rows]

rebuild() -> RefreshStats

Drops every row and rescans all runs.

Source code in src/simscope/index.py
355
356
357
358
359
def rebuild(self) -> RefreshStats:
    """Drops every row and rescans all runs."""
    with self._lock:
        self._reset_schema(self._connect())
        return self.refresh()

refresh() -> RefreshStats

Brings the index up to date with the rollouts under root.

Finds every library under the root, not only root/runs, then stats each run's manifest and annotations file. Only runs whose mtime, size or library changed are re-read. The database update happens in one transaction.

Returns:

Type Description
RefreshStats

Counts of added, updated, removed and unchanged runs.

Source code in src/simscope/index.py
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
def refresh(self) -> RefreshStats:
    """Brings the index up to date with the rollouts under ``root``.

    Finds every library under the root, not only ``root/runs``, then
    stats each run's manifest and annotations file. Only runs whose
    mtime, size or library changed are re-read. The database update
    happens in one transaction.

    Returns:
        Counts of added, updated, removed and unchanged runs.
    """
    self._looked = True
    found = discover.find_runs(self.root)
    if not found and self._conn is None and not self.path.exists():
        return RefreshStats()
    with self._lock:
        return self._refresh(found)

RefreshStats dataclass

What a refresh did.

Attributes:

Name Type Description
added int

Runs seen for the first time.

updated int

Runs whose files changed.

removed int

Runs that vanished.

unchanged int

Runs skipped because their stat signature matched.

Source code in src/simscope/index.py
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
@dataclasses.dataclass(frozen=True)
class RefreshStats:
    """What a refresh did.

    Attributes:
        added: Runs seen for the first time.
        updated: Runs whose files changed.
        removed: Runs that vanished.
        unchanged: Runs skipped because their stat signature matched.
    """

    added: int = 0
    updated: int = 0
    removed: int = 0
    unchanged: int = 0

RunInfo dataclass

Summary of one rollout, as browsing needs it.

Attributes:

Name Type Description
name str

Run name (the directory name).

id str

The run's ULID.

created str

UTC creation time.

status str

"recording", "complete" or "invalid" (the manifest could not be read).

dt float

Seconds per frame.

n_frames int

Frames per stream.

n_envs int

Envs per stream.

n_bodies int

Bodies per pose.

scene_hash str

SHA-256 of the scene descriptor.

favorite bool

Whether the run is starred.

group str | None

Name of the group the run belongs to, or None.

n_events int

Number of events.

n_notes int

Number of notes.

rating float | None

Mean overall rating, or None.

tags tuple[str, ...]

Record-time tags, sorted and unique. (Curation tags, a deprecated field of the sidecar, are not indexed.)

Source code in src/simscope/index.py
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
@dataclasses.dataclass(frozen=True)
class RunInfo:
    """Summary of one rollout, as browsing needs it.

    Attributes:
        name: Run name (the directory name).
        id: The run's ULID.
        created: UTC creation time.
        status: ``"recording"``, ``"complete"`` or ``"invalid"`` (the
            manifest could not be read).
        dt: Seconds per frame.
        n_frames: Frames per stream.
        n_envs: Envs per stream.
        n_bodies: Bodies per pose.
        scene_hash: SHA-256 of the scene descriptor.
        favorite: Whether the run is starred.
        group: Name of the group the run belongs to, or ``None``.
        n_events: Number of events.
        n_notes: Number of notes.
        rating: Mean ``overall`` rating, or ``None``.
        tags: Record-time tags, sorted and unique. (Curation tags, a
            deprecated field of the sidecar, are not indexed.)
    """

    name: str
    id: str
    created: str
    status: str
    dt: float
    n_frames: int
    n_envs: int
    n_bodies: int
    scene_hash: str
    favorite: bool
    group: str | None
    n_events: int
    n_notes: int
    rating: float | None
    tags: tuple[str, ...]

Importers

simscope.importers

Importers for rollouts that already exist on disk.

Two sources are supported, both written through Library.record so the content store deduplicates their meshes across runs (decision D15):

  • .rbundle files (artifacts-server / dial-mpc), via :func:import_rbundle;
  • self-contained Brax HTML viewers (var system = "..."), via :func:import_brax_html.

:func:import_path sniffs a file (or walks a directory) and picks the importer. Conventions of each source are documented in the section comments below. Verified against the reference code and the real data:

.rbundle (core/viz3d/bundle.py) [4B "RBDL"][u64 LE header length][JSON header][tail]; the tail is gzip when header.compression == "gzip". buffers[k].off is a byte offset into the uncompressed tail and count an element count. body_pos is [T, B, 3] and body_quat [T, B, 4] in wxyz (MuJoCo xquat), world frame, Z-up, metres. Body 0 is the MuJoCo world body. Geoms carry MuJoCo geom_size conventions (the same as MuJoCo: box half-extents, capsule/cylinder [r, h] with the axis along local z, plane [hx, hy, spacing]) and local_quat in wxyz. Mesh geoms hold the vertices already in the geom's local frame (verts_count and faces_count count elements, three per vertex or face) and their size is the mesh bounding box, which is dropped. forces is [T, K, 2, 3] holding [anchor, vector] in world frame (Newtons); inactive feet are NaN. It is recorded as the contacts stream (viewer contracts 7), scaled so one body weight draws as 1 m. predictions is [T, H, L, 3]. The scene has no body hierarchy, so every body gets parent = -1.

Brax HTML (brax/io/json.py, brax/visualizer/js/system.js) The page holds var system = "<base64 of zlib(JSON)>" with keys opt (opt.timestep is the playback frame interval, so dt = opt.timestep), link_names, name, geoms and states.x. Z-up, metres, same as MuJoCo. states.x[t] has pos [L, 3] and rot [L, 4] (wxyz) for the L links, in the world frame; the world body is not in the state, so our body 0 is a fixed identity "world" and link i is body i + 1. geoms maps a link name to its geoms; link_idx (-1 for the world, which holds the floor and obstacles) gives the link, pos and rot (wxyz) place the geom in the link frame. name is the type (Plane, Sphere, Capsule, Cylinder, Box, Mesh; HeightMap is not drawn by Brax and is skipped) and size is MuJoCo's geom_size: box half-extents, sphere [r], capsule and cylinder [r, half-length] with the axis along local z, plane [hx, hy, spacing] where zero half-extents mean infinite. Meshes carry vert [V, 3] and face [F, 3] in the geom's local frame (size is then the bounding box, which is dropped). Numbers are rounded to six decimals, so quaternions are renormalized on import.

ImportFormatError

Bases: ValueError

Raised when an input file is not a supported rollout.

Source code in src/simscope/importers.py
87
88
class ImportFormatError(ValueError):
    """Raised when an input file is not a supported rollout."""

ImportResult dataclass

The outcome of importing one file.

Attributes:

Name Type Description
path Path

The input file.

name str | None

The new run, or None if the import failed.

n_frames int

Frames in the new run (0 on failure).

error str | None

A one-line message if the import failed, else None.

Source code in src/simscope/importers.py
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
@dataclasses.dataclass(frozen=True)
class ImportResult:
    """The outcome of importing one file.

    Attributes:
        path: The input file.
        name: The new run, or ``None`` if the import failed.
        n_frames: Frames in the new run (0 on failure).
        error: A one-line message if the import failed, else ``None``.
    """

    path: pathlib.Path
    name: str | None
    n_frames: int = 0
    error: str | None = None

default_name(path: os.PathLike[str] | str) -> str

Returns the unsanitized run name for a file.

Parameters:

Name Type Description Default
path PathLike[str] | str

An importable file.

required

Returns:

Type Description
str

The parent folder name for a file called rollout.rbundle, else

str

the file stem.

Source code in src/simscope/importers.py
122
123
124
125
126
127
128
129
130
131
132
133
def default_name(path: os.PathLike[str] | str) -> str:
    """Returns the unsanitized run name for a file.

    Args:
        path: An importable file.

    Returns:
        The parent folder name for a file called ``rollout.rbundle``, else
        the file stem.
    """
    p = pathlib.Path(path).resolve()
    return p.parent.name if p.name == "rollout.rbundle" else p.stem

find_importable(paths: Sequence[os.PathLike[str] | str]) -> list[tuple[pathlib.Path, str]]

Finds importable files in files and directories (recursively).

Parameters:

Name Type Description Default
paths Sequence[PathLike[str] | str]

Files or directories. Files are sniffed by content; in directories only *.rbundle and *.html files are tried.

required

Returns:

Type Description
list[tuple[Path, str]]

(path, format) pairs, sorted by path, where format is

list[tuple[Path, str]]

"rbundle" or "brax".

Raises:

Type Description
FileNotFoundError

If a path does not exist.

Source code in src/simscope/importers.py
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
def find_importable(
    paths: Sequence[os.PathLike[str] | str],
) -> list[tuple[pathlib.Path, str]]:
    """Finds importable files in files and directories (recursively).

    Args:
        paths: Files or directories. Files are sniffed by content; in
            directories only ``*.rbundle`` and ``*.html`` files are tried.

    Returns:
        ``(path, format)`` pairs, sorted by path, where format is
        ``"rbundle"`` or ``"brax"``.

    Raises:
        FileNotFoundError: If a path does not exist.
    """
    found: dict[pathlib.Path, str] = {}
    for p in map(pathlib.Path, paths):
        if p.is_dir():
            candidates = [
                f
                for f in p.rglob("*")
                if f.suffix in (".rbundle", ".html") and f.is_file()
            ]
        elif p.is_file():
            candidates = [p]
        else:
            raise FileNotFoundError(f"no such file or folder: {p}")
        for f in candidates:
            fmt = _sniff(f)
            if fmt is not None:
                found[f.resolve()] = fmt
            elif f == p:
                logger.warning("%s is not a supported rollout file", f)
    return sorted(found.items())

import_brax_html(lib: library.Library, path: os.PathLike[str] | str, *, name: str | None = None, tags: Sequence[str] = (), overwrite: bool = False) -> str

Imports a self-contained Brax HTML viewer into a library.

Parameters:

Name Type Description Default
lib Library

The target library.

required
path PathLike[str] | str

The HTML file containing var system = "...".

required
name str | None

Run name; default the file stem. Sanitized and de-duplicated with a -N suffix unless overwrite.

None
tags Sequence[str]

Extra tags; source:brax is always added.

()
overwrite bool

Replace an existing run of the same name.

False

Returns:

Type Description
str

The name of the new run.

Raises:

Type Description
ImportFormatError

If the page has no valid Brax system.

OSError

If the file cannot be read.

Source code in src/simscope/importers.py
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
def import_brax_html(
    lib: library.Library,
    path: os.PathLike[str] | str,
    *,
    name: str | None = None,
    tags: Sequence[str] = (),
    overwrite: bool = False,
) -> str:
    """Imports a self-contained Brax HTML viewer into a library.

    Args:
        lib: The target library.
        path: The HTML file containing ``var system = "..."``.
        name: Run name; default the file stem. Sanitized and de-duplicated
            with a ``-N`` suffix unless ``overwrite``.
        tags: Extra tags; ``source:brax`` is always added.
        overwrite: Replace an existing run of the same name.

    Returns:
        The name of the new run.

    Raises:
        ImportFormatError: If the page has no valid Brax ``system``.
        OSError: If the file cannot be read.
    """
    path = pathlib.Path(path).resolve()
    system, title = _load_brax_system(path.read_bytes())
    states = system["states"]["x"]
    if not states:
        raise ImportFormatError("the Brax page has no frames")
    poses = _brax_poses(states)
    n_links = poses.shape[1] - 1
    builder = _SceneBuilder()
    for link_geoms in system["geoms"].values():
        for gj in link_geoms:
            geom = _brax_geom(builder, gj, n_links)
            if geom is not None:
                builder.geoms.append(geom)
    names = [str(n) for n in system.get("link_names", [])]
    names += [f"link {i}" for i in range(len(names), n_links)]
    bodies = [core.Body("world"), *(core.Body(n) for n in names[:n_links])]
    meta = {"brax": {"title": title, "name": system.get("name", "")}}
    dt = float(system["opt"]["timestep"])
    run = unique_run_name(lib, name or default_name(path), overwrite)
    _write_run(
        lib,
        run,
        scene=builder.build(bodies),
        dt=dt,
        poses=poses,
        streams={},
        source={
            "simulator": "brax",
            "importer": "brax",
            "original": str(path),
        },
        tags=_all_tags(tags, "source:brax"),
        meta=meta,
        overwrite=overwrite,
        created=recorded_time(path),
    )
    return run

import_files(lib: library.Library, files: Sequence[tuple[pathlib.Path, str]], *, tags: Sequence[str] = (), overwrite: bool = False, jobs: int | None = None) -> Iterator[ImportResult]

Imports many files, in parallel processes, yielding in input order.

Run names are planned up front, so workers never race for a name. Writes into the shared content store are atomic, so concurrent workers are safe. A file that fails is reported in its result; the others go on.

Parameters:

Name Type Description Default
lib Library

The target library.

required
files Sequence[tuple[Path, str]]

(path, format) pairs from :func:find_importable.

required
tags Sequence[str]

Extra tags for every run.

()
overwrite bool

Replace existing runs of the same names.

False
jobs int | None

Worker processes; None picks min(files, cpus, 8), and 1 imports in this process.

None

Yields:

Type Description
ImportResult

One result per file, in the order given.

Source code in src/simscope/importers.py
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
def import_files(
    lib: library.Library,
    files: Sequence[tuple[pathlib.Path, str]],
    *,
    tags: Sequence[str] = (),
    overwrite: bool = False,
    jobs: int | None = None,
) -> Iterator[ImportResult]:
    """Imports many files, in parallel processes, yielding in input order.

    Run names are planned up front, so workers never race for a name.
    Writes into the shared content store are atomic, so concurrent workers
    are safe. A file that fails is reported in its result; the others go on.

    Args:
        lib: The target library.
        files: ``(path, format)`` pairs from :func:`find_importable`.
        tags: Extra tags for every run.
        overwrite: Replace existing runs of the same names.
        jobs: Worker processes; ``None`` picks ``min(files, cpus, 8)``, and
            1 imports in this process.

    Yields:
        One result per file, in the order given.
    """
    taken: set[str] = set()
    tasks = []
    for path, fmt in files:
        name = unique_run_name(lib, default_name(path), overwrite, taken)
        taken.add(name)
        tasks.append((lib.root, path, fmt, name, tuple(tags), overwrite))
    if jobs is None:
        jobs = min(len(tasks), os.cpu_count() or 1, _MAX_JOBS)
    if jobs <= 1 or len(tasks) <= 1:
        for task in tasks:
            yield _import_task(task)
        return
    with concurrent.futures.ProcessPoolExecutor(jobs) as pool:
        yield from pool.map(_import_task, tasks)

import_one(lib: library.Library, path: os.PathLike[str] | str, fmt: str | None = None, **kwargs: Any) -> str

Imports one file with the importer that matches its content.

Parameters:

Name Type Description Default
lib Library

The target library.

required
path PathLike[str] | str

The file.

required
fmt str | None

"rbundle" or "brax", or None to sniff the file.

None
**kwargs Any

name, tags and overwrite, as for :func:import_rbundle.

{}

Returns:

Type Description
str

The run name.

Raises:

Type Description
ImportFormatError

If the file is not a supported rollout.

Source code in src/simscope/importers.py
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
def import_one(
    lib: library.Library,
    path: os.PathLike[str] | str,
    fmt: str | None = None,
    **kwargs: Any,
) -> str:
    """Imports one file with the importer that matches its content.

    Args:
        lib: The target library.
        path: The file.
        fmt: ``"rbundle"`` or ``"brax"``, or ``None`` to sniff the file.
        **kwargs: ``name``, ``tags`` and ``overwrite``, as for
            :func:`import_rbundle`.

    Returns:
        The run name.

    Raises:
        ImportFormatError: If the file is not a supported rollout.
    """
    fmt = fmt or _sniff(pathlib.Path(path))
    if fmt == "rbundle":
        return import_rbundle(lib, path, **kwargs)
    if fmt == "brax":
        return import_brax_html(lib, path, **kwargs)
    raise ImportFormatError(f"{path} is not a supported rollout file")

import_path(lib: library.Library, path: os.PathLike[str] | str, **kwargs: Any) -> list[str]

Imports a file, or every importable file under a directory.

Parameters:

Name Type Description Default
lib Library

The target library.

required
path PathLike[str] | str

A .rbundle or Brax HTML file, or a directory searched recursively.

required
**kwargs Any

tags and overwrite (and name for a single file), as for :func:import_rbundle.

{}

Returns:

Type Description
list[str]

The names of the new runs, in path order.

Raises:

Type Description
FileNotFoundError

If path does not exist.

ImportFormatError

If path is a file of an unknown format.

Source code in src/simscope/importers.py
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
def import_path(
    lib: library.Library, path: os.PathLike[str] | str, **kwargs: Any
) -> list[str]:
    """Imports a file, or every importable file under a directory.

    Args:
        lib: The target library.
        path: A ``.rbundle`` or Brax HTML file, or a directory searched
            recursively.
        **kwargs: ``tags`` and ``overwrite`` (and ``name`` for a single
            file), as for :func:`import_rbundle`.

    Returns:
        The names of the new runs, in path order.

    Raises:
        FileNotFoundError: If ``path`` does not exist.
        ImportFormatError: If ``path`` is a file of an unknown format.
    """
    p = pathlib.Path(path)
    if p.is_file() and _sniff(p) is None:
        raise ImportFormatError(f"{p} is not a supported rollout file")
    return [
        import_one(lib, f, fmt, **kwargs) for f, fmt in find_importable([p])
    ]

import_rbundle(lib: library.Library, path: os.PathLike[str] | str, *, name: str | None = None, tags: Sequence[str] = (), overwrite: bool = False) -> str

Imports an .rbundle into a library.

Parameters:

Name Type Description Default
lib Library

The target library.

required
path PathLike[str] | str

The .rbundle file. Sibling config.json and metrics.json (each under 64 KB) are copied into meta.

required
name str | None

Run name. Default: the parent folder name for a file called rollout.rbundle, else the file stem. It is sanitized and de-duplicated with a -N suffix unless overwrite.

None
tags Sequence[str]

Extra tags; source:rbundle is always added.

()
overwrite bool

Replace an existing run of the same name.

False

Returns:

Type Description
str

The name of the new run.

Raises:

Type Description
ImportFormatError

If the file is not a valid .rbundle.

OSError

If the file cannot be read.

Source code in src/simscope/importers.py
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
def import_rbundle(
    lib: library.Library,
    path: os.PathLike[str] | str,
    *,
    name: str | None = None,
    tags: Sequence[str] = (),
    overwrite: bool = False,
) -> str:
    """Imports an ``.rbundle`` into a library.

    Args:
        lib: The target library.
        path: The ``.rbundle`` file. Sibling ``config.json`` and
            ``metrics.json`` (each under 64 KB) are copied into ``meta``.
        name: Run name. Default: the parent folder name for a file called
            ``rollout.rbundle``, else the file stem. It is sanitized and
            de-duplicated with a ``-N`` suffix unless ``overwrite``.
        tags: Extra tags; ``source:rbundle`` is always added.
        overwrite: Replace an existing run of the same name.

    Returns:
        The name of the new run.

    Raises:
        ImportFormatError: If the file is not a valid ``.rbundle``.
        OSError: If the file cannot be read.
    """
    path = pathlib.Path(path).resolve()
    header, tail = _parse_rbundle(path.read_bytes())
    bufs = header["buffers"]
    pos = _buffer(tail, bufs["body_pos"], np.float32)
    quat = _buffer(tail, bufs["body_quat"], np.float32)
    n_bodies = pos.shape[1]
    builder = _SceneBuilder()
    for gj in header["geoms"]:
        geom = _rbundle_geom(builder, gj, tail, n_bodies)
        if geom is not None:
            builder.geoms.append(geom)
    meta_in = header.get("meta", {})
    names = meta_in.get("body_names") or []
    bodies = [
        core.Body(
            str(names[i])
            if i < len(names)
            else ("world" if i == 0 else f"body_{i}")
        )
        for i in range(n_bodies)
    ]
    scene = builder.build(bodies)
    meta: dict[str, Any] = {"rbundle": _json_safe(meta_in)}
    for key in ("config", "metrics"):
        value = _read_small_json(path.with_name(f"{key}.json"))
        if value is not None:
            meta[key] = value
    run = unique_run_name(lib, name or default_name(path), overwrite)
    _write_run(
        lib,
        run,
        scene=scene,
        dt=float(meta_in.get("dt") or 1.0 / float(meta_in.get("fps", 50.0))),
        poses=_poses(pos, quat),
        streams=_rbundle_streams(header, tail),
        source={
            "simulator": "mujoco",
            "importer": "rbundle",
            "original": str(path),
        },
        tags=_all_tags(tags, "source:rbundle"),
        meta=meta,
        overwrite=overwrite,
        created=recorded_time(path),
    )
    return run

recorded_time(path: os.PathLike[str] | str) -> str

Estimates when a source file was recorded, as a UTC timestamp.

A timestamp embedded in the file or its folder name wins (such as 20260911-132437_x.html or crate-20260910T005846Z), because copying a file changes its modification time. Otherwise the file's modification time is used.

Parameters:

Name Type Description Default
path PathLike[str] | str

The source file.

required

Returns:

Type Description
str

YYYY-MM-DDTHH:MM:SSZ in UTC.

Source code in src/simscope/importers.py
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
def recorded_time(path: os.PathLike[str] | str) -> str:
    """Estimates when a source file was recorded, as a UTC timestamp.

    A timestamp embedded in the file or its folder name wins (such as
    ``20260911-132437_x.html`` or ``crate-20260910T005846Z``), because
    copying a file changes its modification time. Otherwise the file's
    modification time is used.

    Args:
        path: The source file.

    Returns:
        ``YYYY-MM-DDTHH:MM:SSZ`` in UTC.
    """
    path = pathlib.Path(path)
    for text in (path.name, path.parent.name):
        m = _STAMP.search(text)
        if m:
            try:
                year, month, day, hour, minute, second = map(int, m.groups())
                when = datetime.datetime(year, month, day, hour, minute, second)
            except ValueError:
                continue
            return when.strftime("%Y-%m-%dT%H:%M:%SZ")
    mtime = datetime.datetime.fromtimestamp(path.stat().st_mtime, datetime.UTC)
    return mtime.strftime("%Y-%m-%dT%H:%M:%SZ")

unique_run_name(library_: library.Library, base: str, overwrite: bool = False, taken: Collection[str] = ()) -> str

Builds a valid, unused run name from free text.

Parameters:

Name Type Description Default
library_ Library

The target library.

required
base str

Desired name; invalid characters become _.

required
overwrite bool

Allow the name of a run already in the library.

False
taken Collection[str]

Names to avoid in any case, such as those planned for other files of the same batch.

()

Returns:

Type Description
str

The sanitized name, with -2, -3, ... appended if taken.

Source code in src/simscope/importers.py
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
def unique_run_name(
    library_: library.Library,
    base: str,
    overwrite: bool = False,
    taken: Collection[str] = (),
) -> str:
    """Builds a valid, unused run name from free text.

    Args:
        library_: The target library.
        base: Desired name; invalid characters become ``_``.
        overwrite: Allow the name of a run already in the library.
        taken: Names to avoid in any case, such as those planned for other
            files of the same batch.

    Returns:
        The sanitized name, with ``-2``, ``-3``, ... appended if taken.
    """
    name = _NAME_CHARS.sub("_", base).strip("._-")[:_MAX_NAME] or "run"
    candidate, n = name, 1
    while candidate in taken or (
        not overwrite and library_.run_dir(candidate).exists()
    ):
        n += 1
        candidate = f"{name}-{n}"
    return candidate

Simulator adapters

Both adapters record the contacts stream the viewer draws as force arrows: add_contacts_stream declares it and contacts packs one frame.

simscope.mujoco

Adapter for MuJoCo, MJX, and MuJoCo Warp.

scene_from_model turns a compiled MjModel into a simscope.core.Scene. poses turns simulator state into the [E, B, 7] pose array the recorder logs, converting MuJoCo's wxyz quaternions to simscope's xyzw. Nothing here imports JAX or Warp: MJX and Warp arrays only need to convert through np.asarray (or .numpy()).

contacts turns data.contact into the [1, K, 6] arrows frame of the contacts stream (point and force on the robot, both in the world frame), and add_contacts_stream declares that stream on a recorder with the 1 / (m g) drawing scale.

Importing this module imports mujoco; import simscope does not.

Example

from simscope import mujoco as smj scene = smj.scene_from_model(model) frame = smj.poses(data) # [1, n_bodies, 7] smj.add_contacts_stream(rec, model, max_contacts=16) rec.log(frame, contacts=smj.contacts(model, data, max_contacts=16))

CONTACTS_STREAM = 'contacts' module-attribute

Name of the contacts stream (viewer contracts 7).

DEFAULT_MAX_CONTACTS = 16 module-attribute

Contact slots per frame that add_contacts_stream reserves.

add_contacts_stream(recorder: Any, model: mujoco.MjModel, *, max_contacts: int = DEFAULT_MAX_CONTACTS, name: str = CONTACTS_STREAM) -> None

Declares the contacts stream on a recorder.

Call it before the first log, then pass contacts(...) as the contacts keyword of log.

Parameters:

Name Type Description Default
recorder Any

A simscope.recorder.Recorder.

required
model MjModel

The compiled model (its mass sets the drawing scale).

required
max_contacts int

K, the contact slots per frame.

DEFAULT_MAX_CONTACTS
name str

Stream name.

CONTACTS_STREAM
Source code in src/simscope/mujoco.py
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
def add_contacts_stream(
    recorder: Any,
    model: mujoco.MjModel,
    *,
    max_contacts: int = DEFAULT_MAX_CONTACTS,
    name: str = CONTACTS_STREAM,
) -> None:
    """Declares the contacts stream on a recorder.

    Call it before the first ``log``, then pass ``contacts(...)`` as the
    ``contacts`` keyword of ``log``.

    Args:
        recorder: A ``simscope.recorder.Recorder``.
        model: The compiled model (its mass sets the drawing scale).
        max_contacts: ``K``, the contact slots per frame.
        name: Stream name.
    """
    recorder.add_stream(
        name,
        "arrows",
        (max_contacts, 6),
        units="N",
        scale=contacts_scale(model),
    )

contacts(model: mujoco.MjModel, data: mujoco.MjData, out: npt.NDArray[np.float32] | None = None, *, max_contacts: int | None = None) -> npt.NDArray[np.float32]

Packs the active contacts of one MjData for the contacts stream.

Each row is [px, py, pz, fx, fy, fz]: the contact point (m) and the force (N) that the world exerts on the robot, both in the world frame. MuJoCo's contact force (mj_contactForce, rotated out of the contact frame) is the force on geom 2, so the sign is flipped when the robot is geom 1. The fixed side is the one whose geom belongs to the world body (the ground, a wall); if neither geom does, the force on geom 2 is used. Unused rows are zero, and so are contacts that carry no force. If there are more than K force-carrying contacts, the K strongest are kept.

For batched MJX or MuJoCo Warp data, convert each env to an MjData (mjx.get_data(model, batch) returns one per env) and call this once per env with out=frame[e], where frame is an [E, K, 6] array. A fully batched path would need mj_contactForce on device and is left out on purpose.

Parameters:

Name Type Description Default
model MjModel

The compiled model.

required
data MjData

Simulator state; contacts are read as of the last step.

required
out NDArray[float32] | None

Optional float32 C-contiguous buffer to fill (and return), shape [K, 6] or [1, K, 6]. It is zeroed first.

None
max_contacts int | None

K when out is not given.

None

Returns:

Type Description
NDArray[float32]

A float32 array [1, K, 6] (out itself when it has that

NDArray[float32]

shape, else a view of it), ready for Recorder.log.

Raises:

Type Description
ValueError

If neither out nor max_contacts is given, they disagree, or out is unsuitable.

Source code in src/simscope/mujoco.py
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
def contacts(
    model: mujoco.MjModel,
    data: mujoco.MjData,
    out: npt.NDArray[np.float32] | None = None,
    *,
    max_contacts: int | None = None,
) -> npt.NDArray[np.float32]:
    """Packs the active contacts of one ``MjData`` for the contacts stream.

    Each row is ``[px, py, pz, fx, fy, fz]``: the contact point (m) and the
    force (N) that the world exerts on the robot, both in the world frame.
    MuJoCo's contact force (``mj_contactForce``, rotated out of the contact
    frame) is the force on geom 2, so the sign is flipped when the robot is
    geom 1. The fixed side is the one whose geom belongs to the world body
    (the ground, a wall); if neither geom does, the force on geom 2 is
    used. Unused rows are zero, and so are contacts that carry no force. If
    there are more than ``K`` force-carrying contacts, the ``K`` strongest
    are kept.

    For batched MJX or MuJoCo Warp data, convert each env to an ``MjData``
    (``mjx.get_data(model, batch)`` returns one per env) and call this once
    per env with ``out=frame[e]``, where ``frame`` is an ``[E, K, 6]``
    array. A fully batched path would need ``mj_contactForce`` on device
    and is left out on purpose.

    Args:
        model: The compiled model.
        data: Simulator state; contacts are read as of the last step.
        out: Optional float32 C-contiguous buffer to fill (and return),
            shape ``[K, 6]`` or ``[1, K, 6]``. It is zeroed first.
        max_contacts: ``K`` when ``out`` is not given.

    Returns:
        A float32 array ``[1, K, 6]`` (``out`` itself when it has that
        shape, else a view of it), ready for ``Recorder.log``.

    Raises:
        ValueError: If neither ``out`` nor ``max_contacts`` is given, they
            disagree, or ``out`` is unsuitable.
    """
    if out is None:
        if max_contacts is None or max_contacts < 1:
            raise ValueError("pass out, or max_contacts >= 1")
        out = np.zeros((1, max_contacts, 6), dtype=np.float32)
    else:
        if (
            out.dtype != np.float32
            or not out.flags.c_contiguous
            or out.ndim not in (2, 3)
            or out.shape[-1] != 6
            or (out.ndim == 3 and out.shape[0] != 1)
        ):
            raise ValueError(
                "out must be a C-contiguous float32 [K, 6] or [1, K, 6] "
                f"array, got {out.dtype} {out.shape}"
            )
        if max_contacts is not None and max_contacts != out.shape[-2]:
            raise ValueError(
                f"max_contacts={max_contacts} but out has {out.shape[-2]} rows"
            )
        out[...] = 0.0
    rows = out.reshape(-1, 6)
    n = int(data.ncon)
    if n == 0:
        return out.reshape(1, *rows.shape)
    contact = data.contact
    local = np.zeros(6)
    force = np.zeros((n, 3))
    for i in range(n):
        mujoco.mj_contactForce(model, data, i, local)
        force[i] = local[:3]
    frame = np.asarray(contact.frame[:n]).reshape(n, 3, 3)
    world = np.einsum("nji,nj->ni", frame, force)  # force on geom 2
    geoms = np.asarray(contact.geom[:n])
    fixed = model.geom_bodyid[geoms] == 0
    world[fixed[:, 1] & ~fixed[:, 0]] *= -1.0  # robot is geom 1
    magnitude = np.einsum("ni,ni->n", world, world)
    live = np.flatnonzero(magnitude > 0.0)
    if len(live) > len(rows):
        live = live[np.argsort(-magnitude[live], kind="stable")[: len(rows)]]
        live.sort()
    rows[: len(live), :3] = np.asarray(contact.pos[:n])[live]
    rows[: len(live), 3:] = world[live]
    return out.reshape(1, *rows.shape)

contacts_scale(model: mujoco.MjModel) -> float

Metres of drawn arrow per newton: one body weight draws as 1 m.

Parameters:

Name Type Description Default
model MjModel

The compiled model.

required

Returns:

Type Description
float

1 / (m g) with m the total mass and g gravity's

float

magnitude, or 1 / (m * 9.81) when the model has no gravity, or

float

1.0 when it has no mass.

Source code in src/simscope/mujoco.py
345
346
347
348
349
350
351
352
353
354
355
356
357
358
def contacts_scale(model: mujoco.MjModel) -> float:
    """Metres of drawn arrow per newton: one body weight draws as 1 m.

    Args:
        model: The compiled model.

    Returns:
        ``1 / (m g)`` with ``m`` the total mass and ``g`` gravity's
        magnitude, or ``1 / (m * 9.81)`` when the model has no gravity, or
        ``1.0`` when it has no mass.
    """
    mass = float(model.body_subtreemass[0])
    g = float(np.linalg.norm(model.opt.gravity)) or _GRAVITY
    return 1.0 / (mass * g) if mass > 0.0 else 1.0

poses(data_or_xpos: Any, xquat: Any | None = None, *, out: npt.NDArray[np.float32] | None = None) -> npt.NDArray[np.float32]

Packs body poses into simscope's [E, B, 7] layout.

Parameters:

Name Type Description Default
data_or_xpos Any

Either an object with xpos and xquat attributes (mujoco.MjData, or an MJX data object), or the body positions xpos with shape [..., B, 3]. Arrays may be numpy, JAX, or Warp; anything with a numpy() method uses it, everything else goes through np.asarray.

required
xquat Any | None

Body quaternions in wxyz order, shape [..., B, 4]. Must be None when data_or_xpos is a data object.

None
out NDArray[float32] | None

Optional preallocated float32 C-contiguous [E, B, 7] array to fill and return.

None

Returns:

Type Description
NDArray[float32]

A float32 C-contiguous array [E, B, 7] of xyz followed by

NDArray[float32]

quaternion xyzw. A single MjData gives E = 1; extra

NDArray[float32]

leading axes of batched input are flattened into E. The result

NDArray[float32]

never aliases the input.

Raises:

Type Description
ValueError

If shapes are inconsistent, or out is unsuitable.

TypeError

If xquat is missing and the first argument has no xpos/xquat.

Source code in src/simscope/mujoco.py
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
def poses(
    data_or_xpos: Any,
    xquat: Any | None = None,
    *,
    out: npt.NDArray[np.float32] | None = None,
) -> npt.NDArray[np.float32]:
    """Packs body poses into simscope's ``[E, B, 7]`` layout.

    Args:
        data_or_xpos: Either an object with ``xpos`` and ``xquat``
            attributes (``mujoco.MjData``, or an MJX data object), or the
            body positions ``xpos`` with shape ``[..., B, 3]``. Arrays may be
            numpy, JAX, or Warp; anything with a ``numpy()`` method uses it,
            everything else goes through ``np.asarray``.
        xquat: Body quaternions in wxyz order, shape ``[..., B, 4]``. Must be
            ``None`` when ``data_or_xpos`` is a data object.
        out: Optional preallocated float32 C-contiguous ``[E, B, 7]`` array
            to fill and return.

    Returns:
        A float32 C-contiguous array ``[E, B, 7]`` of ``xyz`` followed by
        quaternion ``xyzw``. A single ``MjData`` gives ``E = 1``; extra
        leading axes of batched input are flattened into ``E``. The result
        never aliases the input.

    Raises:
        ValueError: If shapes are inconsistent, or ``out`` is unsuitable.
        TypeError: If ``xquat`` is missing and the first argument has no
            ``xpos``/``xquat``.
    """
    if xquat is None:
        if not (
            hasattr(data_or_xpos, "xpos") and hasattr(data_or_xpos, "xquat")
        ):
            raise TypeError(
                "pass MjData (or an object with xpos and xquat), "
                "or both xpos and xquat arrays"
            )
        xpos_in, xquat_in = data_or_xpos.xpos, data_or_xpos.xquat
    else:
        xpos_in, xquat_in = data_or_xpos, xquat
    xpos = _to_numpy(xpos_in)
    quat = _to_numpy(xquat_in)
    if xpos.ndim < 2 or xpos.shape[-1] != 3:
        raise ValueError(f"xpos must have shape [..., B, 3], got {xpos.shape}")
    if quat.shape[:-1] != xpos.shape[:-1] or quat.shape[-1] != 4:
        raise ValueError(
            f"xquat must have shape {(*xpos.shape[:-1], 4)}, got {quat.shape}"
        )
    n_bodies = xpos.shape[-2]
    xpos = xpos.reshape(-1, n_bodies, 3)
    quat = quat.reshape(-1, n_bodies, 4)
    shape = (xpos.shape[0], n_bodies, core.POSE_DIM)
    if out is None:
        out = np.empty(shape, dtype=np.float32)
    elif (
        out.shape != shape
        or out.dtype != np.float32
        or not out.flags.c_contiguous
    ):
        raise ValueError(
            f"out must be a C-contiguous float32 array of shape {shape}"
        )
    out[..., :3] = xpos
    out[..., 3:6] = quat[..., 1:]
    out[..., 6] = quat[..., 0]
    return out

scene_from_model(model: mujoco.MjModel, *, collision: bool = True) -> core.Scene

Builds a scene from a compiled MuJoCo model.

Bodies keep MuJoCo's ids, so the pose stream from poses lines up with Scene.bodies. Geoms are attached to geom_bodyid with geom_pos/geom_quat as their local pose. Mesh vertices are stored exactly as mjModel holds them (already recentered and scaled by the compiler), so the geom's local pose alone places them in the world and Geom.scale is always 1.

Geom kinds: box, sphere, capsule, cylinder, ellipsoid, plane, and mesh map one to one with MuJoCo's geom_size (unused entries zeroed). Height fields are tessellated into meshes in the geom frame. Other geom types (SDF) are skipped with a single warning.

Role rule (group only, as in mjviser, whose viewer shows groups 0-2 by default): a geom is "collision" if its geom_group is 3 or higher, or if its displayed alpha is 0; otherwise it is "visual". contype/conaffinity are deliberately ignored, because in plain MJCF a default (group 0) geom collides and is also the only thing to look at.

Color rule (verified against mjv_updateScene): a geom with a material takes mat_rgba unless its own rgba was set explicitly. Materials are deduplicated. Only 2D textures are exported, as PNG; cube maps and skyboxes are dropped. A mesh geom keeps its texture only if the mesh has texture coordinates. Texture coordinates follow OpenGL (v = 0 is the bottom of the image) and PNG rows are top-down.

MuJoCo's stored mesh normals are not exported; viewers compute them.

Parameters:

Name Type Description Default
model MjModel

A compiled mujoco.MjModel.

required
collision bool

If false, keep only "visual" geoms.

True

Returns:

Type Description
Scene

The scene. Meshes, textures, and materials are deduplicated and

Scene

include only what the kept geoms use.

Source code in src/simscope/mujoco.py
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
def scene_from_model(
    model: mujoco.MjModel, *, collision: bool = True
) -> core.Scene:
    """Builds a scene from a compiled MuJoCo model.

    Bodies keep MuJoCo's ids, so the pose stream from ``poses`` lines up
    with ``Scene.bodies``. Geoms are attached to ``geom_bodyid`` with
    ``geom_pos``/``geom_quat`` as their local pose. Mesh vertices are stored
    exactly as ``mjModel`` holds them (already recentered and scaled by the
    compiler), so the geom's local pose alone places them in the world and
    ``Geom.scale`` is always 1.

    Geom kinds: box, sphere, capsule, cylinder, ellipsoid, plane, and mesh
    map one to one with MuJoCo's ``geom_size`` (unused entries zeroed).
    Height fields are tessellated into meshes in the geom frame. Other
    geom types (SDF) are skipped with a single warning.

    Role rule (group only, as in mjviser, whose viewer shows groups 0-2 by
    default): a geom is ``"collision"`` if its ``geom_group`` is 3 or
    higher, or if its displayed alpha is 0; otherwise it is ``"visual"``.
    ``contype``/``conaffinity`` are deliberately ignored, because in plain
    MJCF a default (group 0) geom collides and is also the only thing to
    look at.

    Color rule (verified against ``mjv_updateScene``): a geom with a
    material takes ``mat_rgba`` unless its own ``rgba`` was set explicitly.
    Materials are deduplicated. Only 2D textures are exported, as PNG;
    cube maps and skyboxes are dropped. A mesh geom keeps its texture only
    if the mesh has texture coordinates. Texture coordinates follow OpenGL
    (``v = 0`` is the bottom of the image) and PNG rows are top-down.

    MuJoCo's stored mesh normals are not exported; viewers compute them.

    Args:
        model: A compiled ``mujoco.MjModel``.
        collision: If false, keep only ``"visual"`` geoms.

    Returns:
        The scene. Meshes, textures, and materials are deduplicated and
        include only what the kept geoms use.
    """
    body_names = [
        mujoco.mj_id2name(model, mujoco.mjtObj.mjOBJ_BODY, i) or f"body{i}"
        for i in range(model.nbody)
    ]
    parents = model.body_parentid.tolist()
    if parents:
        parents[0] = -1
    masses = model.body_mass.tolist()
    if masses:
        masses[0] = 0.0  # the world body has no mass
    bodies = tuple(
        core.Body(name=name, parent=parent, mass=mass)
        for name, parent, mass in zip(body_names, parents, masses, strict=True)
    )

    types = model.geom_type
    supported = np.isin(types, list(_KINDS))
    supported &= ~((types == _GEOM.mjGEOM_MESH) & (model.geom_dataid < 0))
    _warn_skipped(model, types[~supported])

    rgba = _mjviser.effective_geom_rgba(model)
    hidden = (model.geom_group >= _FIRST_COLLISION_GROUP) | (rgba[:, 3] == 0)
    keep = supported if collision else supported & ~hidden
    ids = np.flatnonzero(keep)

    sizes = model.geom_size[ids].astype(np.float64)
    kept_types = types[ids]
    sizes[kept_types == _GEOM.mjGEOM_SPHERE, 1:] = 0.0
    sizes[
        (kept_types == _GEOM.mjGEOM_CAPSULE)
        | (kept_types == _GEOM.mjGEOM_CYLINDER),
        2,
    ] = 0.0
    sizes[
        (kept_types == _GEOM.mjGEOM_MESH) | (kept_types == _GEOM.mjGEOM_HFIELD)
    ] = 0.0
    positions = model.geom_pos[ids].tolist()
    quats = model.geom_quat[ids][:, [1, 2, 3, 0]].tolist()
    size_rows = sizes.tolist()

    builder = _SceneBuilder(model, rgba)
    geoms = []
    for k, gid in enumerate(ids.tolist()):
        material, mesh = builder.material_and_mesh(gid)
        geoms.append(
            core.Geom(
                body=int(model.geom_bodyid[gid]),
                kind=_KINDS[int(types[gid])],
                size=tuple(size_rows[k]),
                pos=tuple(positions[k]),
                quat=tuple(quats[k]),
                material=material,
                mesh=mesh,
                role="collision" if hidden[gid] else "visual",
                name=mujoco.mj_id2name(model, mujoco.mjtObj.mjOBJ_GEOM, gid)
                or "",
            )
        )
    return core.Scene(
        bodies=bodies,
        geoms=tuple(geoms),
        materials=tuple(builder.materials) or (core.Material(),),
        meshes=tuple(builder.meshes),
        textures=tuple(builder.textures),
    )

source_info() -> dict[str, str]

Describes the simulator for a rollout manifest.

Returns:

Type Description
dict[str, str]

{"simulator": "mujoco", "version": <mujoco version>}. MJX and

dict[str, str]

MuJoCo Warp are reported as mujoco too.

Source code in src/simscope/mujoco.py
63
64
65
66
67
68
69
70
def source_info() -> dict[str, str]:
    """Describes the simulator for a rollout manifest.

    Returns:
        ``{"simulator": "mujoco", "version": <mujoco version>}``. MJX and
        MuJoCo Warp are reported as ``mujoco`` too.
    """
    return {"simulator": "mujoco", "version": mujoco.__version__}

simscope.isaaclab

Adapter for Isaac Lab 3.0+ (Isaac Sim 6.x).

scene_from_env reads the static scene from the live USD stage. poses and PoseBuffer read asset.data.body_link_pose_w every step and give the [E, B, 7] layout the recorder logs. Isaac Lab 3.0 already uses xyzw quaternions and world-frame poses that include the env origins, so no conversion is needed.

Nothing here imports torch, warp, isaaclab, omni, or isaacsim at import time. Arrays are duck-typed (numpy, torch, Warp, or Isaac Lab's ProxyArray) and torch or Warp are imported lazily inside the branch that needs them. Importing this module does import pxr, which only exists after Isaac Sim has started (for example after AppLauncher); import simscope does not.

contacts turns a ContactSensor's net body forces into the [E, B, 6] arrows frame of the contacts stream, and add_contacts_stream declares that stream with the 1 / (m g) scale.

Example

from simscope import isaaclab as sil scene = sil.scene_from_env(env, ["robot", "cube"]) frame = sil.poses(env, ["robot", "cube"]) # [E, B, 7] sil.add_contacts_stream(rec, n_bodies=len(sensor.body_names), mass=m) rec.log(frame, contacts=sil.contacts(sensor, robot))

CONTACTS_STREAM = 'contacts' module-attribute

Name of the contacts stream (viewer contracts 7).

PoseBuffer

Stages poses in a device-side ring buffer, one host copy per block.

push copies this step's poses into a preallocated buffer on the same device as the source arrays, so it never synchronizes with the host. drain then moves everything to the host in a single transfer. For numpy sources (CPU) it uses a preallocated numpy buffer instead.

Attributes:

Name Type Description
capacity

Maximum number of frames held between drains.

Source code in src/simscope/isaaclab.py
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
class PoseBuffer:
    """Stages poses in a device-side ring buffer, one host copy per block.

    ``push`` copies this step's poses into a preallocated buffer on the same
    device as the source arrays, so it never synchronizes with the host.
    ``drain`` then moves everything to the host in a single transfer. For
    numpy sources (CPU) it uses a preallocated numpy buffer instead.

    Attributes:
        capacity: Maximum number of frames held between drains.
    """

    def __init__(self, env: Any, assets: Sequence[str], capacity: int) -> None:
        """Binds the buffer to an env and a list of assets.

        The device buffer is allocated lazily at the first ``push``, with the
        same framework and device as the first pose array.

        Args:
            env: An Isaac Lab environment (or a gym wrapper around one).
            assets: Scene entity names, in pose order.
            capacity: Frames to hold between drains.

        Raises:
            ValueError: If ``capacity`` is not positive or ``assets`` is
                empty.
        """
        if capacity <= 0:
            raise ValueError(f"capacity must be positive, got {capacity}")
        self.capacity = capacity
        self._sources = _asset_data(env, assets)
        self._buffer: Any = None
        self._is_torch = False
        self._slices: list[tuple[int, int]] = []
        self._count = 0

    def __len__(self) -> int:
        """Returns the number of frames waiting to be drained."""
        return self._count

    @property
    def full(self) -> bool:
        """Whether the next ``push`` would overflow the buffer."""
        return self._count >= self.capacity

    def push(self) -> None:
        """Copies the current poses into the ring buffer on the device.

        Raises:
            BufferError: If the buffer is full; call ``drain`` first.
            ValueError: If an asset's pose array changed shape.
            TypeError: If the assets mix torch and numpy arrays.
        """
        if self._count >= self.capacity:
            raise BufferError("PoseBuffer is full; call drain() first")
        arrays = [_as_array(d.body_link_pose_w) for d in self._sources]
        if self._buffer is None:
            self._allocate(arrays)
        frame = self._buffer[self._count]
        for array, (lo, hi) in zip(arrays, self._slices, strict=True):
            if _is_torch(array) != self._is_torch:
                raise TypeError("assets mix torch and numpy pose arrays")
            if tuple(array.shape) != (frame.shape[0], hi - lo, core.POSE_DIM):
                raise ValueError(f"pose array changed shape: {array.shape}")
            if self._is_torch:
                frame[:, lo:hi].copy_(array)
            else:
                frame[:, lo:hi] = array
        self._count += 1

    def drain(self) -> npt.NDArray[np.float32]:
        """Moves the buffered frames to the host and empties the buffer.

        Returns:
            A new float32 array ``[n, E, B, 7]`` with ``n = len(self)``
            before the call. Uses exactly one device-to-host copy.
        """
        n, self._count = self._count, 0
        if self._buffer is None:
            return np.empty((0, 0, 0, core.POSE_DIM), dtype=np.float32)
        block = self._buffer[:n]
        if not self._is_torch:
            return block.copy()
        if block.device.type == "cpu":
            block = block.clone()
        return block.cpu().numpy()

    def _allocate(self, arrays: list[Any]) -> None:
        """Allocates the buffer to match the first pose arrays.

        Args:
            arrays: Pose arrays of every asset, each ``[E, B_i, 7]``.

        Raises:
            ValueError: If an array is not ``[E, B_i, 7]`` or the assets
                disagree on ``E``.
        """
        first = arrays[0]
        n_envs = first.shape[0]
        self._slices = []
        start = 0
        for array in arrays:
            _check_pose_shape(array.shape)
            if array.shape[0] != n_envs:
                raise ValueError("assets have different numbers of envs")
            self._slices.append((start, start + array.shape[1]))
            start += array.shape[1]
        shape = (self.capacity, n_envs, start, core.POSE_DIM)
        self._is_torch = _is_torch(first)
        if self._is_torch:
            torch = importlib.import_module("torch")  # Only inside Isaac.

            self._buffer = torch.empty(
                shape, dtype=torch.float32, device=first.device
            )
        else:
            self._buffer = np.empty(shape, dtype=np.float32)

full: bool property

Whether the next push would overflow the buffer.

__init__(env: Any, assets: Sequence[str], capacity: int) -> None

Binds the buffer to an env and a list of assets.

The device buffer is allocated lazily at the first push, with the same framework and device as the first pose array.

Parameters:

Name Type Description Default
env Any

An Isaac Lab environment (or a gym wrapper around one).

required
assets Sequence[str]

Scene entity names, in pose order.

required
capacity int

Frames to hold between drains.

required

Raises:

Type Description
ValueError

If capacity is not positive or assets is empty.

Source code in src/simscope/isaaclab.py
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
def __init__(self, env: Any, assets: Sequence[str], capacity: int) -> None:
    """Binds the buffer to an env and a list of assets.

    The device buffer is allocated lazily at the first ``push``, with the
    same framework and device as the first pose array.

    Args:
        env: An Isaac Lab environment (or a gym wrapper around one).
        assets: Scene entity names, in pose order.
        capacity: Frames to hold between drains.

    Raises:
        ValueError: If ``capacity`` is not positive or ``assets`` is
            empty.
    """
    if capacity <= 0:
        raise ValueError(f"capacity must be positive, got {capacity}")
    self.capacity = capacity
    self._sources = _asset_data(env, assets)
    self._buffer: Any = None
    self._is_torch = False
    self._slices: list[tuple[int, int]] = []
    self._count = 0

__len__() -> int

Returns the number of frames waiting to be drained.

Source code in src/simscope/isaaclab.py
279
280
281
def __len__(self) -> int:
    """Returns the number of frames waiting to be drained."""
    return self._count

drain() -> npt.NDArray[np.float32]

Moves the buffered frames to the host and empties the buffer.

Returns:

Type Description
NDArray[float32]

A new float32 array [n, E, B, 7] with n = len(self)

NDArray[float32]

before the call. Uses exactly one device-to-host copy.

Source code in src/simscope/isaaclab.py
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
def drain(self) -> npt.NDArray[np.float32]:
    """Moves the buffered frames to the host and empties the buffer.

    Returns:
        A new float32 array ``[n, E, B, 7]`` with ``n = len(self)``
        before the call. Uses exactly one device-to-host copy.
    """
    n, self._count = self._count, 0
    if self._buffer is None:
        return np.empty((0, 0, 0, core.POSE_DIM), dtype=np.float32)
    block = self._buffer[:n]
    if not self._is_torch:
        return block.copy()
    if block.device.type == "cpu":
        block = block.clone()
    return block.cpu().numpy()

push() -> None

Copies the current poses into the ring buffer on the device.

Raises:

Type Description
BufferError

If the buffer is full; call drain first.

ValueError

If an asset's pose array changed shape.

TypeError

If the assets mix torch and numpy arrays.

Source code in src/simscope/isaaclab.py
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
def push(self) -> None:
    """Copies the current poses into the ring buffer on the device.

    Raises:
        BufferError: If the buffer is full; call ``drain`` first.
        ValueError: If an asset's pose array changed shape.
        TypeError: If the assets mix torch and numpy arrays.
    """
    if self._count >= self.capacity:
        raise BufferError("PoseBuffer is full; call drain() first")
    arrays = [_as_array(d.body_link_pose_w) for d in self._sources]
    if self._buffer is None:
        self._allocate(arrays)
    frame = self._buffer[self._count]
    for array, (lo, hi) in zip(arrays, self._slices, strict=True):
        if _is_torch(array) != self._is_torch:
            raise TypeError("assets mix torch and numpy pose arrays")
        if tuple(array.shape) != (frame.shape[0], hi - lo, core.POSE_DIM):
            raise ValueError(f"pose array changed shape: {array.shape}")
        if self._is_torch:
            frame[:, lo:hi].copy_(array)
        else:
            frame[:, lo:hi] = array
    self._count += 1

add_contacts_stream(recorder: Any, n_bodies: int, *, mass: float, name: str = CONTACTS_STREAM) -> None

Declares the contacts stream on a recorder.

Call it before the first log, then pass contacts(...) as the contacts keyword of log.

Parameters:

Name Type Description Default
recorder Any

A simscope.recorder.Recorder.

required
n_bodies int

K, the number of sensor bodies.

required
mass float

Robot mass in kg; one body weight (mass * 9.81 N) is drawn as a 1 m arrow.

required
name str

Stream name.

CONTACTS_STREAM

Raises:

Type Description
ValueError

If mass is not positive.

Source code in src/simscope/isaaclab.py
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
def add_contacts_stream(
    recorder: Any,
    n_bodies: int,
    *,
    mass: float,
    name: str = CONTACTS_STREAM,
) -> None:
    """Declares the contacts stream on a recorder.

    Call it before the first ``log``, then pass ``contacts(...)`` as the
    ``contacts`` keyword of ``log``.

    Args:
        recorder: A ``simscope.recorder.Recorder``.
        n_bodies: ``K``, the number of sensor bodies.
        mass: Robot mass in kg; one body weight (``mass * 9.81`` N) is drawn
            as a 1 m arrow.
        name: Stream name.

    Raises:
        ValueError: If ``mass`` is not positive.
    """
    if mass <= 0:
        raise ValueError(f"mass must be positive, got {mass}")
    recorder.add_stream(
        name,
        "arrows",
        (n_bodies, 6),
        units="N",
        scale=1.0 / (mass * _GRAVITY),
    )

contacts(sensor: Any, bodies: Any, *, out: npt.NDArray[np.float32] | None = None) -> npt.NDArray[np.float32]

Packs a ContactSensor's net body forces for the contacts stream.

Row b of env e is [px, py, pz, fx, fy, fz]: the position of sensor body b (where the arrow is drawn) and the net contact force on it, both in the world frame (sensor.data.net_forces_w). Bodies without contact give zero rows, which deflate to nothing. K is the number of sensor bodies.

Parameters:

Name Type Description Default
sensor Any

An Isaac Lab ContactSensor (anything with data.net_forces_w of shape [E, B, 3]).

required
bodies Any

Where the sensor bodies are. Either the asset that owns them (an articulation: its body_names are matched against sensor.body_names and data.body_link_pose_w is read), or an array of their positions or poses in sensor-body order, [E, B, 3] or [E, B, 7].

required
out NDArray[float32] | None

Optional float32 C-contiguous [E, B, 6] array to fill.

None

Returns:

Type Description
NDArray[float32]

A float32 array [E, B, 6] on the host (out if given).

Raises:

Type Description
ValueError

If the shapes disagree, a sensor body is not found in the asset, or out is unsuitable.

Source code in src/simscope/isaaclab.py
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
def contacts(
    sensor: Any,
    bodies: Any,
    *,
    out: npt.NDArray[np.float32] | None = None,
) -> npt.NDArray[np.float32]:
    """Packs a ``ContactSensor``'s net body forces for the contacts stream.

    Row ``b`` of env ``e`` is ``[px, py, pz, fx, fy, fz]``: the position of
    sensor body ``b`` (where the arrow is drawn) and the net contact force
    on it, both in the world frame (``sensor.data.net_forces_w``). Bodies
    without contact give zero rows, which deflate to nothing. ``K`` is the
    number of sensor bodies.

    Args:
        sensor: An Isaac Lab ``ContactSensor`` (anything with
            ``data.net_forces_w`` of shape ``[E, B, 3]``).
        bodies: Where the sensor bodies are. Either the asset that owns
            them (an articulation: its ``body_names`` are matched against
            ``sensor.body_names`` and ``data.body_link_pose_w`` is read), or
            an array of their positions or poses in sensor-body order,
            ``[E, B, 3]`` or ``[E, B, 7]``.
        out: Optional float32 C-contiguous ``[E, B, 6]`` array to fill.

    Returns:
        A float32 array ``[E, B, 6]`` on the host (``out`` if given).

    Raises:
        ValueError: If the shapes disagree, a sensor body is not found in
            the asset, or ``out`` is unsuitable.
    """
    forces = _to_host(sensor.data.net_forces_w)
    if forces.ndim != 3 or forces.shape[-1] != 3:
        raise ValueError(f"net_forces_w must be [E, B, 3], got {forces.shape}")
    if hasattr(bodies, "body_names") and hasattr(bodies, "data"):
        names = list(bodies.body_names)
        try:
            ids = [names.index(n) for n in sensor.body_names]
        except ValueError as exc:
            raise ValueError(
                f"sensor bodies {list(sensor.body_names)} are not all in "
                f"the asset's bodies {names}"
            ) from exc
        positions = _to_host(bodies.data.body_link_pose_w)[:, ids, :3]
    else:
        positions = _to_host(bodies)[..., :3]
    if positions.shape != forces.shape:
        raise ValueError(
            f"positions {positions.shape} do not match forces {forces.shape}"
        )
    shape = (*forces.shape[:2], 6)
    if out is None:
        out = np.empty(shape, dtype=np.float32)
    elif (
        out.shape != shape
        or out.dtype != np.float32
        or not out.flags.c_contiguous
    ):
        raise ValueError(
            f"out must be a C-contiguous float32 array of shape {shape}"
        )
    out[..., :3] = positions
    out[..., 3:] = forces
    return out

env_origins(env: Any) -> npt.NDArray[np.float32]

Reads the world origin of every env.

Parameters:

Name Type Description Default
env Any

An Isaac Lab environment (or a gym wrapper around one).

required

Returns:

Type Description
NDArray[float32]

A float32 array [E, 3].

Raises:

Type Description
ValueError

If env.scene.env_origins is not [E, 3].

Source code in src/simscope/isaaclab.py
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
def env_origins(env: Any) -> npt.NDArray[np.float32]:
    """Reads the world origin of every env.

    Args:
        env: An Isaac Lab environment (or a gym wrapper around one).

    Returns:
        A float32 array ``[E, 3]``.

    Raises:
        ValueError: If ``env.scene.env_origins`` is not ``[E, 3]``.
    """
    origins = _to_host(_scene(env).env_origins)
    if origins.ndim != 2 or origins.shape[1] != 3:
        raise ValueError(f"env_origins must be [E, 3], got {origins.shape}")
    return origins

poses(env: Any, assets: Sequence[str]) -> npt.NDArray[np.float32]

Reads body poses for the named assets as a host array.

Parameters:

Name Type Description Default
env Any

An Isaac Lab environment (or a gym wrapper around one).

required
assets Sequence[str]

Scene entity names, such as ["robot", "cube"]. Each must be an articulation, rigid object, or rigid object collection.

required

Returns:

Type Description
NDArray[float32]

A new float32 C-contiguous array [E, B, 7] of xyz followed by

NDArray[float32]

quaternion xyzw in the world frame (env origins included).

NDArray[float32]

Bodies are ordered by asset, then by each asset's body_names.

Raises:

Type Description
ValueError

If an asset's pose array has the wrong shape, or the assets disagree on the number of envs.

Source code in src/simscope/isaaclab.py
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
def poses(env: Any, assets: Sequence[str]) -> npt.NDArray[np.float32]:
    """Reads body poses for the named assets as a host array.

    Args:
        env: An Isaac Lab environment (or a gym wrapper around one).
        assets: Scene entity names, such as ``["robot", "cube"]``. Each must
            be an articulation, rigid object, or rigid object collection.

    Returns:
        A new float32 C-contiguous array ``[E, B, 7]`` of ``xyz`` followed by
        quaternion ``xyzw`` in the world frame (env origins included).
        Bodies are ordered by asset, then by each asset's ``body_names``.

    Raises:
        ValueError: If an asset's pose array has the wrong shape, or the
            assets disagree on the number of envs.
    """
    arrays = [_to_host(a) for a in _pose_sources(env, assets)]
    for array in arrays:
        _check_pose_shape(array.shape)
    n_envs = arrays[0].shape[0]
    n_bodies = sum(a.shape[1] for a in arrays)
    out = np.empty((n_envs, n_bodies, core.POSE_DIM), dtype=np.float32)
    start = 0
    for array in arrays:
        if array.shape[0] != n_envs:
            raise ValueError("assets have different numbers of envs")
        out[:, start : start + array.shape[1]] = array
        start += array.shape[1]
    return out

scene_from_env(env: Any, assets: Sequence[str], *, collision: bool = True) -> core.Scene

Builds a scene from the live USD stage of an Isaac Lab env.

For each asset, the body prims are those of env 0. An articulation's bodies are its links, named by asset.body_names; a rigid object is its root prim; a rigid object collection is one prim per object. Body paths come from the physics view's link_paths when it offers them, and otherwise from a search of the asset's root prim subtree. poses and the returned scene use the same body order (asset by asset, then each asset's own body_names order), so frame [e, b] is Scene.bodies[b] in env e. Each body is named by its prim path.

Parameters:

Name Type Description Default
env Any

An Isaac Lab environment (or a gym wrapper around one).

required
assets Sequence[str]

Scene entity names, in pose order.

required
collision bool

If false, keep only "visual" geoms.

True

Returns:

Type Description
Scene

The scene, extracted with scene_from_stage.

Raises:

Type Description
ValueError

If an asset's body prims cannot be located, or assets is empty.

Source code in src/simscope/isaaclab.py
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
def scene_from_env(
    env: Any, assets: Sequence[str], *, collision: bool = True
) -> core.Scene:
    """Builds a scene from the live USD stage of an Isaac Lab env.

    For each asset, the body prims are those of env 0. An articulation's
    bodies are its links, named by ``asset.body_names``; a rigid object is its
    root prim; a rigid object collection is one prim per object. Body paths
    come from the physics view's ``link_paths`` when it offers them, and
    otherwise from a search of the asset's root prim subtree. ``poses`` and
    the returned scene use the same body order (asset by asset, then each
    asset's own ``body_names`` order), so frame ``[e, b]`` is
    ``Scene.bodies[b]`` in env ``e``. Each body is named by its prim path.

    Args:
        env: An Isaac Lab environment (or a gym wrapper around one).
        assets: Scene entity names, in pose order.
        collision: If false, keep only ``"visual"`` geoms.

    Returns:
        The scene, extracted with ``scene_from_stage``.

    Raises:
        ValueError: If an asset's body prims cannot be located, or ``assets``
            is empty.
    """
    if not assets:
        raise ValueError("assets must not be empty")
    scene = _scene(env)
    stage = _stage(env)
    body_paths: list[str] = []
    for name in assets:
        body_paths.extend(_asset_body_paths(scene, stage, scene[name]))
    return scene_from_stage(stage, body_paths, collision=collision)

scene_from_stage(stage: Any, body_paths: Sequence[str], *, collision: bool = True) -> core.Scene

Builds a scene from bodies and geometry on a USD stage.

Bodies are the prims at body_paths, in that order, all with parent -1: the runtime supplies a world pose per body per frame, so the tree is not needed. Each body owns the geometry prims in its subtree, stopping at any nested prim that is itself in body_paths. Instance proxies are traversed.

Output is in metres: metersPerUnit is applied to sizes, vertices, and offsets. A geom's pose is stored relative to its body prim (the USD world transform with scale removed, which is what PhysX reports as the link pose), so the stage's up axis cancels out and needs no rotation: the pose stream fixes the world frame, and Isaac Lab stages are Z-up. A Y-up stage logs a warning, because its world poses would have to be rotated by the caller (+90 degrees about x) to be Z-up.

Geometry mapping:

  • Cube gives box; Sphere gives sphere (ellipsoid under non-uniform scale); Capsule gives capsule (uniform scale only); Cylinder gives cylinder (uniform radial scale). axis is honoured by rotating the shape onto local +z. Anything that a primitive cannot express (non-uniform capsule, Cone, tapered Cylinder_1, sheared transforms) is tessellated into a mesh.
  • Mesh gives mesh with polygons fan-triangulated. Non-uniform scale goes into Geom.scale. Per-face materials (GeomSubset), normals, and UVs are not exported.
  • Plane at least 100 m wide is an infinite plane; smaller ones become a two-triangle mesh.
  • Other types (points, curves, point instancers) are skipped with one warning.

Role rule: a geom is "collision" if its computed purpose is guide or proxy, or if it (or an ancestor up to the body) has UsdPhysics.CollisionAPI and its purpose is not render. Everything else is "visual". Refinement: if that would leave a body with no visual geom, its default-purpose collision geoms are promoted to "visual", because in Isaac Lab spawned primitives are usually both the only shape and the collider. Invisible prims are skipped.

Material rule: see _read_material. Bound UsdPreviewSurface values win, then MDL constants, then displayColor/displayOpacity. No textures are exported.

Meshes and materials are deduplicated by content.

Parameters:

Name Type Description Default
stage Any

A Usd.Stage.

required
body_paths Sequence[str]

Prim paths of the bodies, in pose order.

required
collision bool

If false, keep only "visual" geoms.

True

Returns:

Type Description
Scene

The scene. Body names are the prim paths, and geom names are paths

Scene

relative to their body.

Raises:

Type Description
ValueError

If body_paths is empty, has duplicates, or names a prim that does not exist.

Source code in src/simscope/isaaclab.py
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
def scene_from_stage(
    stage: Any, body_paths: Sequence[str], *, collision: bool = True
) -> core.Scene:
    """Builds a scene from bodies and geometry on a USD stage.

    Bodies are the prims at ``body_paths``, in that order, all with parent
    ``-1``: the runtime supplies a world pose per body per frame, so the tree
    is not needed. Each body owns the geometry prims in its subtree, stopping
    at any nested prim that is itself in ``body_paths``. Instance proxies are
    traversed.

    Output is in metres: ``metersPerUnit`` is applied to sizes, vertices,
    and offsets. A geom's pose is stored relative to its body prim (the USD
    world transform with scale removed, which is what PhysX reports as the
    link pose), so the stage's up axis cancels out and needs no rotation:
    the pose stream fixes the world frame, and Isaac Lab stages are Z-up. A
    Y-up stage logs a warning, because its world poses would have to be
    rotated by the caller (+90 degrees about x) to be Z-up.

    Geometry mapping:

    * ``Cube`` gives ``box``; ``Sphere`` gives ``sphere`` (``ellipsoid``
      under non-uniform scale); ``Capsule`` gives ``capsule`` (uniform scale
      only); ``Cylinder`` gives ``cylinder`` (uniform radial scale). ``axis``
      is honoured by rotating the shape onto local +z. Anything that a
      primitive cannot express (non-uniform capsule, ``Cone``, tapered
      ``Cylinder_1``, sheared transforms) is tessellated into a ``mesh``.
    * ``Mesh`` gives ``mesh`` with polygons fan-triangulated. Non-uniform
      scale goes into ``Geom.scale``. Per-face materials (``GeomSubset``),
      normals, and UVs are not exported.
    * ``Plane`` at least 100 m wide is an infinite ``plane``; smaller ones
      become a two-triangle mesh.
    * Other types (points, curves, point instancers) are skipped with one
      warning.

    Role rule: a geom is ``"collision"`` if its computed purpose is
    ``guide`` or ``proxy``, or if it (or an ancestor up to the body) has
    ``UsdPhysics.CollisionAPI`` and its purpose is not ``render``. Everything
    else is ``"visual"``. Refinement: if that would leave a body with no
    visual geom, its default-purpose collision geoms are promoted to
    ``"visual"``, because in Isaac Lab spawned primitives are usually both
    the only shape and the collider. Invisible prims are skipped.

    Material rule: see ``_read_material``. Bound ``UsdPreviewSurface`` values
    win, then MDL constants, then ``displayColor``/``displayOpacity``. No
    textures are exported.

    Meshes and materials are deduplicated by content.

    Args:
        stage: A ``Usd.Stage``.
        body_paths: Prim paths of the bodies, in pose order.
        collision: If false, keep only ``"visual"`` geoms.

    Returns:
        The scene. Body names are the prim paths, and geom names are paths
        relative to their body.

    Raises:
        ValueError: If ``body_paths`` is empty, has duplicates, or names a
            prim that does not exist.
    """
    paths = [str(p) for p in body_paths]
    if not paths:
        raise ValueError("body_paths must not be empty")
    if len(set(paths)) != len(paths):
        raise ValueError("body_paths must not contain duplicates")
    prims = [stage.GetPrimAtPath(p) for p in paths]
    missing = [p for p, prim in zip(paths, prims, strict=True) if not prim]
    if missing:
        raise ValueError(f"no such prims on the stage: {missing}")
    builder = _SceneBuilder(stage, set(paths), collision)
    for index, prim in enumerate(prims):
        builder.add_body(index, prim)
    return builder.finish(tuple(core.Body(name=p) for p in paths))

source_info() -> dict[str, str]

Describes the simulator for a rollout manifest.

Returns:

Type Description
dict[str, str]

{"simulator": "isaaclab", "version": <isaaclab version>}. The

dict[str, str]

version is "unknown" if the isaaclab package metadata cannot

dict[str, str]

be found.

Source code in src/simscope/isaaclab.py
61
62
63
64
65
66
67
68
69
70
71
72
73
def source_info() -> dict[str, str]:
    """Describes the simulator for a rollout manifest.

    Returns:
        ``{"simulator": "isaaclab", "version": <isaaclab version>}``. The
        version is ``"unknown"`` if the ``isaaclab`` package metadata cannot
        be found.
    """
    try:
        version = importlib.metadata.version("isaaclab")
    except importlib.metadata.PackageNotFoundError:
        version = "unknown"
    return {"simulator": "isaaclab", "version": version}

step_dt(env: Any) -> float

Returns the env step interval, which is the recorded frame dt.

Parameters:

Name Type Description Default
env Any

An Isaac Lab environment (or a gym wrapper around one).

required

Returns:

Type Description
float

env.step_dt in seconds (physics dt times decimation).

Source code in src/simscope/isaaclab.py
126
127
128
129
130
131
132
133
134
135
def step_dt(env: Any) -> float:
    """Returns the env step interval, which is the recorded frame ``dt``.

    Args:
        env: An Isaac Lab environment (or a gym wrapper around one).

    Returns:
        ``env.step_dt`` in seconds (physics dt times decimation).
    """
    return float(_unwrap(env).step_dt)

Curation and highlights

Marks, notes, ratings, labels, and groups (marks.group, and the ordered list in .simscope/groups.json) are in simscope.annotations. The detectors of simscope.highlights find peaks of the net contact force and of the centre-of-mass acceleration. highlights.register adds your own kind of marker. See Custom markers.

simscope.annotations

Curation sidecars, the event-type registry and run groups (annotations v1).

runs/<name>/annotations.json holds marks, notes, ratings, events and spatial annotations for one run. .simscope/event_types.json holds the library's event vocabulary and .simscope/groups.json the ordered list of run groups (a run belongs to at most one, marks.group). All are pretty-printed with sorted keys and written atomically, so git can diff and merge them (decision D8).

Every record carries a ULID id plus author, created and updated. Lists are sorted by id when written, so two people's appends merge cleanly; :func:merge is the programmatic equivalent.

MAX_GROUP_NAME = 64 module-attribute

Longest group name, in characters.

Annotations

The curation sidecar of one run, loaded into memory.

Mutators change the in-memory state and mark it dirty; :meth:save writes it. author arguments default to the OS user name.

Attributes:

Name Type Description
path

Location of annotations.json.

run_id

The run's id from rollout.json.

dt

Seconds per frame, used to snap events to frames.

n_frames

Frames in the run, used to clip snapped frames.

marks

Run-level marks.

notes list[Note]

Notes, sorted by id.

ratings list[Rating]

Ratings, sorted by id.

events list[Event]

Events, sorted by id.

spatial list[Spatial]

Spatial annotations, sorted by id.

extra dict[str, Any]

Unknown top-level fields read from disk.

id_mismatch

True if the file on disk belonged to another run id.

Source code in src/simscope/annotations.py
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
class Annotations:
    """The curation sidecar of one run, loaded into memory.

    Mutators change the in-memory state and mark it dirty; :meth:`save`
    writes it. ``author`` arguments default to the OS user name.

    Attributes:
        path: Location of ``annotations.json``.
        run_id: The run's id from ``rollout.json``.
        dt: Seconds per frame, used to snap events to frames.
        n_frames: Frames in the run, used to clip snapped frames.
        marks: Run-level marks.
        notes: Notes, sorted by id.
        ratings: Ratings, sorted by id.
        events: Events, sorted by id.
        spatial: Spatial annotations, sorted by id.
        extra: Unknown top-level fields read from disk.
        id_mismatch: True if the file on disk belonged to another run id.
    """

    def __init__(
        self,
        path: os.PathLike[str] | str,
        run_id: str,
        dt: float,
        n_frames: int,
    ) -> None:
        """Creates an empty annotation set (nothing is read or written).

        Args:
            path: Location of ``annotations.json``.
            run_id: The run's id.
            dt: Seconds per frame (positive).
            n_frames: Frames in the run.

        Raises:
            ValueError: If ``dt`` is not positive.
        """
        if not dt > 0:
            raise ValueError(f"dt must be positive, got {dt!r}")
        self.path = pathlib.Path(path)
        self.run_id = run_id
        self.dt = float(dt)
        self.n_frames = int(n_frames)
        self.marks = Marks()
        self.notes: list[Note] = []
        self.ratings: list[Rating] = []
        self.events: list[Event] = []
        self.spatial: list[Spatial] = []
        self.extra: dict[str, Any] = {}
        self.id_mismatch = False
        self._dirty = False

    # -- persistence --

    @classmethod
    def load(
        cls,
        run_dir: os.PathLike[str] | str,
        run_id: str,
        dt: float,
        n_frames: int,
    ) -> Self:
        """Loads ``annotations.json`` from a run directory.

        A missing file means "no annotations". If the file's ``run_id``
        differs from ``run_id`` a warning is logged and ``id_mismatch`` is
        set; the records are kept and ``save`` will write the current id.

        Args:
            run_dir: The run directory.
            run_id: The run's id.
            dt: Seconds per frame.
            n_frames: Frames in the run.

        Returns:
            The annotations.

        Raises:
            errors.FormatError: If the file exists but is invalid.
        """
        ann = cls(pathlib.Path(run_dir) / FILE_NAME, run_id, dt, n_frames)
        if ann.path.is_file():
            ann._fill(_read_json(ann.path))
        return ann

    def _fill(self, obj: dict[str, Any]) -> None:
        """Populates this object from a parsed file."""
        _check_format(obj, "simscope-annotations", self.path)
        file_id = obj.get("run_id")
        if file_id not in (None, self.run_id):
            logger.warning(
                "%s belongs to run %s, not %s", self.path, file_id, self.run_id
            )
            self.id_mismatch = True
        marks = obj.get("marks")
        self.marks = (
            Marks.from_json(marks) if isinstance(marks, dict) else Marks()
        )
        for key, rec_type in _LIST_TYPES.items():
            items = obj.get(key) or []
            if not isinstance(items, list):
                raise errors.FormatError(f"{self.path}: {key!r} must be a list")
            records = [rec_type.from_json(o) for o in items]
            records.sort(key=lambda r: r.id)
            getattr(self, key)[:] = records
        known = {"format", "run_id", "marks", *_LIST_TYPES}
        self.extra = {k: v for k, v in obj.items() if k not in known}

    def to_json(self) -> dict[str, Any]:
        """Returns the file contents, lists sorted by id."""
        out = copy.deepcopy(self.extra)
        out["format"] = ANNOTATIONS_FORMAT
        out["run_id"] = self.run_id
        out["marks"] = self.marks.to_json()
        for key in _LIST_TYPES:
            records = sorted(getattr(self, key), key=lambda r: r.id)
            out[key] = [r.to_json() for r in records]
        return out

    @property
    def dirty(self) -> bool:
        """True if there are changes that ``save`` has not written."""
        return self._dirty

    def save(self) -> bool:
        """Writes ``annotations.json`` atomically.

        Nothing is written if there are no changes and no file exists yet,
        because writers create the file on the first change.

        Returns:
            True if the file was written.
        """
        if not self._dirty and not self.path.exists():
            return False
        cas.atomic_write(self.path, _dump(self.to_json()))
        self._dirty = False
        self.id_mismatch = False
        return True

    # -- helpers --

    def _touch(self) -> str:
        """Marks the state dirty and returns the current timestamp."""
        self._dirty = True
        return utc_now()

    def _new(
        self, rec_type: type[Record], author: str | None, **fields: Any
    ) -> Any:
        """Builds a record with a fresh id and timestamps and appends it."""
        now = self._touch()
        rec = rec_type(
            id=manifest.new_ulid(),
            author=author or default_author(),
            created=now,
            updated=now,
            **fields,
        )
        for key, cls in _LIST_TYPES.items():
            if cls is rec_type:
                getattr(self, key).append(rec)
        return rec

    def snap(self, t: float) -> int:
        """Returns the frame nearest to ``t`` seconds, clipped to the run.

        Uses ``floor(t / dt + 0.5)`` (round half up, as JavaScript's
        ``Math.round``) so the Python and player sides agree.

        Args:
            t: Time in seconds.

        Returns:
            A frame index in ``[0, max(n_frames - 1, 0)]``.
        """
        f = math.floor(_finite("t", t) / self.dt + 0.5)
        return int(min(max(f, 0), max(self.n_frames - 1, 0)))

    def get(self, record_id: str) -> Any:
        """Returns the record with this id.

        Raises:
            KeyError: If there is none.
        """
        for key in _LIST_TYPES:
            for rec in getattr(self, key):
                if rec.id == record_id:
                    return rec
        raise KeyError(record_id)

    def remove(self, record_id: str) -> bool:
        """Removes the record with this id.

        Spatial annotations linked to a removed event keep their ``event``
        field; readers treat a dangling link as unlinked.

        Args:
            record_id: A note, rating, event or spatial id.

        Returns:
            True if a record was removed.
        """
        for key in _LIST_TYPES:
            records = getattr(self, key)
            for i, rec in enumerate(records):
                if rec.id == record_id:
                    del records[i]
                    self._dirty = True
                    return True
        return False

    # -- marks --

    def set_favorite(self, value: bool = True) -> None:
        """Sets or clears the favorite mark."""
        self.marks.favorite = bool(value)
        self._dirty = True

    def set_group(self, group: str | None) -> None:
        """Moves the run into a group, or out of every group with ``None``.

        Args:
            group: The group's name (see :func:`check_group_name`), or
                ``None``.

        Raises:
            ValueError: If the name is invalid.
        """
        self.marks.group = None if group is None else check_group_name(group)
        self._dirty = True

    # -- notes --

    def add_note(self, text: str, *, author: str | None = None) -> Note:
        """Adds a Markdown note.

        Args:
            text: The note body.
            author: Defaults to the OS user name.

        Returns:
            The new note.
        """
        return self._new(Note, author, text=text)

    def update_note(self, note_id: str, text: str) -> Note:
        """Replaces a note's text.

        Raises:
            KeyError: If there is no note with this id.
        """
        for note in self.notes:
            if note.id == note_id:
                note.text = text
                note.updated = self._touch()
                return note
        raise KeyError(note_id)

    # -- ratings --

    def rate(
        self,
        value: float,
        *,
        criterion: str = DEFAULT_CRITERION,
        scale: RatingScale = "stars5",
        rationale: str | None = None,
        author: str | None = None,
    ) -> Rating:
        """Rates the run, replacing the author's rating for the criterion.

        A replacement keeps the old ``id`` and ``created``.

        Args:
            value: A number on ``scale``: 1-5 for ``stars5``, 0-100 for
                ``score100``, -1 or 1 for ``thumb``.
            criterion: What is rated.
            scale: The scale of ``value``.
            rationale: Optional explanation.
            author: Defaults to the OS user name.

        Returns:
            The new or updated rating.

        Raises:
            ValueError: If the scale or value is invalid.
        """
        if scale not in _SCALE_RANGES:
            raise ValueError(f"unknown rating scale {scale!r}")
        value = _finite("value", value)
        lo, hi = _SCALE_RANGES[scale]
        if (
            not lo <= value <= hi
            or (scale == "stars5" and value % 1)
            or (scale == "thumb" and value not in (-1, 1))
        ):
            raise ValueError(f"value {value!r} is invalid for scale {scale!r}")
        num: float = int(value) if value == int(value) else value
        who = author or default_author()
        for rating in self.ratings:
            if rating.author == who and rating.criterion == criterion:
                rating.scale, rating.value = scale, num
                rating.rationale = rationale
                rating.updated = self._touch()
                return rating
        return self._new(
            Rating,
            who,
            criterion=criterion,
            scale=scale,
            value=num,
            rationale=rationale,
        )

    # -- events --

    def _event_times(self, t0: float, t1: float | None) -> tuple[float, float]:
        """Validates ``t0 <= t1`` and defaults ``t1`` to ``t0``."""
        a = _finite("t0", t0)
        b = a if t1 is None else _finite("t1", t1)
        if a > b:
            raise ValueError(f"event needs t0 <= t1, got {a} > {b}")
        return a, b

    def add_event(
        self,
        type: str = "",  # noqa: A002
        *,
        t0: float,
        t1: float | None = None,
        label: str = "",
        env: int | None = None,
        props: dict[str, Any] | None = None,
        author: str | None = None,
    ) -> Event:
        """Adds an event, snapping ``f0`` and ``f1`` to the nearest frames.

        Args:
            type: Key into the event-type registry, or ``""``.
            t0: Start in seconds.
            t1: End in seconds; defaults to ``t0`` (an instant).
            label: Short display string.
            env: Env index, or ``None`` for all envs.
            props: Typed property values.
            author: Defaults to the OS user name.

        Returns:
            The new event.

        Raises:
            ValueError: If ``t0 > t1`` or a time is not finite.
        """
        a, b = self._event_times(t0, t1)
        return self._new(
            Event,
            author,
            type=type,
            label=label,
            t0=a,
            t1=b,
            f0=self.snap(a),
            f1=self.snap(b),
            env=env,
            props=dict(props or {}),
        )

    def update_event(self, event_id: str, **changes: Any) -> Event:
        """Changes fields of an event and re-snaps its frames.

        Args:
            event_id: The event's id.
            **changes: Any of ``type``, ``label``, ``t0``, ``t1``, ``env``
                and ``props``.

        Returns:
            The updated event.

        Raises:
            KeyError: If there is no event with this id.
            ValueError: If a field is unknown or the times are invalid.
        """
        allowed = {"type", "label", "t0", "t1", "env", "props"}
        bad = set(changes) - allowed
        if bad:
            raise ValueError(f"cannot update event fields {sorted(bad)}")
        for ev in self.events:
            if ev.id != event_id:
                continue
            a, b = self._event_times(
                changes.get("t0", ev.t0), changes.get("t1", ev.t1)
            )
            for name in ("type", "label", "env"):
                if name in changes:
                    setattr(ev, name, changes[name])
            if "props" in changes:
                ev.props = dict(changes["props"])
            ev.t0, ev.t1 = a, b
            ev.f0, ev.f1 = self.snap(a), self.snap(b)
            ev.updated = self._touch()
            return ev
        raise KeyError(event_id)

    # -- spatial --

    def add_spatial(
        self,
        kind: str,
        position: tuple[float, float, float] | list[float],
        *,
        t: float,
        env: int = 0,
        frame: str = "world",
        size: tuple[float, float, float] | list[float] | None = None,
        label: str = "",
        event: str | None = None,
        author: str | None = None,
    ) -> Spatial:
        """Adds a 3D point or box, snapping ``f`` to the nearest frame.

        Args:
            kind: ``"point"`` or ``"box"``.
            position: ``[x, y, z]`` in metres.
            t: Time in seconds.
            env: Env index.
            frame: ``"world"`` or ``"body:<index>"``.
            size: Half-extents of a box (required for ``"box"``).
            label: Short display string.
            event: Optional id of a linked event.
            author: Defaults to the OS user name.

        Returns:
            The new annotation.

        Raises:
            ValueError: If an argument is invalid.
        """
        if kind not in SPATIAL_KINDS:
            raise ValueError(f"spatial kind must be one of {SPATIAL_KINDS}")
        if not _FRAME_RE.match(frame):
            raise ValueError(f"frame must be 'world' or 'body:<i>': {frame!r}")
        pos = [_finite("position", v) for v in position]
        if len(pos) != 3:
            raise ValueError("position needs 3 components")
        dims = None
        if kind == "box":
            if size is None:
                raise ValueError("a box needs a size")
            dims = [_finite("size", v) for v in size]
            if len(dims) != 3:
                raise ValueError("size needs 3 components")
        elif size is not None:
            raise ValueError("a point has no size")
        t = _finite("t", t)
        return self._new(
            Spatial,
            author,
            kind=kind,
            frame=frame,
            t=t,
            f=self.snap(t),
            env=int(env),
            position=pos,
            size=dims,
            label=label,
            event=event,
        )

dirty: bool property

True if there are changes that save has not written.

__init__(path: os.PathLike[str] | str, run_id: str, dt: float, n_frames: int) -> None

Creates an empty annotation set (nothing is read or written).

Parameters:

Name Type Description Default
path PathLike[str] | str

Location of annotations.json.

required
run_id str

The run's id.

required
dt float

Seconds per frame (positive).

required
n_frames int

Frames in the run.

required

Raises:

Type Description
ValueError

If dt is not positive.

Source code in src/simscope/annotations.py
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
def __init__(
    self,
    path: os.PathLike[str] | str,
    run_id: str,
    dt: float,
    n_frames: int,
) -> None:
    """Creates an empty annotation set (nothing is read or written).

    Args:
        path: Location of ``annotations.json``.
        run_id: The run's id.
        dt: Seconds per frame (positive).
        n_frames: Frames in the run.

    Raises:
        ValueError: If ``dt`` is not positive.
    """
    if not dt > 0:
        raise ValueError(f"dt must be positive, got {dt!r}")
    self.path = pathlib.Path(path)
    self.run_id = run_id
    self.dt = float(dt)
    self.n_frames = int(n_frames)
    self.marks = Marks()
    self.notes: list[Note] = []
    self.ratings: list[Rating] = []
    self.events: list[Event] = []
    self.spatial: list[Spatial] = []
    self.extra: dict[str, Any] = {}
    self.id_mismatch = False
    self._dirty = False

add_event(type: str = '', *, t0: float, t1: float | None = None, label: str = '', env: int | None = None, props: dict[str, Any] | None = None, author: str | None = None) -> Event

Adds an event, snapping f0 and f1 to the nearest frames.

Parameters:

Name Type Description Default
type str

Key into the event-type registry, or "".

''
t0 float

Start in seconds.

required
t1 float | None

End in seconds; defaults to t0 (an instant).

None
label str

Short display string.

''
env int | None

Env index, or None for all envs.

None
props dict[str, Any] | None

Typed property values.

None
author str | None

Defaults to the OS user name.

None

Returns:

Type Description
Event

The new event.

Raises:

Type Description
ValueError

If t0 > t1 or a time is not finite.

Source code in src/simscope/annotations.py
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
def add_event(
    self,
    type: str = "",  # noqa: A002
    *,
    t0: float,
    t1: float | None = None,
    label: str = "",
    env: int | None = None,
    props: dict[str, Any] | None = None,
    author: str | None = None,
) -> Event:
    """Adds an event, snapping ``f0`` and ``f1`` to the nearest frames.

    Args:
        type: Key into the event-type registry, or ``""``.
        t0: Start in seconds.
        t1: End in seconds; defaults to ``t0`` (an instant).
        label: Short display string.
        env: Env index, or ``None`` for all envs.
        props: Typed property values.
        author: Defaults to the OS user name.

    Returns:
        The new event.

    Raises:
        ValueError: If ``t0 > t1`` or a time is not finite.
    """
    a, b = self._event_times(t0, t1)
    return self._new(
        Event,
        author,
        type=type,
        label=label,
        t0=a,
        t1=b,
        f0=self.snap(a),
        f1=self.snap(b),
        env=env,
        props=dict(props or {}),
    )

add_note(text: str, *, author: str | None = None) -> Note

Adds a Markdown note.

Parameters:

Name Type Description Default
text str

The note body.

required
author str | None

Defaults to the OS user name.

None

Returns:

Type Description
Note

The new note.

Source code in src/simscope/annotations.py
797
798
799
800
801
802
803
804
805
806
807
def add_note(self, text: str, *, author: str | None = None) -> Note:
    """Adds a Markdown note.

    Args:
        text: The note body.
        author: Defaults to the OS user name.

    Returns:
        The new note.
    """
    return self._new(Note, author, text=text)

add_spatial(kind: str, position: tuple[float, float, float] | list[float], *, t: float, env: int = 0, frame: str = 'world', size: tuple[float, float, float] | list[float] | None = None, label: str = '', event: str | None = None, author: str | None = None) -> Spatial

Adds a 3D point or box, snapping f to the nearest frame.

Parameters:

Name Type Description Default
kind str

"point" or "box".

required
position tuple[float, float, float] | list[float]

[x, y, z] in metres.

required
t float

Time in seconds.

required
env int

Env index.

0
frame str

"world" or "body:<index>".

'world'
size tuple[float, float, float] | list[float] | None

Half-extents of a box (required for "box").

None
label str

Short display string.

''
event str | None

Optional id of a linked event.

None
author str | None

Defaults to the OS user name.

None

Returns:

Type Description
Spatial

The new annotation.

Raises:

Type Description
ValueError

If an argument is invalid.

Source code in src/simscope/annotations.py
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
def add_spatial(
    self,
    kind: str,
    position: tuple[float, float, float] | list[float],
    *,
    t: float,
    env: int = 0,
    frame: str = "world",
    size: tuple[float, float, float] | list[float] | None = None,
    label: str = "",
    event: str | None = None,
    author: str | None = None,
) -> Spatial:
    """Adds a 3D point or box, snapping ``f`` to the nearest frame.

    Args:
        kind: ``"point"`` or ``"box"``.
        position: ``[x, y, z]`` in metres.
        t: Time in seconds.
        env: Env index.
        frame: ``"world"`` or ``"body:<index>"``.
        size: Half-extents of a box (required for ``"box"``).
        label: Short display string.
        event: Optional id of a linked event.
        author: Defaults to the OS user name.

    Returns:
        The new annotation.

    Raises:
        ValueError: If an argument is invalid.
    """
    if kind not in SPATIAL_KINDS:
        raise ValueError(f"spatial kind must be one of {SPATIAL_KINDS}")
    if not _FRAME_RE.match(frame):
        raise ValueError(f"frame must be 'world' or 'body:<i>': {frame!r}")
    pos = [_finite("position", v) for v in position]
    if len(pos) != 3:
        raise ValueError("position needs 3 components")
    dims = None
    if kind == "box":
        if size is None:
            raise ValueError("a box needs a size")
        dims = [_finite("size", v) for v in size]
        if len(dims) != 3:
            raise ValueError("size needs 3 components")
    elif size is not None:
        raise ValueError("a point has no size")
    t = _finite("t", t)
    return self._new(
        Spatial,
        author,
        kind=kind,
        frame=frame,
        t=t,
        f=self.snap(t),
        env=int(env),
        position=pos,
        size=dims,
        label=label,
        event=event,
    )

get(record_id: str) -> Any

Returns the record with this id.

Raises:

Type Description
KeyError

If there is none.

Source code in src/simscope/annotations.py
742
743
744
745
746
747
748
749
750
751
752
def get(self, record_id: str) -> Any:
    """Returns the record with this id.

    Raises:
        KeyError: If there is none.
    """
    for key in _LIST_TYPES:
        for rec in getattr(self, key):
            if rec.id == record_id:
                return rec
    raise KeyError(record_id)

load(run_dir: os.PathLike[str] | str, run_id: str, dt: float, n_frames: int) -> Self classmethod

Loads annotations.json from a run directory.

A missing file means "no annotations". If the file's run_id differs from run_id a warning is logged and id_mismatch is set; the records are kept and save will write the current id.

Parameters:

Name Type Description Default
run_dir PathLike[str] | str

The run directory.

required
run_id str

The run's id.

required
dt float

Seconds per frame.

required
n_frames int

Frames in the run.

required

Returns:

Type Description
Self

The annotations.

Raises:

Type Description
FormatError

If the file exists but is invalid.

Source code in src/simscope/annotations.py
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
@classmethod
def load(
    cls,
    run_dir: os.PathLike[str] | str,
    run_id: str,
    dt: float,
    n_frames: int,
) -> Self:
    """Loads ``annotations.json`` from a run directory.

    A missing file means "no annotations". If the file's ``run_id``
    differs from ``run_id`` a warning is logged and ``id_mismatch`` is
    set; the records are kept and ``save`` will write the current id.

    Args:
        run_dir: The run directory.
        run_id: The run's id.
        dt: Seconds per frame.
        n_frames: Frames in the run.

    Returns:
        The annotations.

    Raises:
        errors.FormatError: If the file exists but is invalid.
    """
    ann = cls(pathlib.Path(run_dir) / FILE_NAME, run_id, dt, n_frames)
    if ann.path.is_file():
        ann._fill(_read_json(ann.path))
    return ann

rate(value: float, *, criterion: str = DEFAULT_CRITERION, scale: RatingScale = 'stars5', rationale: str | None = None, author: str | None = None) -> Rating

Rates the run, replacing the author's rating for the criterion.

A replacement keeps the old id and created.

Parameters:

Name Type Description Default
value float

A number on scale: 1-5 for stars5, 0-100 for score100, -1 or 1 for thumb.

required
criterion str

What is rated.

DEFAULT_CRITERION
scale RatingScale

The scale of value.

'stars5'
rationale str | None

Optional explanation.

None
author str | None

Defaults to the OS user name.

None

Returns:

Type Description
Rating

The new or updated rating.

Raises:

Type Description
ValueError

If the scale or value is invalid.

Source code in src/simscope/annotations.py
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
def rate(
    self,
    value: float,
    *,
    criterion: str = DEFAULT_CRITERION,
    scale: RatingScale = "stars5",
    rationale: str | None = None,
    author: str | None = None,
) -> Rating:
    """Rates the run, replacing the author's rating for the criterion.

    A replacement keeps the old ``id`` and ``created``.

    Args:
        value: A number on ``scale``: 1-5 for ``stars5``, 0-100 for
            ``score100``, -1 or 1 for ``thumb``.
        criterion: What is rated.
        scale: The scale of ``value``.
        rationale: Optional explanation.
        author: Defaults to the OS user name.

    Returns:
        The new or updated rating.

    Raises:
        ValueError: If the scale or value is invalid.
    """
    if scale not in _SCALE_RANGES:
        raise ValueError(f"unknown rating scale {scale!r}")
    value = _finite("value", value)
    lo, hi = _SCALE_RANGES[scale]
    if (
        not lo <= value <= hi
        or (scale == "stars5" and value % 1)
        or (scale == "thumb" and value not in (-1, 1))
    ):
        raise ValueError(f"value {value!r} is invalid for scale {scale!r}")
    num: float = int(value) if value == int(value) else value
    who = author or default_author()
    for rating in self.ratings:
        if rating.author == who and rating.criterion == criterion:
            rating.scale, rating.value = scale, num
            rating.rationale = rationale
            rating.updated = self._touch()
            return rating
    return self._new(
        Rating,
        who,
        criterion=criterion,
        scale=scale,
        value=num,
        rationale=rationale,
    )

remove(record_id: str) -> bool

Removes the record with this id.

Spatial annotations linked to a removed event keep their event field; readers treat a dangling link as unlinked.

Parameters:

Name Type Description Default
record_id str

A note, rating, event or spatial id.

required

Returns:

Type Description
bool

True if a record was removed.

Source code in src/simscope/annotations.py
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
def remove(self, record_id: str) -> bool:
    """Removes the record with this id.

    Spatial annotations linked to a removed event keep their ``event``
    field; readers treat a dangling link as unlinked.

    Args:
        record_id: A note, rating, event or spatial id.

    Returns:
        True if a record was removed.
    """
    for key in _LIST_TYPES:
        records = getattr(self, key)
        for i, rec in enumerate(records):
            if rec.id == record_id:
                del records[i]
                self._dirty = True
                return True
    return False

save() -> bool

Writes annotations.json atomically.

Nothing is written if there are no changes and no file exists yet, because writers create the file on the first change.

Returns:

Type Description
bool

True if the file was written.

Source code in src/simscope/annotations.py
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
def save(self) -> bool:
    """Writes ``annotations.json`` atomically.

    Nothing is written if there are no changes and no file exists yet,
    because writers create the file on the first change.

    Returns:
        True if the file was written.
    """
    if not self._dirty and not self.path.exists():
        return False
    cas.atomic_write(self.path, _dump(self.to_json()))
    self._dirty = False
    self.id_mismatch = False
    return True

set_favorite(value: bool = True) -> None

Sets or clears the favorite mark.

Source code in src/simscope/annotations.py
777
778
779
780
def set_favorite(self, value: bool = True) -> None:
    """Sets or clears the favorite mark."""
    self.marks.favorite = bool(value)
    self._dirty = True

set_group(group: str | None) -> None

Moves the run into a group, or out of every group with None.

Parameters:

Name Type Description Default
group str | None

The group's name (see :func:check_group_name), or None.

required

Raises:

Type Description
ValueError

If the name is invalid.

Source code in src/simscope/annotations.py
782
783
784
785
786
787
788
789
790
791
792
793
def set_group(self, group: str | None) -> None:
    """Moves the run into a group, or out of every group with ``None``.

    Args:
        group: The group's name (see :func:`check_group_name`), or
            ``None``.

    Raises:
        ValueError: If the name is invalid.
    """
    self.marks.group = None if group is None else check_group_name(group)
    self._dirty = True

snap(t: float) -> int

Returns the frame nearest to t seconds, clipped to the run.

Uses floor(t / dt + 0.5) (round half up, as JavaScript's Math.round) so the Python and player sides agree.

Parameters:

Name Type Description Default
t float

Time in seconds.

required

Returns:

Type Description
int

A frame index in [0, max(n_frames - 1, 0)].

Source code in src/simscope/annotations.py
727
728
729
730
731
732
733
734
735
736
737
738
739
740
def snap(self, t: float) -> int:
    """Returns the frame nearest to ``t`` seconds, clipped to the run.

    Uses ``floor(t / dt + 0.5)`` (round half up, as JavaScript's
    ``Math.round``) so the Python and player sides agree.

    Args:
        t: Time in seconds.

    Returns:
        A frame index in ``[0, max(n_frames - 1, 0)]``.
    """
    f = math.floor(_finite("t", t) / self.dt + 0.5)
    return int(min(max(f, 0), max(self.n_frames - 1, 0)))

to_json() -> dict[str, Any]

Returns the file contents, lists sorted by id.

Source code in src/simscope/annotations.py
671
672
673
674
675
676
677
678
679
680
def to_json(self) -> dict[str, Any]:
    """Returns the file contents, lists sorted by id."""
    out = copy.deepcopy(self.extra)
    out["format"] = ANNOTATIONS_FORMAT
    out["run_id"] = self.run_id
    out["marks"] = self.marks.to_json()
    for key in _LIST_TYPES:
        records = sorted(getattr(self, key), key=lambda r: r.id)
        out[key] = [r.to_json() for r in records]
    return out

update_event(event_id: str, **changes: Any) -> Event

Changes fields of an event and re-snaps its frames.

Parameters:

Name Type Description Default
event_id str

The event's id.

required
**changes Any

Any of type, label, t0, t1, env and props.

{}

Returns:

Type Description
Event

The updated event.

Raises:

Type Description
KeyError

If there is no event with this id.

ValueError

If a field is unknown or the times are invalid.

Source code in src/simscope/annotations.py
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
def update_event(self, event_id: str, **changes: Any) -> Event:
    """Changes fields of an event and re-snaps its frames.

    Args:
        event_id: The event's id.
        **changes: Any of ``type``, ``label``, ``t0``, ``t1``, ``env``
            and ``props``.

    Returns:
        The updated event.

    Raises:
        KeyError: If there is no event with this id.
        ValueError: If a field is unknown or the times are invalid.
    """
    allowed = {"type", "label", "t0", "t1", "env", "props"}
    bad = set(changes) - allowed
    if bad:
        raise ValueError(f"cannot update event fields {sorted(bad)}")
    for ev in self.events:
        if ev.id != event_id:
            continue
        a, b = self._event_times(
            changes.get("t0", ev.t0), changes.get("t1", ev.t1)
        )
        for name in ("type", "label", "env"):
            if name in changes:
                setattr(ev, name, changes[name])
        if "props" in changes:
            ev.props = dict(changes["props"])
        ev.t0, ev.t1 = a, b
        ev.f0, ev.f1 = self.snap(a), self.snap(b)
        ev.updated = self._touch()
        return ev
    raise KeyError(event_id)

update_note(note_id: str, text: str) -> Note

Replaces a note's text.

Raises:

Type Description
KeyError

If there is no note with this id.

Source code in src/simscope/annotations.py
809
810
811
812
813
814
815
816
817
818
819
820
def update_note(self, note_id: str, text: str) -> Note:
    """Replaces a note's text.

    Raises:
        KeyError: If there is no note with this id.
    """
    for note in self.notes:
        if note.id == note_id:
            note.text = text
            note.updated = self._touch()
            return note
    raise KeyError(note_id)

Event dataclass

Bases: Record

A time instant or segment on the timeline.

Attributes:

Name Type Description
type str

Key into the event-type registry, or "" if untyped.

label str

Short display string.

t0 float

Start in seconds.

t1 float

End in seconds (t0 == t1 for an instant).

f0 int

Frame nearest t0.

f1 int

Frame nearest t1.

env int | None

Env index, or None for all envs.

props dict[str, Any]

Typed property values.

Source code in src/simscope/annotations.py
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
@dataclasses.dataclass(kw_only=True)
class Event(Record):
    """A time instant or segment on the timeline.

    Attributes:
        type: Key into the event-type registry, or ``""`` if untyped.
        label: Short display string.
        t0: Start in seconds.
        t1: End in seconds (``t0 == t1`` for an instant).
        f0: Frame nearest ``t0``.
        f1: Frame nearest ``t1``.
        env: Env index, or ``None`` for all envs.
        props: Typed property values.
    """

    type: str = ""
    label: str = ""
    t0: float = 0.0
    t1: float = 0.0
    f0: int = 0
    f1: int = 0
    env: int | None = None
    props: dict[str, Any] = dataclasses.field(default_factory=dict)

EventType dataclass

Bases: _JsonRecord

One entry of the event vocabulary.

Attributes:

Name Type Description
type_id str

The key the entry is stored under, such as "fall".

name str

Display name.

color str

#rrggbb hex color.

key str | None

Optional single-character hotkey.

props dict[str, dict[str, Any]]

Property specs by name; each has a type of "text", "number", "bool" or "select" (with options).

extra dict[str, Any]

Unknown fields read from disk.

Source code in src/simscope/annotations.py
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
@dataclasses.dataclass(kw_only=True)
class EventType(_JsonRecord):
    """One entry of the event vocabulary.

    Attributes:
        type_id: The key the entry is stored under, such as ``"fall"``.
        name: Display name.
        color: ``#rrggbb`` hex color.
        key: Optional single-character hotkey.
        props: Property specs by name; each has a ``type`` of ``"text"``,
            ``"number"``, ``"bool"`` or ``"select"`` (with ``options``).
        extra: Unknown fields read from disk.
    """

    type_id: str
    name: str
    color: str = "#888888"
    key: str | None = None
    props: dict[str, dict[str, Any]] = dataclasses.field(default_factory=dict)
    extra: dict[str, Any] = dataclasses.field(default_factory=dict)

    def __post_init__(self) -> None:
        """Validates color, hotkey and property specs.

        Raises:
            ValueError: If a field is invalid.
        """
        if not _COLOR_RE.match(self.color):
            raise ValueError(f"event type {self.type_id!r}: bad color")
        if self.key is not None and len(self.key) != 1:
            raise ValueError(f"event type {self.type_id!r}: key must be 1 char")
        for pname, spec in self.props.items():
            ptype = spec.get("type")
            if ptype not in PROP_TYPES:
                raise ValueError(
                    f"event type {self.type_id!r} prop {pname!r}: "
                    f"unknown type {ptype!r}"
                )
            if ptype == "select" and not isinstance(spec.get("options"), list):
                raise ValueError(
                    f"event type {self.type_id!r} prop {pname!r}: "
                    "select needs options"
                )

    def to_json(self) -> dict[str, Any]:
        """Returns the JSON object (without ``type_id``)."""
        out = copy.deepcopy(self.extra)
        out.update(
            name=self.name,
            color=self.color,
            key=self.key,
            props=copy.deepcopy(self.props),
        )
        return out

    @classmethod
    def from_json(cls, obj: dict[str, Any], type_id: str = "") -> Self:
        """Parses one entry.

        Args:
            obj: The entry's JSON object.
            type_id: The key it was stored under.

        Raises:
            errors.FormatError: If the entry is invalid.
        """
        known = {"name", "color", "key", "props"}
        try:
            return cls(
                type_id=type_id,
                name=str(obj.get("name", type_id)),
                color=str(obj.get("color", "#888888")),
                key=obj.get("key"),
                props=dict(obj.get("props") or {}),
                extra={k: v for k, v in obj.items() if k not in known},
            )
        except (TypeError, ValueError, AttributeError) as exc:
            raise errors.FormatError(
                f"bad event type {type_id!r}: {exc}"
            ) from exc

__post_init__() -> None

Validates color, hotkey and property specs.

Raises:

Type Description
ValueError

If a field is invalid.

Source code in src/simscope/annotations.py
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
def __post_init__(self) -> None:
    """Validates color, hotkey and property specs.

    Raises:
        ValueError: If a field is invalid.
    """
    if not _COLOR_RE.match(self.color):
        raise ValueError(f"event type {self.type_id!r}: bad color")
    if self.key is not None and len(self.key) != 1:
        raise ValueError(f"event type {self.type_id!r}: key must be 1 char")
    for pname, spec in self.props.items():
        ptype = spec.get("type")
        if ptype not in PROP_TYPES:
            raise ValueError(
                f"event type {self.type_id!r} prop {pname!r}: "
                f"unknown type {ptype!r}"
            )
        if ptype == "select" and not isinstance(spec.get("options"), list):
            raise ValueError(
                f"event type {self.type_id!r} prop {pname!r}: "
                "select needs options"
            )

from_json(obj: dict[str, Any], type_id: str = '') -> Self classmethod

Parses one entry.

Parameters:

Name Type Description Default
obj dict[str, Any]

The entry's JSON object.

required
type_id str

The key it was stored under.

''

Raises:

Type Description
FormatError

If the entry is invalid.

Source code in src/simscope/annotations.py
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
@classmethod
def from_json(cls, obj: dict[str, Any], type_id: str = "") -> Self:
    """Parses one entry.

    Args:
        obj: The entry's JSON object.
        type_id: The key it was stored under.

    Raises:
        errors.FormatError: If the entry is invalid.
    """
    known = {"name", "color", "key", "props"}
    try:
        return cls(
            type_id=type_id,
            name=str(obj.get("name", type_id)),
            color=str(obj.get("color", "#888888")),
            key=obj.get("key"),
            props=dict(obj.get("props") or {}),
            extra={k: v for k, v in obj.items() if k not in known},
        )
    except (TypeError, ValueError, AttributeError) as exc:
        raise errors.FormatError(
            f"bad event type {type_id!r}: {exc}"
        ) from exc

to_json() -> dict[str, Any]

Returns the JSON object (without type_id).

Source code in src/simscope/annotations.py
315
316
317
318
319
320
321
322
323
324
def to_json(self) -> dict[str, Any]:
    """Returns the JSON object (without ``type_id``)."""
    out = copy.deepcopy(self.extra)
    out.update(
        name=self.name,
        color=self.color,
        key=self.key,
        props=copy.deepcopy(self.props),
    )
    return out

Group dataclass

Bases: _JsonRecord

One entry of .simscope/groups.json.

Attributes:

Name Type Description
name str

The group's name, unique case-insensitively.

created str

RFC 3339 UTC creation time.

extra dict[str, Any]

Unknown fields read from disk.

Source code in src/simscope/annotations.py
459
460
461
462
463
464
465
466
467
468
469
470
471
@dataclasses.dataclass(kw_only=True)
class Group(_JsonRecord):
    """One entry of ``.simscope/groups.json``.

    Attributes:
        name: The group's name, unique case-insensitively.
        created: RFC 3339 UTC creation time.
        extra: Unknown fields read from disk.
    """

    name: str
    created: str = ""
    extra: dict[str, Any] = dataclasses.field(default_factory=dict)

Marks dataclass

Bases: _JsonRecord

Run-level marks. The last write wins.

flag, status and tags are deprecated since viewer v3.1 (decision D23): they are read and written back unchanged so that no data is lost, but nothing sets them and the viewer does not show them.

Attributes:

Name Type Description
favorite bool

Whether the run is starred.

group str | None

Name of the group the run belongs to, or None.

flag str | None

Deprecated. Free-text flag such as "review", or None.

status str | None

Deprecated. Free-text status, or None.

tags list[str]

Deprecated. Curation tags, stored sorted and unique.

extra dict[str, Any]

Unknown fields read from disk.

Source code in src/simscope/annotations.py
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
@dataclasses.dataclass(kw_only=True)
class Marks(_JsonRecord):
    """Run-level marks. The last write wins.

    ``flag``, ``status`` and ``tags`` are deprecated since viewer v3.1
    (decision D23): they are read and written back unchanged so that no data
    is lost, but nothing sets them and the viewer does not show them.

    Attributes:
        favorite: Whether the run is starred.
        group: Name of the group the run belongs to, or ``None``.
        flag: Deprecated. Free-text flag such as ``"review"``, or ``None``.
        status: Deprecated. Free-text status, or ``None``.
        tags: Deprecated. Curation tags, stored sorted and unique.
        extra: Unknown fields read from disk.
    """

    favorite: bool = False
    group: str | None = None
    flag: str | None = None
    status: str | None = None
    tags: list[str] = dataclasses.field(default_factory=list)
    extra: dict[str, Any] = dataclasses.field(default_factory=dict)

    def __post_init__(self) -> None:
        """Normalizes tags to a sorted, unique list."""
        self.favorite = bool(self.favorite)
        self.tags = sorted({str(t) for t in self.tags})

__post_init__() -> None

Normalizes tags to a sorted, unique list.

Source code in src/simscope/annotations.py
174
175
176
177
def __post_init__(self) -> None:
    """Normalizes tags to a sorted, unique list."""
    self.favorite = bool(self.favorite)
    self.tags = sorted({str(t) for t in self.tags})

Note dataclass

Bases: Record

A Markdown note.

Attributes:

Name Type Description
text str

The note body, Markdown.

Source code in src/simscope/annotations.py
180
181
182
183
184
185
186
187
188
@dataclasses.dataclass(kw_only=True)
class Note(Record):
    """A Markdown note.

    Attributes:
        text: The note body, Markdown.
    """

    text: str = ""

Rating dataclass

Bases: Record

A rating on one criterion.

Attributes:

Name Type Description
criterion str

Free string, "overall" by default.

scale RatingScale

"stars5", "score100" or "thumb".

value float

Number on that scale.

rationale str | None

Optional explanation.

Source code in src/simscope/annotations.py
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
@dataclasses.dataclass(kw_only=True)
class Rating(Record):
    """A rating on one criterion.

    Attributes:
        criterion: Free string, ``"overall"`` by default.
        scale: ``"stars5"``, ``"score100"`` or ``"thumb"``.
        value: Number on that scale.
        rationale: Optional explanation.
    """

    criterion: str = DEFAULT_CRITERION
    scale: RatingScale = "stars5"
    value: float = 0
    rationale: str | None = None

Record dataclass

Bases: _JsonRecord

Fields common to every list record.

Attributes:

Name Type Description
id str

ULID, unique within the file.

author str

Who made the record.

created str

RFC 3339 UTC creation time.

updated str

RFC 3339 UTC time of the last change.

Source code in src/simscope/annotations.py
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
@dataclasses.dataclass(kw_only=True)
class Record(_JsonRecord):
    """Fields common to every list record.

    Attributes:
        id: ULID, unique within the file.
        author: Who made the record.
        created: RFC 3339 UTC creation time.
        updated: RFC 3339 UTC time of the last change.
    """

    id: str
    author: str
    created: str
    updated: str

Spatial dataclass

Bases: Record

A 3D point or box at a moment in time.

Attributes:

Name Type Description
kind str

"point" or "box".

frame str

"world" or "body:<index>".

t float

Time in seconds.

f int

Frame nearest t.

env int

Env index.

position list[float]

[x, y, z] in metres.

size list[float] | None

Box half-extents [hx, hy, hz], or None for a point.

label str

Short display string.

event str | None

Optional id of a linked event.

Source code in src/simscope/annotations.py
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
@dataclasses.dataclass(kw_only=True)
class Spatial(Record):
    """A 3D point or box at a moment in time.

    Attributes:
        kind: ``"point"`` or ``"box"``.
        frame: ``"world"`` or ``"body:<index>"``.
        t: Time in seconds.
        f: Frame nearest ``t``.
        env: Env index.
        position: ``[x, y, z]`` in metres.
        size: Box half-extents ``[hx, hy, hz]``, or ``None`` for a point.
        label: Short display string.
        event: Optional id of a linked event.
    """

    kind: str = "point"
    frame: str = "world"
    t: float = 0.0
    f: int = 0
    env: int = 0
    position: list[float] = dataclasses.field(default_factory=lambda: [0.0] * 3)
    size: list[float] | None = None
    label: str = ""
    event: str | None = None

check_group_name(name: object) -> str

Validates a group name and returns it without surrounding spaces.

Parameters:

Name Type Description Default
name object

The proposed name.

required

Returns:

Type Description
str

The name, 1 to MAX_GROUP_NAME printable characters.

Raises:

Type Description
ValueError

If it is not a string, is empty or too long, or has characters that cannot be printed (such as a newline).

Source code in src/simscope/annotations.py
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
def check_group_name(name: object) -> str:
    """Validates a group name and returns it without surrounding spaces.

    Args:
        name: The proposed name.

    Returns:
        The name, 1 to ``MAX_GROUP_NAME`` printable characters.

    Raises:
        ValueError: If it is not a string, is empty or too long, or has
            characters that cannot be printed (such as a newline).
    """
    if not isinstance(name, str):
        raise ValueError("a group name must be a string")
    name = name.strip()
    if not 1 <= len(name) <= MAX_GROUP_NAME:
        raise ValueError(f"a group name has 1 to {MAX_GROUP_NAME} characters")
    if not name.isprintable():
        raise ValueError("a group name must be printable text")
    return name

default_author() -> str

Returns the OS user name, which is the default record author.

Source code in src/simscope/annotations.py
52
53
54
55
56
57
def default_author() -> str:
    """Returns the OS user name, which is the default record author."""
    try:
        return getpass.getuser()
    except (OSError, KeyError, ImportError):
        return "unknown"

default_event_types() -> dict[str, EventType]

Returns the built-in vocabulary used when the file is missing.

Source code in src/simscope/annotations.py
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
def default_event_types() -> dict[str, EventType]:
    """Returns the built-in vocabulary used when the file is missing."""
    severity = {"type": "select", "options": ["minor", "major"]}
    return {
        t.type_id: t
        for t in (
            EventType(
                type_id="fall",
                name="Fall",
                color="#d33b3b",
                key="f",
                props={"severity": severity},
            ),
            EventType(type_id="slip", name="Slip", color="#e59a1c", key="s"),
            EventType(
                type_id="success", name="Success", color="#2f9e44", key="g"
            ),
            EventType(type_id="note", name="Note", color="#4c6ef5", key="o"),
        )
    }

event_types_path(root: os.PathLike[str] | str) -> pathlib.Path

Returns <root>/.simscope/event_types.json.

Source code in src/simscope/annotations.py
375
376
377
def event_types_path(root: os.PathLike[str] | str) -> pathlib.Path:
    """Returns ``<root>/.simscope/event_types.json``."""
    return pathlib.Path(root) / ".simscope" / "event_types.json"

groups_path(root: os.PathLike[str] | str) -> pathlib.Path

Returns <root>/.simscope/groups.json.

Source code in src/simscope/annotations.py
474
475
476
def groups_path(root: os.PathLike[str] | str) -> pathlib.Path:
    """Returns ``<root>/.simscope/groups.json``."""
    return pathlib.Path(root) / ".simscope" / "groups.json"

load_event_types(root: os.PathLike[str] | str) -> tuple[dict[str, EventType], dict[str, Any]]

Loads the event vocabulary of a library.

Parameters:

Name Type Description Default
root PathLike[str] | str

The library root.

required

Returns:

Type Description
dict[str, EventType]

(types, extra): the entries by id, and unknown top-level fields.

dict[str, Any]

The built-in default (and no extra) when the file is missing.

Raises:

Type Description
FormatError

If the file is invalid.

Source code in src/simscope/annotations.py
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
def load_event_types(
    root: os.PathLike[str] | str,
) -> tuple[dict[str, EventType], dict[str, Any]]:
    """Loads the event vocabulary of a library.

    Args:
        root: The library root.

    Returns:
        ``(types, extra)``: the entries by id, and unknown top-level fields.
        The built-in default (and no extra) when the file is missing.

    Raises:
        errors.FormatError: If the file is invalid.
    """
    path = event_types_path(root)
    if not path.is_file():
        return default_event_types(), {}
    obj = _read_json(path)
    _check_format(obj, "simscope-event-types", path)
    raw = obj.get("types") or {}
    if not isinstance(raw, dict):
        raise errors.FormatError(f"{path}: 'types' must be an object")
    types = {
        str(k): EventType.from_json(v, str(k)) for k, v in sorted(raw.items())
    }
    extra = {k: v for k, v in obj.items() if k not in ("format", "types")}
    return types, extra

load_groups(root: os.PathLike[str] | str) -> tuple[list[Group], dict[str, Any]]

Loads the ordered groups of a library.

A group whose name repeats an earlier one (ignoring case) is dropped.

Parameters:

Name Type Description Default
root PathLike[str] | str

The library root.

required

Returns:

Type Description
list[Group]

(groups, extra): the groups in library order, and unknown

dict[str, Any]

top-level fields. No groups when the file is missing.

Raises:

Type Description
FormatError

If the file is invalid.

Source code in src/simscope/annotations.py
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
def load_groups(
    root: os.PathLike[str] | str,
) -> tuple[list[Group], dict[str, Any]]:
    """Loads the ordered groups of a library.

    A group whose name repeats an earlier one (ignoring case) is dropped.

    Args:
        root: The library root.

    Returns:
        ``(groups, extra)``: the groups in library order, and unknown
        top-level fields. No groups when the file is missing.

    Raises:
        errors.FormatError: If the file is invalid.
    """
    path = groups_path(root)
    if not path.is_file():
        return [], {}
    obj = _read_json(path)
    _check_format(obj, "simscope-groups", path)
    raw = obj.get("groups") or []
    if not isinstance(raw, list) or not all(isinstance(g, dict) for g in raw):
        raise errors.FormatError(f"{path}: 'groups' must be a list of objects")
    groups: list[Group] = []
    seen: set[str] = set()
    for entry in raw:
        group = Group.from_json(entry)
        try:
            group.name = check_group_name(group.name)
        except ValueError as exc:
            raise errors.FormatError(f"{path}: {exc}") from exc
        if group.name.casefold() not in seen:
            seen.add(group.name.casefold())
            groups.append(group)
    extra = {k: v for k, v in obj.items() if k not in ("format", "groups")}
    return groups, extra

merge(a: Annotations, b: Annotations, base: Annotations | None = None) -> Annotations

Merges two annotation sets that diverged from a common base (D8).

List entries merge by id: the union of both sides, and where the same id exists on both sides the one with the later updated wins. Ratings are then reduced to one per author and criterion. This is what a textual git merge of the two sorted files would do, without conflicts.

Parameters:

Name Type Description Default
a Annotations

One side. The result takes its path, run id and dt.

required
b Annotations

The other side ("theirs"; wins mark conflicts).

required
base Annotations | None

The common ancestor, if known. It lets the merge propagate deletions and tell which side changed a mark.

None

Returns:

Type Description
Annotations

A new, dirty Annotations. Neither input is modified.

Source code in src/simscope/annotations.py
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
def merge(
    a: Annotations, b: Annotations, base: Annotations | None = None
) -> Annotations:
    """Merges two annotation sets that diverged from a common base (D8).

    List entries merge by id: the union of both sides, and where the same id
    exists on both sides the one with the later ``updated`` wins. Ratings are
    then reduced to one per author and criterion. This is what a textual git
    merge of the two sorted files would do, without conflicts.

    Args:
        a: One side. The result takes its path, run id and dt.
        b: The other side ("theirs"; wins mark conflicts).
        base: The common ancestor, if known. It lets the merge propagate
            deletions and tell which side changed a mark.

    Returns:
        A new, dirty ``Annotations``. Neither input is modified.
    """
    out = Annotations(a.path, a.run_id, a.dt, a.n_frames)
    out.marks = _merge_marks(a.marks, b.marks, base.marks if base else None)
    for key in _LIST_TYPES:
        merged = _merge_lists(
            getattr(a, key),
            getattr(b, key),
            getattr(base, key) if base is not None else None,
        )
        if key == "ratings":
            merged = _dedupe_ratings(merged)
        getattr(out, key)[:] = merged
    out.extra = {**copy.deepcopy(b.extra), **copy.deepcopy(a.extra)}
    out._dirty = True
    return out

save_event_types(root: os.PathLike[str] | str, types: dict[str, EventType], extra: dict[str, Any] | None = None) -> pathlib.Path

Writes the event vocabulary atomically.

Parameters:

Name Type Description Default
root PathLike[str] | str

The library root.

required
types dict[str, EventType]

Entries by id.

required
extra dict[str, Any] | None

Unknown top-level fields to keep.

None

Returns:

Type Description
Path

The path written.

Source code in src/simscope/annotations.py
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
def save_event_types(
    root: os.PathLike[str] | str,
    types: dict[str, EventType],
    extra: dict[str, Any] | None = None,
) -> pathlib.Path:
    """Writes the event vocabulary atomically.

    Args:
        root: The library root.
        types: Entries by id.
        extra: Unknown top-level fields to keep.

    Returns:
        The path written.
    """
    obj = dict(extra or {})
    obj["format"] = EVENT_TYPES_FORMAT
    obj["types"] = {k: v.to_json() for k, v in sorted(types.items())}
    path = event_types_path(root)
    cas.atomic_write(path, _dump(obj))
    return path

save_groups(root: os.PathLike[str] | str, groups: list[Group], extra: dict[str, Any] | None = None) -> pathlib.Path

Writes the ordered groups atomically.

Parameters:

Name Type Description Default
root PathLike[str] | str

The library root.

required
groups list[Group]

The groups in library order.

required
extra dict[str, Any] | None

Unknown top-level fields to keep.

None

Returns:

Type Description
Path

The path written.

Source code in src/simscope/annotations.py
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
def save_groups(
    root: os.PathLike[str] | str,
    groups: list[Group],
    extra: dict[str, Any] | None = None,
) -> pathlib.Path:
    """Writes the ordered groups atomically.

    Args:
        root: The library root.
        groups: The groups in library order.
        extra: Unknown top-level fields to keep.

    Returns:
        The path written.
    """
    obj = dict(extra or {})
    obj["format"] = GROUPS_FORMAT
    obj["groups"] = [g.to_json() for g in groups]
    path = groups_path(root)
    cas.atomic_write(path, _dump(obj))
    return path

utc_now() -> str

Returns the current UTC time as RFC 3339 with seconds precision.

Source code in src/simscope/annotations.py
542
543
544
def utc_now() -> str:
    """Returns the current UTC time as RFC 3339 with seconds precision."""
    return manifest.utc_now()

simscope.highlights

Automatic highlights: the moments where a rollout's physics peaks.

A highlight is a marker on the timeline. Two kinds are built in, both computed from signals every simulator provides, so they mean the same for any robot or task:

contact The net contact force: the norm of the sum of all the force vectors of the contacts stream. A rollout without a contacts stream gets none. acceleration The centre-of-mass acceleration: the second difference of the centre of mass, in m/s^2. The centre of mass is the mean of the body positions weighted by Body.mass; a scene without masses uses the plain mean of the bodies other than the world. A robot standing still reads about 0, free fall reads about 9.8, and an impact or a push-off is a peak.

A signal is scored per env by a robust z-score (x - median) / max(1.4826 * MAD, floor). The floor keeps flat and intermittent signals from exploding (see SPREAD_FLOOR). A highlight is a local maximum within +-0.25 s whose score is above 6. The top 10 per env and kind are kept, and the top 50 per kind across envs; markers of one kind within 0.15 s merge. ratio says how many times the run's typical peak the value is.

Nothing else is built in: there are no task words such as "jump" or "fall" here. To mark something that is specific to your robot, register your own kind with :func:register, or add explicit events with Annotations.add_event (see "Custom markers" in the getting-started guide).

Everything is vectorized over [T, E] and reads the run one window at a time, decoding env chunks on threads, so 1,000 frames of 4,096 envs take a few seconds and a single 400-frame run takes milliseconds. Teleports (env resets) are masked out.

Example

from simscope import highlights doc = highlights.load_or_compute(rollout, cache_dir) [(h["t"], h["kind"]) for h in doc["highlights"]]

ACCEL_FLOOR = 0.5 module-attribute

Scale floor of the acceleration, in m/s^2 (q16d noise is about 0.04).

COLOR_PATTERN = re.compile('^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$') module-attribute

What a custom kind's colour looks like: #rgb or #rrggbb.

CONTACT_FLOOR = 1.0 module-attribute

Scale floor of the net contact force, in newtons.

DETECTOR_VERSION = 'simscope/3' module-attribute

Version of the built-in detectors; a change invalidates cached results.

Detector = Callable[[library.Rollout], list[Highlight]] module-attribute

A custom detector: returns highlights whose kind is its key.

GRAVITY = 9.81 module-attribute

Standard gravity in m/s^2, for the g of a detail.

KEY_PATTERN = re.compile('^[a-z][a-z0-9_]{0,31}$') module-attribute

What a custom kind's key looks like: lower case, digits and _.

MAD_SIGMA = 1.4826 module-attribute

Scales the median absolute deviation to a standard deviation.

MAX_LABEL = 40 module-attribute

Longest label of a custom kind, in characters.

MERGE_S = 0.15 module-attribute

Markers of one kind closer than this merge into one.

MIN_FRAMES = 8 module-attribute

Fewest frames a signal needs for a typical level to mean anything.

PER_ENV = 10 module-attribute

Most highlights per env and kind.

PER_KIND = 50 module-attribute

Most highlights per kind across envs.

REL_FLOOR = 0.05 module-attribute

Scale floor as a fraction of the median, so a steady load is not flat.

Reducer = Callable[[npt.NDArray[np.float32], int], npt.NDArray[np.float32]] module-attribute

Maps a window [t, e, c] of envs starting at lo to [t, e, ...].

SPREAD_FLOOR = 0.25 module-attribute

Scale floor as a fraction of the signal's usual peak excursion.

A gait's contact force is zero half the time, so its MAD is near zero and every stride would score as an outlier. The usual peak is the k-th largest value, k = max(TOP_MIN, TOP_FRACTION * T); a highlight has to beat it by 50 %.

TELEPORT_SPEED = 20.0 module-attribute

A centre-of-mass step faster than this (m/s) is a reset, not motion.

WINDOW_S = 0.25 module-attribute

A highlight is the largest score within this many seconds.

Z_THRESHOLD = 6.0 module-attribute

Least robust z-score of a highlight.

Highlight dataclass

One marker on the timeline (viewer contracts 9.3).

Attributes:

Name Type Description
t float

Time in seconds (the start, for a span).

frame int

Frame index.

env int

Env index.

kind str

Key of the kind of marker, such as "contact".

score float

Number that ranks the marker within its kind (higher is more notable). The built-in kinds use the robust z-score.

value float

The headline measurement, in the unit of the kind: newtons of contact, m/s^2 of acceleration.

body int | None

Body index the marker belongs to, or None.

label str

Name shown for this marker; the kind's label if left empty.

detail str

One short phrase with the numbers behind the marker, with units, such as "412 N, 5.1 times typical".

t1 float | None

End of a span in seconds, or None for a moment.

frame1 int | None

End frame of a span, or None.

ratio float | None

How many times the typical level the value is, or None.

also tuple[str, ...]

Keys of other kinds that were merged into this marker.

Source code in src/simscope/highlights.py
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
@dataclasses.dataclass(frozen=True)
class Highlight:
    """One marker on the timeline (viewer contracts 9.3).

    Attributes:
        t: Time in seconds (the start, for a span).
        frame: Frame index.
        env: Env index.
        kind: Key of the kind of marker, such as ``"contact"``.
        score: Number that ranks the marker within its kind (higher is more
            notable). The built-in kinds use the robust z-score.
        value: The headline measurement, in the unit of the kind: newtons of
            ``contact``, m/s^2 of ``acceleration``.
        body: Body index the marker belongs to, or ``None``.
        label: Name shown for this marker; the kind's label if left empty.
        detail: One short phrase with the numbers behind the marker, with
            units, such as ``"412 N, 5.1 times typical"``.
        t1: End of a span in seconds, or ``None`` for a moment.
        frame1: End frame of a span, or ``None``.
        ratio: How many times the typical level the value is, or ``None``.
        also: Keys of other kinds that were merged into this marker.
    """

    t: float
    frame: int
    env: int
    kind: str
    score: float
    value: float
    body: int | None = None
    label: str = ""
    detail: str = ""
    t1: float | None = None
    frame1: int | None = None
    ratio: float | None = None
    also: tuple[str, ...] = ()

cache_key(rollout: library.Rollout) -> str

Identifies the inputs of a cached result.

Parameters:

Name Type Description Default
rollout Rollout

The run.

required

Returns:

Type Description
str

A string of run id, detector version, the registered custom kinds

str

(key, label and colour) and the manifest's modification time.

Source code in src/simscope/highlights.py
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
def cache_key(rollout: library.Rollout) -> str:
    """Identifies the inputs of a cached result.

    Args:
        rollout: The run.

    Returns:
        A string of run id, detector version, the registered custom kinds
        (key, label and colour) and the manifest's modification time.
    """
    man = rollout.path / (
        "rollout.json"
        if (rollout.path / "rollout.json").exists()
        else "rollout.json.partial"
    )
    extra = ",".join(
        sorted(
            f"{k.key}:{k.label}:{k.color or ''}"
            for k in _REGISTRY.values()
            if k.detector is not None
        )
    )
    return (
        f"{rollout.manifest.id} {DETECTOR_VERSION} [{extra}] "
        f"{man.stat().st_mtime_ns}"
    )

detect(rollout: library.Rollout, *, envs: Sequence[int] | None = None) -> list[Highlight]

Runs every registered detector on a finished run.

A custom detector that raises, or whose result is invalid (see :func:register), is logged and skipped.

Parameters:

Name Type Description Default
rollout Rollout

The run. It must not be recording.

required
envs Sequence[int] | None

Only look at these envs (built-in detectors read only them; custom detectors see the whole run and their result is filtered). None means all envs.

None

Returns:

Type Description
list[Highlight]

The highlights, sorted by time (then env and kind).

Raises:

Type Description
IndexError

If an env index is out of range.

FormatError

If the run is unfinished.

Source code in src/simscope/highlights.py
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
def detect(
    rollout: library.Rollout, *, envs: Sequence[int] | None = None
) -> list[Highlight]:
    """Runs every registered detector on a finished run.

    A custom detector that raises, or whose result is invalid (see
    :func:`register`), is logged and skipped.

    Args:
        rollout: The run. It must not be recording.
        envs: Only look at these envs (built-in detectors read only them;
            custom detectors see the whole run and their result is
            filtered). ``None`` means all envs.

    Returns:
        The highlights, sorted by time (then env and kind).

    Raises:
        IndexError: If an env index is out of range.
        errors.FormatError: If the run is unfinished.
    """
    ctx = _Context(rollout, envs)
    wanted = None if envs is None else set(ctx.envs.tolist())
    found: list[Highlight] = []
    for key, run_pass in _PASSES:
        if key in _REGISTRY:
            found += _cap(run_pass(ctx))
    for kind in list(_REGISTRY.values()):
        if kind.detector is None:
            continue
        try:
            mine = _check(
                kind.key,
                kind.detector(rollout),
                rollout.n_frames,
                rollout.n_envs,
            )
        except Exception as exc:  # isolation point: one bad detector only
            logger.warning(
                "skipping detector %r: %s",
                kind.key,
                exc,
                exc_info=not isinstance(exc, ValueError),
            )
            continue
        found += [
            dataclasses.replace(h, label=h.label or kind.label)
            for h in mine
            if wanted is None or h.env in wanted
        ]
    merged = _merge(found)
    merged.sort(key=lambda h: (h.t, h.env, h.kind))
    return merged

kinds() -> list[dict[str, str]]

Lists the registered kinds as {key, label} objects.

A custom kind that has a colour also carries color.

Source code in src/simscope/highlights.py
240
241
242
243
244
245
246
247
248
249
250
251
def kinds() -> list[dict[str, str]]:
    """Lists the registered kinds as ``{key, label}`` objects.

    A custom kind that has a colour also carries ``color``.
    """
    out = []
    for kind in _REGISTRY.values():
        entry = {"key": kind.key, "label": kind.label}
        if kind.detector is not None and kind.color is not None:
            entry["color"] = kind.color
        out.append(entry)
    return out

load_or_compute(rollout: library.Rollout, cache_dir: os.PathLike[str] | str) -> dict[str, Any]

Returns the highlights document, cached in cache_dir.

The cache is highlights.json plus a highlights.key file beside it; both are replaced atomically, the key last. A different run id, detector version, set of registered custom kinds or manifest mtime makes the next call recompute.

Parameters:

Name Type Description Default
rollout Rollout

A finished run.

required
cache_dir PathLike[str] | str

Directory for the cache (created if missing), for example .simscope/derived/<run id>.

required

Returns:

Type Description
dict[str, Any]

The highlights.json object.

Source code in src/simscope/highlights.py
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
def load_or_compute(
    rollout: library.Rollout, cache_dir: os.PathLike[str] | str
) -> dict[str, Any]:
    """Returns the highlights document, cached in ``cache_dir``.

    The cache is ``highlights.json`` plus a ``highlights.key`` file beside
    it; both are replaced atomically, the key last. A different run id,
    detector version, set of registered custom kinds or manifest mtime makes
    the next call recompute.

    Args:
        rollout: A finished run.
        cache_dir: Directory for the cache (created if missing), for example
            ``.simscope/derived/<run id>``.

    Returns:
        The ``highlights.json`` object.
    """
    cache = pathlib.Path(cache_dir)
    key = cache_key(rollout)
    doc_path, key_path = cache / FILE_NAME, cache / KEY_NAME
    try:
        if key_path.read_text("utf-8") == key:
            return json.loads(doc_path.read_bytes())
    except (OSError, ValueError):
        pass
    doc = to_json(rollout, detect(rollout))
    cache.mkdir(parents=True, exist_ok=True)
    cas.atomic_write(doc_path, json.dumps(doc, separators=(",", ":")).encode())
    cas.atomic_write(key_path, key.encode("utf-8"))
    logger.info("highlights for %s: %d", rollout.name, len(doc["highlights"]))
    return doc

register(key: str, detector: Detector, *, label: str, color: str | None = None) -> None

Adds your own kind of marker to the ones :func:detect runs.

The detector runs once per run, in the background, and its markers are cached with the built-in ones. Adding or changing a detector refreshes the cache. A detector that raises, or returns something invalid, is logged and skipped; the other kinds still work.

Parameters:

Name Type Description Default
key str

Name of the kind: a lower case letter, then lower case letters, digits and _, at most 32 characters. It must be new (see :func:unregister).

required
detector Detector

detector(rollout) returns a list of :class:Highlight whose kind equals key. Each has a t in seconds, a frame inside the run, and an env of the run. A span also sets t1 and frame1. It is called once for the whole run; detect keeps the envs it was asked for.

required
label str

Display name of the kind (at most 40 characters), used for highlights that have no label of their own.

required
color str | None

A CSS hex colour for the marker, such as "#d9480f", or None for the viewer's default.

None

Raises:

Type Description
ValueError

If the key, label or colour is invalid, or the key is already registered.

Source code in src/simscope/highlights.py
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
def register(
    key: str,
    detector: Detector,
    *,
    label: str,
    color: str | None = None,
) -> None:
    """Adds your own kind of marker to the ones :func:`detect` runs.

    The detector runs once per run, in the background, and its markers are
    cached with the built-in ones. Adding or changing a detector refreshes
    the cache. A detector that raises, or returns something invalid, is
    logged and skipped; the other kinds still work.

    Args:
        key: Name of the kind: a lower case letter, then lower case letters,
            digits and ``_``, at most 32 characters. It must be new (see
            :func:`unregister`).
        detector: ``detector(rollout)`` returns a list of :class:`Highlight`
            whose ``kind`` equals ``key``. Each has a ``t`` in seconds, a
            ``frame`` inside the run, and an ``env`` of the run. A span also
            sets ``t1`` and ``frame1``. It is called once for the whole run;
            ``detect`` keeps the envs it was asked for.
        label: Display name of the kind (at most 40 characters), used for
            highlights that have no label of their own.
        color: A CSS hex colour for the marker, such as ``"#d9480f"``, or
            ``None`` for the viewer's default.

    Raises:
        ValueError: If the key, label or colour is invalid, or the key is
            already registered.
    """
    if not isinstance(key, str) or not KEY_PATTERN.match(key):
        raise ValueError(
            f"kind key {key!r} must be lower case letters, digits and _, "
            "start with a letter, and have at most 32 characters"
        )
    if key in _REGISTRY:
        raise ValueError(f"kind {key!r} is already registered")
    if not 1 <= len(label) <= MAX_LABEL:
        raise ValueError(f"label must have 1 to {MAX_LABEL} characters")
    if color is not None and not (
        isinstance(color, str) and COLOR_PATTERN.match(color)
    ):
        raise ValueError(f"color {color!r} must be a hex colour like #d9480f")
    _REGISTRY[key] = _Kind(key, label, color, detector)

root_body(names: Sequence[str] | core.Scene) -> int

Picks the followed (root) body of a scene.

That is the first of :data:ROOT_NAMES (exact names first, then prefixes), else the first body that is not the world. The derived root-pose stream and the player's follow camera use this same rule (FOLLOW_ALIASES in web/src/core/player.js).

Parameters:

Name Type Description Default
names Sequence[str] | Scene

Body names in scene order, or the scene itself.

required

Returns:

Type Description
int

A body index.

Source code in src/simscope/highlights.py
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
def root_body(names: Sequence[str] | core.Scene) -> int:
    """Picks the followed (root) body of a scene.

    That is the first of :data:`ROOT_NAMES` (exact names first, then
    prefixes), else the first body that is not the world. The derived
    root-pose stream and the player's follow camera use this same rule
    (``FOLLOW_ALIASES`` in ``web/src/core/player.js``).

    Args:
        names: Body names in scene order, or the scene itself.

    Returns:
        A body index.
    """
    if isinstance(names, core.Scene):
        names = [b.name for b in names.bodies]
    names = [n.lower() for n in names]
    for test in (str.__eq__, str.startswith):
        for wanted in ROOT_NAMES:
            for i, name in enumerate(names):
                if test(name, wanted):
                    return i
    for i, name in enumerate(names):
        if name != "world":
            return i
    return 0

to_json(rollout: library.Rollout, highlights: Sequence[Highlight]) -> dict[str, Any]

Builds the highlights.json object (viewer contracts 9.3).

Parameters:

Name Type Description Default
rollout Rollout

The run the highlights belong to.

required
highlights Sequence[Highlight]

Highlights from :func:detect.

required

Returns:

Type Description
dict[str, Any]

The JSON-ready object. kinds lists the kinds that have at least

dict[str, Any]

one highlight (as its kind or among its also), in registration

dict[str, Any]

order; a custom kind with a colour carries it.

Source code in src/simscope/highlights.py
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
def to_json(
    rollout: library.Rollout, highlights: Sequence[Highlight]
) -> dict[str, Any]:
    """Builds the ``highlights.json`` object (viewer contracts 9.3).

    Args:
        rollout: The run the highlights belong to.
        highlights: Highlights from :func:`detect`.

    Returns:
        The JSON-ready object. ``kinds`` lists the kinds that have at least
        one highlight (as its kind or among its ``also``), in registration
        order; a custom kind with a colour carries it.
    """
    ordered = sorted(highlights, key=lambda h: (h.t, h.env, h.kind))
    present = {h.kind for h in ordered} | {k for h in ordered for k in h.also}
    labels = {k["key"]: k["label"] for k in kinds()}

    def rounded(x: float | None, digits: int) -> float | None:
        return None if x is None else round(x, digits)

    return {
        "format": FORMAT,
        "detector": DETECTOR_VERSION,
        "run_id": rollout.manifest.id,
        "kinds": [k for k in kinds() if k["key"] in present],
        "highlights": [
            {
                "t": round(h.t, 6),
                "frame": h.frame,
                "t1": rounded(h.t1, 6),
                "frame1": h.frame1,
                "env": h.env,
                "kind": h.kind,
                "label": h.label or labels.get(h.kind, h.kind),
                "detail": h.detail,
                "score": round(h.score, 3),
                "ratio": rounded(h.ratio, 3),
                "value": round(h.value, 4),
                "body": h.body,
                "also": list(h.also),
            }
            for h in ordered
        ],
    }

unregister(key: str) -> None

Removes a kind, built-in or registered.

Parameters:

Name Type Description Default
key str

Kind key.

required

Raises:

Type Description
KeyError

If no such kind is registered.

Source code in src/simscope/highlights.py
228
229
230
231
232
233
234
235
236
237
def unregister(key: str) -> None:
    """Removes a kind, built-in or registered.

    Args:
        key: Kind key.

    Raises:
        KeyError: If no such kind is registered.
    """
    del _REGISTRY[key]

Derived data

simscope.derived

Derived data: files computed once per run and cached (contracts 5).

Everything here is "just more files in the existing formats", so the browser reads it through the same path-addressed Source as the recording itself:

  • root_pose.blk: the followed body of every env, [E, 1, 7] q16d blocks in the normal block format, for runs with more than :data:CROWD_ENVS envs. It is what the crowd tier draws.
  • summaries.json: one number per env for each of a few columns, which sorts the env picker.
  • envelopes/<stream>.json: the p5, p50 and p95 of a scalar or vector stream across envs, per frame.
  • highlights.json: the contact-force and acceleration peaks of :mod:simscope.highlights; n_highlights counts them per env.

Files live in .simscope/derived/<run id>/ next to a stamp.json that holds the digest of the run's manifest: when the manifest changes, every derived file of the run is discarded. Nothing is computed for a run that is still recording.

Every pass reads one window at a time, and only the components it needs: a block stores its components as separate byte planes, so the root body is inflated from a 140-float item and unshuffled as 7 floats. A 4,096-env, 1,000-frame, 20-body run never holds more than one window of one body.

CROWD_ENVS = 64 module-attribute

Runs with more envs than this get a root_pose.blk.

VERSION = 4 module-attribute

Bump to discard every cached derived file.

2: highlights are simscope-highlights/2 (viewer v3.1). 3: detector simscope/2.1, a fall is a body that stayed down. 4: detector simscope/3, only contact and acceleration kinds.

applicable(rollout: library.Rollout, what: str) -> bool

Tells whether a derived file exists for this run at all.

Parameters:

Name Type Description Default
rollout Rollout

The run.

required
what str

A path relative to derived/<run>/: root_pose.blk, summaries.json, highlights.json or envelopes/<stream>.json.

required

Returns:

Type Description
bool

False for a run that is still recording, for a root stream when

bool

there are at most :data:CROWD_ENVS envs, for an envelope of a

bool

stream that is not scalar or vector (or of a single env), for

bool

highlights when :mod:simscope.highlights is not available, and for

bool

anything else that is not derived data.

Source code in src/simscope/derived.py
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
def applicable(rollout: library.Rollout, what: str) -> bool:
    """Tells whether a derived file exists for this run at all.

    Args:
        rollout: The run.
        what: A path relative to ``derived/<run>/``: ``root_pose.blk``,
            ``summaries.json``, ``highlights.json`` or
            ``envelopes/<stream>.json``.

    Returns:
        False for a run that is still recording, for a root stream when
        there are at most :data:`CROWD_ENVS` envs, for an envelope of a
        stream that is not scalar or vector (or of a single env), for
        highlights when :mod:`simscope.highlights` is not available, and for
        anything else that is not derived data.
    """
    m = rollout.manifest
    if m.status != "complete":
        return False
    if what == ROOT_POSE:
        return m.n_envs > CROWD_ENVS
    if what == SUMMARIES:
        return True
    if what == HIGHLIGHTS:
        return True
    if what.startswith(ENVELOPES) and what.endswith(".json"):
        info = m.streams.get(what[len(ENVELOPES) : -len(".json")])
        return (
            info is not None
            and info.kind in ("scalar", "vector")
            and m.n_envs > 1
        )
    return False

cache_dir(lib_root: pathlib.Path, rollout: library.Rollout) -> pathlib.Path

Returns the cache folder of one run, keyed by the run's ULID.

Parameters:

Name Type Description Default
lib_root Path

The library folder.

required
rollout Rollout

The run.

required

Returns:

Type Description
Path

.simscope/derived/<run id> (created on first write).

Source code in src/simscope/derived.py
 98
 99
100
101
102
103
104
105
106
107
108
def cache_dir(lib_root: pathlib.Path, rollout: library.Rollout) -> pathlib.Path:
    """Returns the cache folder of one run, keyed by the run's ULID.

    Args:
        lib_root: The library folder.
        rollout: The run.

    Returns:
        ``.simscope/derived/<run id>`` (created on first write).
    """
    return cache_root(lib_root) / rollout.manifest.id

cache_root(lib_root: pathlib.Path) -> pathlib.Path

Returns the folder that holds every run's derived cache.

That is <library>/.simscope/derived, or a per-library folder under the system temp directory when the library is read-only.

Parameters:

Name Type Description Default
lib_root Path

The library folder.

required

Returns:

Type Description
Path

The cache folder (not necessarily created yet).

Source code in src/simscope/derived.py
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
def cache_root(lib_root: pathlib.Path) -> pathlib.Path:
    """Returns the folder that holds every run's derived cache.

    That is ``<library>/.simscope/derived``, or a per-library folder under
    the system temp directory when the library is read-only.

    Args:
        lib_root: The library folder.

    Returns:
        The cache folder (not necessarily created yet).
    """
    home = pathlib.Path(lib_root) / ".simscope"
    parent = home if home.exists() else pathlib.Path(lib_root)
    if os.access(parent, os.W_OK):
        return home / "derived"
    digest = hashlib.sha1(
        os.path.realpath(lib_root).encode(), usedforsecurity=False
    ).hexdigest()[:12]
    return pathlib.Path(tempfile.gettempdir()) / "simscope-derived" / digest

carry_over(lib_root: pathlib.Path, rollout: library.Rollout, before: tuple[str, str]) -> None

Keeps a run's cache valid after its manifest was rewritten.

A rename rewrites rollout.json (only its name changes), which would make the stamp and the highlights key look stale and throw the cache away. If the cache was valid for before (:func:fingerprint taken before the rewrite), it is stamped for the run as it is now.

Parameters:

Name Type Description Default
lib_root Path

The library folder.

required
rollout Rollout

The run, opened after the rewrite.

required
before tuple[str, str]

The fingerprint from before the rewrite.

required
Source code in src/simscope/derived.py
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
def carry_over(
    lib_root: pathlib.Path,
    rollout: library.Rollout,
    before: tuple[str, str],
) -> None:
    """Keeps a run's cache valid after its manifest was rewritten.

    A rename rewrites ``rollout.json`` (only its ``name`` changes), which
    would make the stamp and the highlights key look stale and throw the
    cache away. If the cache was valid for ``before`` (:func:`fingerprint`
    taken before the rewrite), it is stamped for the run as it is now.

    Args:
        lib_root: The library folder.
        rollout: The run, opened after the rewrite.
        before: The fingerprint from before the rewrite.
    """
    digest, key = before
    cache = cache_dir(lib_root, rollout)
    with _lock_for(cache):
        try:
            stamp = json.loads((cache / STAMP).read_bytes())
        except (OSError, ValueError):
            stamp = None
        if stamp == {"version": VERSION, "manifest": digest}:
            now = {"version": VERSION, "manifest": manifest_digest(rollout)}
            cas.atomic_write(cache / STAMP, _dumps(now))
        key_path = cache / highlights.KEY_NAME
        try:
            same = key_path.read_text("utf-8") == key
        except (OSError, ValueError):
            same = False
        if same:
            cas.atomic_write(
                key_path, highlights.cache_key(rollout).encode("utf-8")
            )

ensure(rollout: library.Rollout, cache: pathlib.Path, what: str) -> pathlib.Path | None

Computes a derived file if it is missing or stale, and returns it.

Blocks until the file exists; the server runs it in a worker thread. Two callers asking for the same run wait for each other, so a pass runs once. The file is written atomically.

Parameters:

Name Type Description Default
rollout Rollout

The run (complete).

required
cache Path

The run's cache folder, from :func:cache_dir.

required
what str

See :func:applicable.

required

Returns:

Type Description
Path | None

The file path, or None if :func:applicable is false.

Raises:

Type Description
FormatError

If the run's data is corrupt.

OSError

If the cache cannot be written.

Source code in src/simscope/derived.py
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
def ensure(
    rollout: library.Rollout, cache: pathlib.Path, what: str
) -> pathlib.Path | None:
    """Computes a derived file if it is missing or stale, and returns it.

    Blocks until the file exists; the server runs it in a worker thread.
    Two callers asking for the same run wait for each other, so a pass runs
    once. The file is written atomically.

    Args:
        rollout: The run (complete).
        cache: The run's cache folder, from :func:`cache_dir`.
        what: See :func:`applicable`.

    Returns:
        The file path, or ``None`` if :func:`applicable` is false.

    Raises:
        errors.FormatError: If the run's data is corrupt.
        OSError: If the cache cannot be written.
    """
    if not applicable(rollout, what):
        return None
    with _lock_for(cache):
        if not _stamp_ok(rollout, cache):
            _reset(rollout, cache)
        path = cache / what
        if path.is_file():
            return path
        if what == ROOT_POSE:
            _root_pass(rollout, cache)
        elif what == SUMMARIES:
            _write_json(path, _summaries(rollout, cache))
        elif what == HIGHLIGHTS:
            return _highlights(rollout, cache)
        else:
            stream = what[len(ENVELOPES) : -len(".json")]
            _write_json(path, _envelope(rollout, stream))
        return path

envelope_name(stream: str) -> str

Returns the derived file name of a stream's envelope.

Source code in src/simscope/derived.py
211
212
213
def envelope_name(stream: str) -> str:
    """Returns the derived file name of a stream's envelope."""
    return f"{ENVELOPES}{stream}.json"

fingerprint(rollout: library.Rollout) -> tuple[str, str]

Returns what the cache of a run is valid for, to carry it over.

Parameters:

Name Type Description Default
rollout Rollout

The run.

required

Returns:

Type Description
tuple[str, str]

The manifest digest and the highlights cache key.

Source code in src/simscope/derived.py
144
145
146
147
148
149
150
151
152
153
def fingerprint(rollout: library.Rollout) -> tuple[str, str]:
    """Returns what the cache of a run is valid for, to carry it over.

    Args:
        rollout: The run.

    Returns:
        The manifest digest and the highlights cache key.
    """
    return manifest_digest(rollout), highlights.cache_key(rollout)

fresh(rollout: library.Rollout, cache: pathlib.Path, what: str) -> pathlib.Path | None

Returns the cached file if it is up to date, without computing.

Parameters:

Name Type Description Default
rollout Rollout

The run.

required
cache Path

The run's cache folder.

required
what str

See :func:applicable.

required

Returns:

Type Description
Path | None

The file path, or None if it must be computed first.

Source code in src/simscope/derived.py
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
def fresh(
    rollout: library.Rollout, cache: pathlib.Path, what: str
) -> pathlib.Path | None:
    """Returns the cached file if it is up to date, without computing.

    Args:
        rollout: The run.
        cache: The run's cache folder.
        what: See :func:`applicable`.

    Returns:
        The file path, or ``None`` if it must be computed first.
    """
    path = cache / what
    if path.is_file() and _stamp_ok(rollout, cache):
        return path
    return None

highlight_count(cache: pathlib.Path) -> int | None

Counts the highlights in a run's cached file, if it has one.

Parameters:

Name Type Description Default
cache Path

The run's cache folder.

required

Returns:

Type Description
int | None

The count, or None if nothing is cached.

Source code in src/simscope/derived.py
270
271
272
273
274
275
276
277
278
279
280
281
282
283
def highlight_count(cache: pathlib.Path) -> int | None:
    """Counts the highlights in a run's cached file, if it has one.

    Args:
        cache: The run's cache folder.

    Returns:
        The count, or ``None`` if nothing is cached.
    """
    try:
        doc = json.loads((cache / HIGHLIGHTS).read_bytes())
        return len(doc["highlights"])
    except (OSError, ValueError, KeyError, TypeError):
        return None

manifest_digest(rollout: library.Rollout) -> str

Hashes the run's manifest file, which is what invalidates the cache.

Parameters:

Name Type Description Default
rollout Rollout

The run.

required

Returns:

Type Description
str

A hex digest, or "missing" if the manifest cannot be read.

Source code in src/simscope/derived.py
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
def manifest_digest(rollout: library.Rollout) -> str:
    """Hashes the run's manifest file, which is what invalidates the cache.

    Args:
        rollout: The run.

    Returns:
        A hex digest, or ``"missing"`` if the manifest cannot be read.
    """
    for name in (manifest.MANIFEST_NAME, manifest.PARTIAL_NAME):
        try:
            data = (rollout.path / name).read_bytes()
        except OSError:
            continue
        return hashlib.sha1(data, usedforsecurity=False).hexdigest()
    return "missing"

root_body(scene: core.Scene) -> int

Picks the followed body (see :func:simscope.highlights.root_body).

Parameters:

Name Type Description Default
scene Scene

The run's scene.

required

Returns:

Type Description
int

A body index (never the world body when another exists).

Source code in src/simscope/derived.py
465
466
467
468
469
470
471
472
473
474
def root_body(scene: core.Scene) -> int:
    """Picks the followed body (see :func:`simscope.highlights.root_body`).

    Args:
        scene: The run's scene.

    Returns:
        A body index (never the world body when another exists).
    """
    return highlights.root_body(scene)

Viewer server

simscope serve starts this app. It needs the viewer extra.

simscope.server

The simscope viewer server: Starlette over a library directory.

create_app(root, author=...) builds the ASGI app and serve runs it with uvicorn. The routes read the library folder on disk.

create_app(root: pathlib.Path | str, *, author: str | None = None, token: str | None = None, allowed_hosts: Sequence[str] | None = None, watch: bool = True, warm: bool = True) -> Starlette

Builds the app that serves one library to the browser.

The library is scanned before this returns. The file watcher and the highlight warmer start with the app's lifespan (uvicorn runs it; a test client runs it inside with).

Parameters:

Name Type Description Default
root Path | str

The library folder.

required
author str | None

Author of annotations made through the API; the OS user name if None.

None
token str | None

The token that writes must send in X-Simscope-Token; a random one if None. The page's boot block carries it.

None
allowed_hosts Sequence[str] | None

Host header values to accept (DNS-rebinding guard); loopback names by default.

None
watch bool

Watch the folder for changes. Turn off to drive app.state.services.state.rescan() by hand.

True
warm bool

Compute highlights of every run in the background.

True

Returns:

Type Description
Starlette

The ASGI app. Its services are on app.state.services.

Raises:

Type Description
FileNotFoundError

If root is not a directory.

Source code in src/simscope/server/app.py
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
def create_app(
    root: pathlib.Path | str,
    *,
    author: str | None = None,
    token: str | None = None,
    allowed_hosts: Sequence[str] | None = None,
    watch: bool = True,
    warm: bool = True,
) -> Starlette:
    """Builds the app that serves one library to the browser.

    The library is scanned before this returns. The file watcher and the
    highlight warmer start with the app's lifespan (``uvicorn`` runs it; a
    test client runs it inside ``with``).

    Args:
        root: The library folder.
        author: Author of annotations made through the API; the OS user name
            if ``None``.
        token: The token that writes must send in ``X-Simscope-Token``; a
            random one if ``None``. The page's boot block carries it.
        allowed_hosts: ``Host`` header values to accept (DNS-rebinding
            guard); loopback names by default.
        watch: Watch the folder for changes. Turn off to drive
            ``app.state.services.state.rescan()`` by hand.
        warm: Compute highlights of every run in the background.

    Returns:
        The ASGI app. Its services are on ``app.state.services``.

    Raises:
        FileNotFoundError: If ``root`` is not a directory.
    """
    path = pathlib.Path(root)
    if not path.is_dir():
        raise FileNotFoundError(f"no library folder at {path}")
    state = state_mod.LibraryState(
        path, author=author, token=token or secrets.token_urlsafe(24)
    )
    services = routes.Services(state, jobs.Jobs(state), blocks.BlockStore())

    @contextlib.asynccontextmanager
    async def lifespan(_app: Starlette) -> AsyncIterator[None]:
        state.start(watch=watch)
        if warm:
            services.jobs.start_warmer()
        try:
            yield
        finally:
            services.jobs.stop()
            services.blocks.close()
            state.stop()

    async def not_found(_request: Request, _exc: Exception) -> Response:
        return routes.error(404, "not found")

    async def not_allowed(_request: Request, _exc: Exception) -> Response:
        return routes.error(405, "method not allowed")

    app = Starlette(
        routes=[
            Route("/", routes.index),
            Route("/assets/{name}", routes.asset),
            Route("/api/library", routes.library_info),
            Route("/api/runs", routes.runs),
            Route("/api/changes", routes.changes),
            Route("/api/blk", routes.blk),
            Route("/api/blocks", routes.blocks_route),
            Route("/api/export", routes.export_route),
            Route("/api/groups", routes.groups, methods=["GET"]),
            Route("/api/groups", routes.change_groups, methods=["POST"]),
            Route(
                "/api/runs/{name}/annotations",
                routes.annotate,
                methods=["POST"],
            ),
            Route(
                "/api/runs/{name}/rename",
                routes.rename_run,
                methods=["POST"],
            ),
            Route("/files/{path:path}", routes.files),
        ],
        middleware=[
            Middleware(
                security.HostGuard,
                allowed=allowed_hosts or security.LOOPBACK_HOSTS,
            ),
        ],
        exception_handlers={404: not_found, 405: not_allowed},
        lifespan=lifespan,
    )
    app.state.services = services
    return app

serve(root: pathlib.Path | str, host: str = '127.0.0.1', port: int = 8080, author: str | None = None, *, token: str | None = None) -> None

Serves a library until interrupted.

Binds host (loopback by default). A loopback bind answers only loopback Host headers, which stops DNS rebinding; a wildcard bind answers any.

Parameters:

Name Type Description Default
root Path | str

The library folder.

required
host str

Address to listen on.

'127.0.0.1'
port int

Port to listen on (0 picks a free one).

8080
author str | None

Author of annotations made through the API.

None
token str | None

The write token; random if None.

None
Source code in src/simscope/server/app.py
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
def serve(
    root: pathlib.Path | str,
    host: str = "127.0.0.1",
    port: int = 8080,
    author: str | None = None,
    *,
    token: str | None = None,
) -> None:
    """Serves a library until interrupted.

    Binds ``host`` (loopback by default). A loopback bind answers only
    loopback ``Host`` headers, which stops DNS rebinding; a wildcard bind
    answers any.

    Args:
        root: The library folder.
        host: Address to listen on.
        port: Port to listen on (0 picks a free one).
        author: Author of annotations made through the API.
        token: The write token; random if ``None``.
    """
    app = create_app(
        root,
        author=author,
        token=token,
        allowed_hosts=security.allowed_hosts_for(host),
    )
    config = uvicorn.Config(
        app, host=host, port=port, log_level="warning", access_log=False
    )
    server = uvicorn.Server(config)
    server.run()

Export

export_html writes one offline HTML file. For layout="compare" the arrange keyword sets how the panes sit: "side" (side by side, the default for one or two rollouts), "stack" (one above the other), or "grid" (two by two, the default for three or four). Lean files carry no highlights. ui="full" files do.

simscope.export

Exports runs as one offline HTML file or as a .simscope pack.

The HTML file has no external dependencies. It carries:

  • the player runtime (simscope-player.js), gzipped and base64-encoded in a <script type="text/plain"> block, plus a tiny classic bootstrap script that decodes it (Uint8Array.fromBase64 with an atob fallback), gunzips it with DecompressionStream and starts it from a blob: script;
  • one pack holding every run (shared scenes and meshes are stored once), base64-encoded in a second <script type="text/plain"> block that all players reference with src="#simscope-pack";
  • the full third-party license text, in an HTML comment at the end.

Given the same inputs, the output is byte-identical: entries are sorted and gzip uses a fixed level and mtime=0. No timestamps are written.

Annotation sidecars (annotations.json) are copied verbatim into the pack, so notes, ratings and author names travel with it. Pass annotations=False to leave them out.

ui="full" swaps the lean <simscope-player> page for the whole app (simscope-app.js and .css) started from a boot block that points at the same inline pack (viewer contracts 6). The pack also carries each run's derived data under derived/<run>/: the highlights (full exports only, because the lean player does not draw them) and, for more than 64 envs, the crowd root-pose stream and per-env summaries. envs=[...] exports only those envs.

The lean page is deliberately bare (viewer contracts 10): a dark/light neutral background and the <simscope-player> elements, nothing else. The runtime lays out a compare page itself, in the arrangement named by data-arrange, and adds the one shared control bar.

ARRANGEMENTS = ('side', 'stack', 'grid') module-attribute

How a compare page lays out its panes: side by side (a horizontal split), stacked (a vertical split), or a 2 x 2 grid (one cell empty for three runs).

COMPARE_MAX = 4 module-attribute

A compare page takes up to this many runs.

COMPARE_SYNC = 'compare' module-attribute

Name of the shared clock of a compare page.

CROWD_ENVS = 64 module-attribute

Runs exported with more envs than this carry the crowd-tier files.

build_html(pack_bytes: bytes, runs: Sequence[str], *, title: str | None = None, layout: Layout = 'grid', arrange: Arrange | None = None, autoplay: bool = True, loop: bool = True, ui: Ui = 'lean') -> bytes

Builds the HTML page around an existing pack.

Parameters:

Name Type Description Default
pack_bytes bytes

A pack holding at least the named runs.

required
runs Sequence[str]

Runs to show, in page order.

required
title str | None

Page title; defaults to the run name for a single run and to "simscope export" otherwise.

None
layout Layout

"single" (exactly one run), "grid" (independent players), or "compare" (panes driven by one shared control).

'grid'
arrange Arrange | None

For layout="compare", how the panes sit: "side", "stack" or "grid". None picks "side" for one or two runs and "grid" for three or four.

None
autoplay bool

Whether playback starts once the players are ready (ui="lean" only; the app has its own controls).

True
loop bool

Whether playback restarts at the end (ui="lean" only).

True
ui Ui

"lean" for <simscope-player> elements, "full" for the whole app.

'lean'

Returns:

Type Description
bytes

The UTF-8 page.

Raises:

Type Description
ValueError

On an unknown layout, ui or arrange, an arrange without layout="compare", an empty run list, several runs with layout="single", or more than four with layout="compare".

Source code in src/simscope/export.py
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
def build_html(
    pack_bytes: bytes,
    runs: Sequence[str],
    *,
    title: str | None = None,
    layout: Layout = "grid",
    arrange: Arrange | None = None,
    autoplay: bool = True,
    loop: bool = True,
    ui: Ui = "lean",
) -> bytes:
    """Builds the HTML page around an existing pack.

    Args:
        pack_bytes: A pack holding at least the named runs.
        runs: Runs to show, in page order.
        title: Page title; defaults to the run name for a single run and to
            ``"simscope export"`` otherwise.
        layout: ``"single"`` (exactly one run), ``"grid"`` (independent
            players), or ``"compare"`` (panes driven by one shared control).
        arrange: For ``layout="compare"``, how the panes sit: ``"side"``,
            ``"stack"`` or ``"grid"``. ``None`` picks ``"side"`` for one or
            two runs and ``"grid"`` for three or four.
        autoplay: Whether playback starts once the players are ready
            (``ui="lean"`` only; the app has its own controls).
        loop: Whether playback restarts at the end (``ui="lean"`` only).
        ui: ``"lean"`` for ``<simscope-player>`` elements, ``"full"`` for the
            whole app.

    Returns:
        The UTF-8 page.

    Raises:
        ValueError: On an unknown layout, ``ui`` or ``arrange``, an
            ``arrange`` without ``layout="compare"``, an empty run list,
            several runs with ``layout="single"``, or more than four with
            ``layout="compare"``.
    """
    if ui not in _UIS:
        raise ValueError(f"ui must be one of {_UIS}, got {ui!r}")
    if layout not in _LAYOUTS:
        raise ValueError(f"layout must be one of {_LAYOUTS}, got {layout!r}")
    names = _check_layout(layout, _check_runs(runs))
    where = _arrangement(layout, arrange, len(names))
    heading = (
        title
        if title is not None
        else (names[0] if layout == "single" else "simscope export")
    )
    if ui == "full":
        return _full_page(pack_bytes, names, heading, layout, where)
    runtime = gzip_runtime(read_asset(RUNTIME_ASSET))
    licenses = _licences()
    parts = [
        "<!doctype html>",
        '<html lang="en">',
        "<head>",
        '<meta charset="utf-8">',
        '<meta name="viewport" content="width=device-width,initial-scale=1">',
        _icon.ICON_LINK,  # a data URI: no /favicon.ico request
        f"<title>{html.escape(heading)}</title>",
        f"<style>\n{_CSS}</style>",
        "</head>",
        "<body>",
        f'<p id="simscope-error" role="alert" hidden style="{_ERROR_STYLE}">'
        "</p>",
        f'<noscript><p style="{_ERROR_STYLE}">This page needs JavaScript.</p>'
        "</noscript>",
        _content(names, layout, where, autoplay, loop),
        f'<script type="text/plain" id="{RUNTIME_ID}">\n'
        f"{_b64_block(runtime)}\n</script>",
        f'<script type="text/plain" id="{PACK_ID}">\n'
        f"{_b64_block(pack_bytes)}\n</script>",
        f"<script>\n{_BOOTSTRAP}</script>",
        f"<script>\n{_MASTER}</script>" if layout == "compare" else "",
        "</body>",
        "</html>",
        f"<!--\n{licenses}\n-->",
        "",
    ]
    return "\n".join(part for part in parts if part).encode("utf-8")

build_pack(library_root: os.PathLike[str] | str, runs: Sequence[str], *, transcode: bool = True, annotations: bool = True, envs: Sequence[int] | None = None, derived: bool = True, with_highlights: bool = True) -> bytes

Builds one pack holding all runs.

All runs go through :func:simscope.io.pack.write_pack together, so shared scenes and meshes are stored once. Each run's poster.png is included when it exists.

Parameters:

Name Type Description Default
library_root PathLike[str] | str

The library directory, or a folder that contains libraries.

required
runs Sequence[str]

Names of the runs to include.

required
transcode bool

Whether to shrink the pack (q16d poses, q16 meshes).

True
annotations bool

Whether to keep each run's annotations.json.

True
envs Sequence[int] | None

Keep only these envs of every run (see :func:simscope.io.pack.write_pack).

None
derived bool

Whether to add the derived/<run>/ entries of :func:derived_entries. Computing them caches files under the library's .simscope/derived.

True
with_highlights bool

Whether those entries include highlights.json (derived must be true for it to matter).

True

Returns:

Type Description
bytes

The pack bytes.

Raises:

Type Description
ValueError

On an empty or duplicate run list, an invalid run name, a run that is still recording, or an invalid envs.

FileNotFoundError

If a run or a file it references is missing.

Source code in src/simscope/export.py
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
def build_pack(
    library_root: os.PathLike[str] | str,
    runs: Sequence[str],
    *,
    transcode: bool = True,
    annotations: bool = True,
    envs: Sequence[int] | None = None,
    derived: bool = True,
    with_highlights: bool = True,
) -> bytes:
    """Builds one pack holding all runs.

    All runs go through :func:`simscope.io.pack.write_pack` together, so
    shared scenes and meshes are stored once. Each run's ``poster.png`` is
    included when it exists.

    Args:
        library_root: The library directory, or a folder that contains
            libraries.
        runs: Names of the runs to include.
        transcode: Whether to shrink the pack (q16d poses, q16 meshes).
        annotations: Whether to keep each run's ``annotations.json``.
        envs: Keep only these envs of every run (see
            :func:`simscope.io.pack.write_pack`).
        derived: Whether to add the ``derived/<run>/`` entries of
            :func:`derived_entries`. Computing them caches files under the
            library's ``.simscope/derived``.
        with_highlights: Whether those entries include ``highlights.json``
            (``derived`` must be true for it to matter).

    Returns:
        The pack bytes.

    Raises:
        ValueError: On an empty or duplicate run list, an invalid run name,
            a run that is still recording, or an invalid ``envs``.
        FileNotFoundError: If a run or a file it references is missing.
    """
    root = pathlib.Path(library_root)
    names = _check_runs(runs)
    with tempfile.TemporaryDirectory(prefix="simscope-export-") as tmp:
        scratch = pathlib.Path(tmp)
        extra = (
            derived_entries(
                root,
                names,
                scratch,
                envs=envs,
                with_highlights=with_highlights,
            )
            if derived
            else {}
        )
        out = scratch / "export.simscope"
        pack.write_pack(
            root,
            names,
            out,
            transcode=transcode,
            annotations=annotations,
            envs=envs,
            derived=extra or None,
        )
        return out.read_bytes()

derived_entries(library_root: os.PathLike[str] | str, runs: Sequence[str], scratch: pathlib.Path, *, envs: Sequence[int] | None = None, with_highlights: bool = True) -> dict[str, pack.Source]

Collects the derived/<run>/ pack entries of runs.

Highlights are included for every run whose highlights can be computed, unless with_highlights is false. A run that still has more than 64 envs after envs also gets its crowd-tier root_pose.blk and summaries.json, when :mod:simscope.derived is present. Nothing is computed for a run that is still recording, and a derived file that fails to compute is left out with a warning: derived data is optional in a pack.

Parameters:

Name Type Description Default
library_root PathLike[str] | str

The library directory, or a folder that contains libraries.

required
runs Sequence[str]

Run names (validated by the caller).

required
scratch Path

A folder for files made on the way; it must outlive the pack write.

required
envs Sequence[int] | None

The env subset of the export, if any.

None
with_highlights bool

Whether to include highlights.json. Lean HTML exports leave it out: the player does not draw highlights.

True

Returns:

Type Description
dict[str, Source]

Entries by pack path, ready for write_pack(derived=...).

Raises:

Type Description
ValueError

If envs is invalid for a run.

Source code in src/simscope/export.py
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
def derived_entries(
    library_root: os.PathLike[str] | str,
    runs: Sequence[str],
    scratch: pathlib.Path,
    *,
    envs: Sequence[int] | None = None,
    with_highlights: bool = True,
) -> dict[str, pack.Source]:
    """Collects the ``derived/<run>/`` pack entries of runs.

    Highlights are included for every run whose highlights can be computed,
    unless ``with_highlights`` is false.
    A run that still has more than 64 envs after ``envs`` also gets its
    crowd-tier ``root_pose.blk`` and ``summaries.json``, when
    :mod:`simscope.derived` is present. Nothing is computed for a run that
    is still recording, and a derived file that fails to compute is left out
    with a warning: derived data is optional in a pack.

    Args:
        library_root: The library directory, or a folder that contains
            libraries.
        runs: Run names (validated by the caller).
        scratch: A folder for files made on the way; it must outlive the
            pack write.
        envs: The env subset of the export, if any.
        with_highlights: Whether to include ``highlights.json``. Lean HTML
            exports leave it out: the player does not draw highlights.

    Returns:
        Entries by pack path, ready for ``write_pack(derived=...)``.

    Raises:
        ValueError: If ``envs`` is invalid for a run.
    """
    root = pathlib.Path(library_root)
    derived = _derived_module()
    located = discover.by_name(root)
    entries: dict[str, pack.Source] = {}
    for name in runs:
        hit = located.get(name)
        owner = root if hit is None else hit.library
        try:
            run = library.Rollout(owner, name)
        except FileNotFoundError:
            continue  # write_pack reports it
        with run:
            if run.is_recording:
                continue
            ids = (
                None
                if envs is None
                else pack.check_envs(envs, run.n_envs, name)
            )
            n_out = run.n_envs if ids is None else len(ids)
            prefix = f"{pack.DERIVED_PREFIX}{name}/"
            if with_highlights:
                try:
                    entries[prefix + highlights.FILE_NAME] = _run_highlights(
                        run, derived, owner, scratch, ids
                    )
                except Exception:  # isolation point: optional data
                    logger.warning("no highlights for %s", name, exc_info=True)
            if derived is None or n_out <= CROWD_ENVS:
                continue
            try:
                found = _crowd_entries(run, derived, owner, scratch, ids)
            except Exception:  # isolation point: optional data must not fail
                logger.warning("no crowd data for %s", name, exc_info=True)
                continue
            entries.update({prefix + k: v for k, v in found.items()})
    return entries

export_html(library_root: os.PathLike[str] | str, runs: Sequence[str], out_path: os.PathLike[str] | str, *, title: str | None = None, layout: Layout = 'grid', arrange: Arrange | None = None, transcode: bool = True, annotations: bool = True, autoplay: bool = True, loop: bool = True, ui: Ui = 'lean', envs: Sequence[int] | None = None) -> pathlib.Path

Exports runs as one self-contained HTML file.

The file makes no network requests and works from file://. With ui="lean" runs appear as <simscope-player> elements in the order given; with ui="full" the page is the whole app, started on the same pack. One pack holds all runs, so scenes and meshes shared between runs are stored once. A full export's pack also carries each run's highlights; for more than 64 envs, either kind carries the crowd root-pose stream and summaries. Output is deterministic for equal inputs.

A lean page is bare: the players fill the window and the runtime adds one control bar (viewer contracts 10). A compare page looks like the app's compare view: the panes fill the page, each with its run name as a title and nothing else, and one control bar below them drives every pane.

A pack is inlined as base64 in a string, which Chrome caps at 512 MiB, so very large runs need envs to pick a subset.

Parameters:

Name Type Description Default
library_root PathLike[str] | str

The library directory, or a folder that contains libraries.

required
runs Sequence[str]

Names of the runs to export.

required
out_path PathLike[str] | str

The HTML file to write (replaced atomically).

required
title str | None

Page title; defaults to the run name for a single run and to "simscope export" otherwise.

None
layout Layout

"single" for exactly one run, "grid" for independent players, or "compare" for up to four players driven in lockstep by one shared control bar (play and pause, step, a scrubber, the time, loop and speed). Runs of different durations stop at their own ends.

'grid'
arrange Arrange | None

For layout="compare", how the panes sit: "side" (side by side), "stack" (one above the other) or "grid" (two by two). The default is "side" for one or two runs and "grid" for three or four. In a full export it is the arrangement the app opens with.

None
transcode bool

Whether to shrink the pack (q16d poses, q16 meshes).

True
annotations bool

Whether to include each run's annotations.json, so events show as timeline markers.

True
autoplay bool

Whether playback starts once the players are ready (ui="lean").

True
loop bool

Whether playback restarts at the end (ui="lean"). With "compare" the shared control restarts every run when the longest one ends.

True
ui Ui

"lean" (the default) or "full".

'lean'
envs Sequence[int] | None

Export only these envs of every run, in this order; they are renumbered from 0. None exports all.

None

Returns:

Type Description
Path

The output path.

Raises:

Type Description
ValueError

On an unknown layout, ui or arrange, an arrange without layout="compare", an empty or duplicate run list, an invalid run name, several runs with layout="single", more than four with layout="compare", a run that is still recording, or an invalid envs.

FileNotFoundError

If a run or a file it references is missing.

Source code in src/simscope/export.py
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
def export_html(
    library_root: os.PathLike[str] | str,
    runs: Sequence[str],
    out_path: os.PathLike[str] | str,
    *,
    title: str | None = None,
    layout: Layout = "grid",
    arrange: Arrange | None = None,
    transcode: bool = True,
    annotations: bool = True,
    autoplay: bool = True,
    loop: bool = True,
    ui: Ui = "lean",
    envs: Sequence[int] | None = None,
) -> pathlib.Path:
    """Exports runs as one self-contained HTML file.

    The file makes no network requests and works from ``file://``. With
    ``ui="lean"`` runs appear as ``<simscope-player>`` elements in the order
    given; with ``ui="full"`` the page is the whole app, started on the same
    pack. One pack holds all runs, so scenes and meshes shared between runs
    are stored once. A full export's pack also carries each run's
    highlights; for more than 64 envs, either kind carries the crowd root-pose
    stream and summaries. Output is deterministic for equal inputs.

    A lean page is bare: the players fill the window and the runtime adds one
    control bar (viewer contracts 10). A ``compare`` page looks like the
    app's compare view: the panes fill the page, each with its run name as a
    title and nothing else, and one control bar below them drives every pane.

    A pack is inlined as base64 in a string, which Chrome caps at 512 MiB, so
    very large runs need ``envs`` to pick a subset.

    Args:
        library_root: The library directory, or a folder that contains
            libraries.
        runs: Names of the runs to export.
        out_path: The HTML file to write (replaced atomically).
        title: Page title; defaults to the run name for a single run and to
            ``"simscope export"`` otherwise.
        layout: ``"single"`` for exactly one run, ``"grid"`` for independent
            players, or ``"compare"`` for up to four players driven in
            lockstep by one shared control bar (play and pause, step, a
            scrubber, the time, loop and speed). Runs of different durations
            stop at their own ends.
        arrange: For ``layout="compare"``, how the panes sit: ``"side"``
            (side by side), ``"stack"`` (one above the other) or ``"grid"``
            (two by two). The default is ``"side"`` for one or two runs and
            ``"grid"`` for three or four. In a full export it is the
            arrangement the app opens with.
        transcode: Whether to shrink the pack (q16d poses, q16 meshes).
        annotations: Whether to include each run's ``annotations.json``, so
            events show as timeline markers.
        autoplay: Whether playback starts once the players are ready
            (``ui="lean"``).
        loop: Whether playback restarts at the end (``ui="lean"``). With
            ``"compare"`` the shared control restarts every run when the
            longest one ends.
        ui: ``"lean"`` (the default) or ``"full"``.
        envs: Export only these envs of every run, in this order; they are
            renumbered from 0. ``None`` exports all.

    Returns:
        The output path.

    Raises:
        ValueError: On an unknown layout, ``ui`` or ``arrange``, an
            ``arrange`` without ``layout="compare"``, an empty or duplicate
            run list, an invalid run name, several runs with
            ``layout="single"``, more than four with ``layout="compare"``, a
            run that is still recording, or an invalid ``envs``.
        FileNotFoundError: If a run or a file it references is missing.
    """
    if ui not in _UIS:
        raise ValueError(f"ui must be one of {_UIS}, got {ui!r}")
    names = _check_layout(layout, _check_runs(runs))
    _arrangement(layout, arrange, len(names))  # refuse before packing
    data = build_pack(
        library_root,
        names,
        transcode=transcode,
        annotations=annotations,
        envs=envs,
        with_highlights=ui == "full",
    )
    page = build_html(
        data,
        names,
        title=title,
        layout=layout,
        arrange=arrange,
        autoplay=autoplay,
        loop=loop,
        ui=ui,
    )
    return _write_atomic(pathlib.Path(out_path), page)

export_pack(library_root: os.PathLike[str] | str, runs: Sequence[str], out_path: os.PathLike[str] | str, *, transcode: bool = True, annotations: bool = True, envs: Sequence[int] | None = None, derived: bool = True) -> pathlib.Path

Writes runs to a .simscope pack.

Parameters:

Name Type Description Default
library_root PathLike[str] | str

The library directory, or a folder that contains libraries.

required
runs Sequence[str]

Names of the runs to include.

required
out_path PathLike[str] | str

The pack file to write (replaced atomically).

required
transcode bool

Whether to shrink the pack (q16d poses, q16 meshes).

True
annotations bool

Whether to keep each run's annotations.json.

True
envs Sequence[int] | None

Keep only these envs of every run.

None
derived bool

Whether to add highlights and crowd data (see :func:build_pack).

True

Returns:

Type Description
Path

The output path.

Raises:

Type Description
ValueError

On an empty or duplicate run list, an invalid run name, a run that is still recording, or an invalid envs.

FileNotFoundError

If a run or a file it references is missing.

Source code in src/simscope/export.py
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
def export_pack(
    library_root: os.PathLike[str] | str,
    runs: Sequence[str],
    out_path: os.PathLike[str] | str,
    *,
    transcode: bool = True,
    annotations: bool = True,
    envs: Sequence[int] | None = None,
    derived: bool = True,
) -> pathlib.Path:
    """Writes runs to a ``.simscope`` pack.

    Args:
        library_root: The library directory, or a folder that contains
            libraries.
        runs: Names of the runs to include.
        out_path: The pack file to write (replaced atomically).
        transcode: Whether to shrink the pack (q16d poses, q16 meshes).
        annotations: Whether to keep each run's ``annotations.json``.
        envs: Keep only these envs of every run.
        derived: Whether to add highlights and crowd data (see
            :func:`build_pack`).

    Returns:
        The output path.

    Raises:
        ValueError: On an empty or duplicate run list, an invalid run name,
            a run that is still recording, or an invalid ``envs``.
        FileNotFoundError: If a run or a file it references is missing.
    """
    data = build_pack(
        library_root,
        runs,
        transcode=transcode,
        annotations=annotations,
        envs=envs,
        derived=derived,
    )
    return _write_atomic(pathlib.Path(out_path), data)

gzip_runtime(runtime: bytes) -> bytes

Compresses the player runtime deterministically.

Parameters:

Name Type Description Default
runtime bytes

The player IIFE.

required

Returns:

Type Description
bytes

A gzip stream (fixed level, mtime=0, no file name).

Source code in src/simscope/export.py
439
440
441
442
443
444
445
446
447
448
def gzip_runtime(runtime: bytes) -> bytes:
    """Compresses the player runtime deterministically.

    Args:
        runtime: The player IIFE.

    Returns:
        A gzip stream (fixed level, ``mtime=0``, no file name).
    """
    return gzip.compress(runtime, compresslevel=GZIP_LEVEL, mtime=0)

read_asset(name: str) -> bytes

Returns the bytes of a file shipped in simscope/_assets.

Parameters:

Name Type Description Default
name str

File name, for example simscope-player.js.

required

Returns:

Type Description
bytes

The file contents.

Source code in src/simscope/export.py
81
82
83
84
85
86
87
88
89
90
91
def read_asset(name: str) -> bytes:
    """Returns the bytes of a file shipped in ``simscope/_assets``.

    Args:
        name: File name, for example ``simscope-player.js``.

    Returns:
        The file contents.
    """
    resource = importlib.resources.files("simscope") / "_assets" / name
    return resource.read_bytes()

Data model

simscope.core

Simulator-neutral data model for scenes and rollouts.

These types are the in-memory contract shared by the adapters, the storage layer, the viewer, and the exporter. The on-disk encoding is simscope.io.

Conventions: Z-up, right-handed, metres, seconds. A pose is [px, py, pz, qx, qy, qz, qw] with the quaternion in xyzw order.

POSE_DIM = 7 module-attribute

Floats per pose: position xyz, then quaternion xyzw.

Body dataclass

A rigid body that owns geoms and has one pose per frame.

Attributes:

Name Type Description
name str

Body name or USD path. Not required to be unique.

parent int

Index of the parent body, or -1 for a root.

mass float

Mass in kilograms, or 0.0 if unknown (the world body, or a simulator that does not say). Highlights weight the centre of mass by it.

Raises:

Type Description
ValueError

If mass is negative or not finite.

Source code in src/simscope/core.py
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
@dataclasses.dataclass(frozen=True)
class Body:
    """A rigid body that owns geoms and has one pose per frame.

    Attributes:
        name: Body name or USD path. Not required to be unique.
        parent: Index of the parent body, or ``-1`` for a root.
        mass: Mass in kilograms, or ``0.0`` if unknown (the world body, or
            a simulator that does not say). Highlights weight the centre of
            mass by it.

    Raises:
        ValueError: If ``mass`` is negative or not finite.
    """

    name: str
    parent: int = -1
    mass: float = 0.0

    def __post_init__(self) -> None:
        """Checks the mass."""
        if not (math.isfinite(self.mass) and self.mass >= 0):
            raise ValueError(f"body mass must be finite and >= 0: {self.mass}")

__post_init__() -> None

Checks the mass.

Source code in src/simscope/core.py
146
147
148
149
def __post_init__(self) -> None:
    """Checks the mass."""
    if not (math.isfinite(self.mass) and self.mass >= 0):
        raise ValueError(f"body mass must be finite and >= 0: {self.mass}")

Geom dataclass

A visual or collision shape attached to a body.

kind and size follow MuJoCo conventions.

Attributes:

Name Type Description
body int

Index into Scene.bodies.

kind GeomKind

Shape kind.

size Vec3

Kind-specific size parameters in metres.

pos Vec3

Position in the body frame.

quat Quat

Orientation in the body frame, xyzw.

scale Vec3

Component-wise scale applied to local vertices.

material int

Index into Scene.materials.

mesh int | None

Index into Scene.meshes when kind == "mesh".

role GeomRole

Whether viewers show the geom by default.

name str

Optional geom name.

Source code in src/simscope/core.py
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
@dataclasses.dataclass(frozen=True)
class Geom:
    """A visual or collision shape attached to a body.

    ``kind`` and ``size`` follow MuJoCo conventions.

    Attributes:
        body: Index into ``Scene.bodies``.
        kind: Shape kind.
        size: Kind-specific size parameters in metres.
        pos: Position in the body frame.
        quat: Orientation in the body frame, xyzw.
        scale: Component-wise scale applied to local vertices.
        material: Index into ``Scene.materials``.
        mesh: Index into ``Scene.meshes`` when ``kind == "mesh"``.
        role: Whether viewers show the geom by default.
        name: Optional geom name.
    """

    body: int
    kind: GeomKind
    size: Vec3 = (0.0, 0.0, 0.0)
    pos: Vec3 = (0.0, 0.0, 0.0)
    quat: Quat = IDENTITY_QUAT
    scale: Vec3 = UNIT_SCALE
    material: int = 0
    mesh: int | None = None
    role: GeomRole = "visual"
    name: str = ""

Material dataclass

Surface appearance of a geom.

Attributes:

Name Type Description
rgba Rgba

Base color and opacity, each in [0, 1].

metallic float

PBR metalness in [0, 1].

roughness float

PBR roughness in [0, 1].

texture int | None

Index into Scene.textures, or None.

texrepeat tuple[float, float]

Texture repeats along u and v.

Source code in src/simscope/core.py
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
@dataclasses.dataclass(frozen=True)
class Material:
    """Surface appearance of a geom.

    Attributes:
        rgba: Base color and opacity, each in ``[0, 1]``.
        metallic: PBR metalness in ``[0, 1]``.
        roughness: PBR roughness in ``[0, 1]``.
        texture: Index into ``Scene.textures``, or ``None``.
        texrepeat: Texture repeats along u and v.
    """

    rgba: Rgba = (0.8, 0.8, 0.8, 1.0)
    metallic: float = 0.0
    roughness: float = 1.0
    texture: int | None = None
    texrepeat: tuple[float, float] = (1.0, 1.0)

Mesh dataclass

A triangle mesh in its geom's local frame.

Attributes:

Name Type Description
vertices NDArray[float32]

Vertex positions, shape [V, 3], dtype float32.

faces NDArray[uint32]

Triangle vertex indices, shape [F, 3], dtype uint32.

normals NDArray[float32] | None

Optional per-vertex normals, shape [V, 3], float32.

uvs NDArray[float32] | None

Optional per-vertex texture coordinates, shape [V, 2], float32.

Source code in src/simscope/core.py
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
@dataclasses.dataclass(frozen=True, eq=False)
class Mesh:
    """A triangle mesh in its geom's local frame.

    Attributes:
        vertices: Vertex positions, shape ``[V, 3]``, dtype float32.
        faces: Triangle vertex indices, shape ``[F, 3]``, dtype uint32.
        normals: Optional per-vertex normals, shape ``[V, 3]``, float32.
        uvs: Optional per-vertex texture coordinates, shape ``[V, 2]``,
            float32.
    """

    vertices: npt.NDArray[np.float32]
    faces: npt.NDArray[np.uint32]
    normals: npt.NDArray[np.float32] | None = None
    uvs: npt.NDArray[np.float32] | None = None

    def __post_init__(self) -> None:
        """Validates array shapes and dtypes.

        Raises:
            ValueError: If an array has the wrong shape or dtype.
        """
        n_verts = _check_array("vertices", self.vertices, np.float32, 3)
        _check_array("faces", self.faces, np.uint32, 3)
        normals, uvs = self.normals, self.uvs
        if (
            normals is not None
            and _check_array("normals", normals, np.float32, 3) != n_verts
        ):
            raise ValueError("normals must have one row per vertex")
        if (
            uvs is not None
            and _check_array("uvs", uvs, np.float32, 2) != n_verts
        ):
            raise ValueError("uvs must have one row per vertex")

__post_init__() -> None

Validates array shapes and dtypes.

Raises:

Type Description
ValueError

If an array has the wrong shape or dtype.

Source code in src/simscope/core.py
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
def __post_init__(self) -> None:
    """Validates array shapes and dtypes.

    Raises:
        ValueError: If an array has the wrong shape or dtype.
    """
    n_verts = _check_array("vertices", self.vertices, np.float32, 3)
    _check_array("faces", self.faces, np.uint32, 3)
    normals, uvs = self.normals, self.uvs
    if (
        normals is not None
        and _check_array("normals", normals, np.float32, 3) != n_verts
    ):
        raise ValueError("normals must have one row per vertex")
    if (
        uvs is not None
        and _check_array("uvs", uvs, np.float32, 2) != n_verts
    ):
        raise ValueError("uvs must have one row per vertex")

Scene dataclass

The static part of a rollout, shared by every frame and env.

Attributes:

Name Type Description
bodies tuple[Body, ...]

Bodies, in the order of the pose stream.

geoms tuple[Geom, ...]

Geoms attached to bodies.

materials tuple[Material, ...]

Materials referenced by geoms. Must not be empty when there are geoms.

meshes tuple[Mesh, ...]

Meshes referenced by mesh geoms.

textures tuple[Texture, ...]

Textures referenced by materials.

Source code in src/simscope/core.py
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
@dataclasses.dataclass(frozen=True, eq=False)
class Scene:
    """The static part of a rollout, shared by every frame and env.

    Attributes:
        bodies: Bodies, in the order of the pose stream.
        geoms: Geoms attached to bodies.
        materials: Materials referenced by geoms. Must not be empty when
            there are geoms.
        meshes: Meshes referenced by mesh geoms.
        textures: Textures referenced by materials.
    """

    bodies: tuple[Body, ...]
    geoms: tuple[Geom, ...] = ()
    materials: tuple[Material, ...] = (Material(),)
    meshes: tuple[Mesh, ...] = ()
    textures: tuple[Texture, ...] = ()

    def __post_init__(self) -> None:
        """Validates cross-references between scene elements.

        Raises:
            ValueError: If an index points outside its table, or a mesh geom
                has no mesh.
        """
        n_bodies = len(self.bodies)
        for i, body in enumerate(self.bodies):
            if not -1 <= body.parent < n_bodies or body.parent == i:
                raise ValueError(f"body {i} has invalid parent {body.parent}")
        for j, geom in enumerate(self.geoms):
            if not 0 <= geom.body < n_bodies:
                raise ValueError(f"geom {j} has invalid body {geom.body}")
            if not 0 <= geom.material < len(self.materials):
                raise ValueError(
                    f"geom {j} has invalid material {geom.material}"
                )
            if geom.kind == "mesh":
                if geom.mesh is None or not 0 <= geom.mesh < len(self.meshes):
                    raise ValueError(f"mesh geom {j} has invalid mesh")
            elif geom.mesh is not None:
                raise ValueError(f"geom {j} of kind {geom.kind} has a mesh")
        for k, material in enumerate(self.materials):
            texture = material.texture
            if texture is not None and not 0 <= texture < len(self.textures):
                raise ValueError(f"material {k} has invalid texture {texture}")

    @property
    def n_bodies(self) -> int:
        """Number of bodies, which is the pose count per frame."""
        return len(self.bodies)

n_bodies: int property

Number of bodies, which is the pose count per frame.

__post_init__() -> None

Validates cross-references between scene elements.

Raises:

Type Description
ValueError

If an index points outside its table, or a mesh geom has no mesh.

Source code in src/simscope/core.py
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
def __post_init__(self) -> None:
    """Validates cross-references between scene elements.

    Raises:
        ValueError: If an index points outside its table, or a mesh geom
            has no mesh.
    """
    n_bodies = len(self.bodies)
    for i, body in enumerate(self.bodies):
        if not -1 <= body.parent < n_bodies or body.parent == i:
            raise ValueError(f"body {i} has invalid parent {body.parent}")
    for j, geom in enumerate(self.geoms):
        if not 0 <= geom.body < n_bodies:
            raise ValueError(f"geom {j} has invalid body {geom.body}")
        if not 0 <= geom.material < len(self.materials):
            raise ValueError(
                f"geom {j} has invalid material {geom.material}"
            )
        if geom.kind == "mesh":
            if geom.mesh is None or not 0 <= geom.mesh < len(self.meshes):
                raise ValueError(f"mesh geom {j} has invalid mesh")
        elif geom.mesh is not None:
            raise ValueError(f"geom {j} of kind {geom.kind} has a mesh")
    for k, material in enumerate(self.materials):
        texture = material.texture
        if texture is not None and not 0 <= texture < len(self.textures):
            raise ValueError(f"material {k} has invalid texture {texture}")

Texture dataclass

An encoded 2D image.

Attributes:

Name Type Description
data bytes

The encoded file bytes.

media_type Literal['image/png', 'image/jpeg']

"image/png" or "image/jpeg".

width int

Width in pixels.

height int

Height in pixels.

Source code in src/simscope/core.py
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
@dataclasses.dataclass(frozen=True)
class Texture:
    """An encoded 2D image.

    Attributes:
        data: The encoded file bytes.
        media_type: ``"image/png"`` or ``"image/jpeg"``.
        width: Width in pixels.
        height: Height in pixels.
    """

    data: bytes
    media_type: Literal["image/png", "image/jpeg"]
    width: int
    height: int

simscope.transforms

Vectorized quaternion and pose math.

Quaternions are xyzw. Poses are [..., 7] arrays of position xyz followed by quaternion xyzw. Every function broadcasts over leading axes and never loops in Python.

compose_poses(parent: FloatArray, child: FloatArray) -> FloatArray

Composes poses: expresses child (given in parent) in world.

Parameters:

Name Type Description Default
parent FloatArray

Poses with shape [..., 7].

required
child FloatArray

Poses relative to parent with shape [..., 7]. Broadcasts with parent.

required

Returns:

Type Description
FloatArray

World poses with the broadcast shape [..., 7].

Source code in src/simscope/transforms.py
79
80
81
82
83
84
85
86
87
88
89
90
91
92
def compose_poses(parent: FloatArray, child: FloatArray) -> FloatArray:
    """Composes poses: expresses ``child`` (given in ``parent``) in world.

    Args:
        parent: Poses with shape ``[..., 7]``.
        child: Poses relative to ``parent`` with shape ``[..., 7]``.
            Broadcasts with ``parent``.

    Returns:
        World poses with the broadcast shape ``[..., 7]``.
    """
    pq = parent[..., 3:]
    pos = parent[..., :3] + quat_rotate(pq, child[..., :3])
    return np.concatenate((pos, quat_mul(pq, child[..., 3:])), axis=-1)

enforce_sign_continuity(quat: FloatArray, previous: FloatArray | None = None) -> FloatArray

Flips quaternion signs so consecutive frames stay in one hemisphere.

q and -q are the same rotation. Keeping dot(q[t], q[t-1]) >= 0 makes time deltas small, which the block codecs rely on.

Parameters:

Name Type Description Default
quat FloatArray

Quaternions with shape [T, ..., 4], time first.

required
previous FloatArray | None

The quaternion frame just before quat[0], with shape [..., 4], or None to leave frame 0 unchanged.

None

Returns:

Type Description
FloatArray

A new array with the same shape, with signs flipped where needed.

Source code in src/simscope/transforms.py
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
def enforce_sign_continuity(
    quat: FloatArray, previous: FloatArray | None = None
) -> FloatArray:
    """Flips quaternion signs so consecutive frames stay in one hemisphere.

    ``q`` and ``-q`` are the same rotation. Keeping ``dot(q[t], q[t-1]) >= 0``
    makes time deltas small, which the block codecs rely on.

    Args:
        quat: Quaternions with shape ``[T, ..., 4]``, time first.
        previous: The quaternion frame just before ``quat[0]``, with shape
            ``[..., 4]``, or ``None`` to leave frame 0 unchanged.

    Returns:
        A new array with the same shape, with signs flipped where needed.
    """
    q = np.array(quat, copy=True)
    if q.shape[0] == 0:
        return q
    if previous is not None:
        q[0] *= np.where(np.sum(q[0] * previous, axis=-1) < 0, -1, 1)[..., None]
    if q.shape[0] > 1:
        flips = np.sum(q[1:] * q[:-1], axis=-1) < 0
        # Each flip toggles the sign of every later frame.
        parity = np.cumsum(flips, axis=0) % 2
        q[1:] *= np.where(parity == 1, -1, 1)[..., None]
    return q

quat_mul(a: FloatArray, b: FloatArray) -> FloatArray

Multiplies quaternions a * b (apply b, then a).

Parameters:

Name Type Description Default
a FloatArray

Quaternions with shape [..., 4], xyzw.

required
b FloatArray

Quaternions with shape [..., 4], xyzw. Broadcasts with a.

required

Returns:

Type Description
FloatArray

The products with the broadcast shape, xyzw.

Source code in src/simscope/transforms.py
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
def quat_mul(a: FloatArray, b: FloatArray) -> FloatArray:
    """Multiplies quaternions ``a * b`` (apply ``b``, then ``a``).

    Args:
        a: Quaternions with shape ``[..., 4]``, xyzw.
        b: Quaternions with shape ``[..., 4]``, xyzw. Broadcasts with ``a``.

    Returns:
        The products with the broadcast shape, xyzw.
    """
    ax, ay, az, aw = np.moveaxis(a, -1, 0)
    bx, by, bz, bw = np.moveaxis(b, -1, 0)
    return np.stack(
        (
            aw * bx + ax * bw + ay * bz - az * by,
            aw * by - ax * bz + ay * bw + az * bx,
            aw * bz + ax * by - ay * bx + az * bw,
            aw * bw - ax * bx - ay * by - az * bz,
        ),
        axis=-1,
    )

quat_rotate(quat: FloatArray, vec: FloatArray) -> FloatArray

Rotates vectors by unit quaternions.

Parameters:

Name Type Description Default
quat FloatArray

Unit quaternions with shape [..., 4], xyzw.

required
vec FloatArray

Vectors with shape [..., 3]. Broadcasts with quat.

required

Returns:

Type Description
FloatArray

The rotated vectors with the broadcast shape.

Source code in src/simscope/transforms.py
63
64
65
66
67
68
69
70
71
72
73
74
75
76
def quat_rotate(quat: FloatArray, vec: FloatArray) -> FloatArray:
    """Rotates vectors by unit quaternions.

    Args:
        quat: Unit quaternions with shape ``[..., 4]``, xyzw.
        vec: Vectors with shape ``[..., 3]``. Broadcasts with ``quat``.

    Returns:
        The rotated vectors with the broadcast shape.
    """
    u = quat[..., :3]
    w = quat[..., 3:4]
    t = 2.0 * np.cross(u, vec)
    return vec + w * t + np.cross(u, t)

quat_to_matrix(quat: FloatArray) -> FloatArray

Converts unit quaternions to rotation matrices.

Parameters:

Name Type Description Default
quat FloatArray

Unit quaternions with shape [..., 4], xyzw.

required

Returns:

Type Description
FloatArray

Rotation matrices with shape [..., 3, 3].

Source code in src/simscope/transforms.py
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
def quat_to_matrix(quat: FloatArray) -> FloatArray:
    """Converts unit quaternions to rotation matrices.

    Args:
        quat: Unit quaternions with shape ``[..., 4]``, xyzw.

    Returns:
        Rotation matrices with shape ``[..., 3, 3]``.
    """
    x, y, z, w = np.moveaxis(quat, -1, 0)
    return np.stack(
        (
            1 - 2 * (y * y + z * z),
            2 * (x * y - z * w),
            2 * (x * z + y * w),
            2 * (x * y + z * w),
            1 - 2 * (x * x + z * z),
            2 * (y * z - x * w),
            2 * (x * z - y * w),
            2 * (y * z + x * w),
            1 - 2 * (x * x + y * y),
        ),
        axis=-1,
    ).reshape((*quat.shape[:-1], 3, 3))

wxyz_to_xyzw(quat: npt.ArrayLike) -> npt.NDArray[np.float32]

Reorders quaternions from wxyz to xyzw.

Parameters:

Name Type Description Default
quat ArrayLike

Quaternions with shape [..., 4] in wxyz order.

required

Returns:

Type Description
NDArray[float32]

A new float32 array with shape [..., 4] in xyzw order.

Source code in src/simscope/transforms.py
14
15
16
17
18
19
20
21
22
23
24
def wxyz_to_xyzw(quat: npt.ArrayLike) -> npt.NDArray[np.float32]:
    """Reorders quaternions from wxyz to xyzw.

    Args:
        quat: Quaternions with shape ``[..., 4]`` in wxyz order.

    Returns:
        A new float32 array with shape ``[..., 4]`` in xyzw order.
    """
    q = np.asarray(quat, dtype=np.float32)
    return np.ascontiguousarray(q[..., [1, 2, 3, 0]])

xyzw_to_wxyz(quat: npt.ArrayLike) -> npt.NDArray[np.float32]

Reorders quaternions from xyzw to wxyz.

Parameters:

Name Type Description Default
quat ArrayLike

Quaternions with shape [..., 4] in xyzw order.

required

Returns:

Type Description
NDArray[float32]

A new float32 array with shape [..., 4] in wxyz order.

Source code in src/simscope/transforms.py
27
28
29
30
31
32
33
34
35
36
37
def xyzw_to_wxyz(quat: npt.ArrayLike) -> npt.NDArray[np.float32]:
    """Reorders quaternions from xyzw to wxyz.

    Args:
        quat: Quaternions with shape ``[..., 4]`` in xyzw order.

    Returns:
        A new float32 array with shape ``[..., 4]`` in wxyz order.
    """
    q = np.asarray(quat, dtype=np.float32)
    return np.ascontiguousarray(q[..., [3, 0, 1, 2]])

Storage

simscope.io is the format v1 implementation: block files, the content-addressed store, manifests, and packs. Most recordings go through Library and never call this layer.

simscope.io

On-disk format shared by the recorder and the player.

Modules:

Name Description
codecs

Block payload codecs (f32s, q16d) and mesh blobs (raw, q16).

blockfile

BlockWriter, BlockReader and recover.

cas

Ref and the content-addressed ContentStore.

scene

Canonical scene descriptors (put_scene, load_scene).

manifest

RolloutManifest, ULIDs and run-name validation.

pack

write_pack and PackReader.

BlockReader

Random-access reader for a block file.

The file is memory-mapped. read decodes only the blocks that overlap the requested window and keeps the most recent decoded blocks in an LRU cache. Instances are safe to share between threads.

With partial=True the reader can also tail a file that a :class:BlockWriter is still writing: it builds the directory by scanning blocks and :meth:refresh picks up windows appended later. It never writes to the file, and only complete windows whose CRCs check out are visible, so a window the writer is in the middle of writing is never returned.

Attributes:

Name Type Description
n_frames

Total frames (the complete windows found so far, for a file that is still being written).

n_envs

Number of envs.

item_shape

Shape of one env-frame item.

block_frames

Nominal frames per block.

n_blocks

Number of blocks.

directory

Structured array of directory entries (DIR_DTYPE).

Source code in src/simscope/io/blockfile.py
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
class BlockReader:
    """Random-access reader for a block file.

    The file is memory-mapped. ``read`` decodes only the blocks that overlap
    the requested window and keeps the most recent decoded blocks in an LRU
    cache. Instances are safe to share between threads.

    With ``partial=True`` the reader can also tail a file that a
    :class:`BlockWriter` is still writing: it builds the
    directory by scanning blocks and :meth:`refresh` picks up windows
    appended later. It never writes to the file, and only complete windows
    whose CRCs check out are visible, so a window the writer is in the middle
    of writing is never returned.

    Attributes:
        n_frames: Total frames (the complete windows found so far, for a
            file that is still being written).
        n_envs: Number of envs.
        item_shape: Shape of one env-frame item.
        block_frames: Nominal frames per block.
        n_blocks: Number of blocks.
        directory: Structured array of directory entries (``DIR_DTYPE``).
    """

    def __init__(
        self,
        source: os.PathLike[str] | str | bytes | bytearray | memoryview,
        *,
        cache_blocks: int = 64,
        kind: core.StreamKind | None = None,
        verify: bool = True,
        partial: bool = False,
    ) -> None:
        """Opens a block file or an in-memory block file.

        Args:
            source: A path, or the file bytes (for example a pack entry).
            cache_blocks: Decoded blocks to keep in the LRU cache.
            kind: The stream kind from the manifest. For ``"pose"`` streams,
                quaternions of q16d blocks are renormalized.
            verify: Check each block's payload CRC when it is decoded.
            partial: Accept an unfinished file (``dir_offset == 0``) by
                scanning its blocks. A finished file opens as usual.

        Raises:
            errors.FormatError: If the file is unfinished and ``partial`` is
                false (use :func:`recover`), or is corrupt, or uses an
                unknown version or codec.
        """
        self._mm: mmap.mmap | None = None
        self._view: memoryview | None = None
        self._path: pathlib.Path | None = None
        self._verify = verify
        self._kind = kind
        self._cache_blocks = max(0, int(cache_blocks))
        self._cache: collections.OrderedDict[int, npt.NDArray[np.float32]] = (
            collections.OrderedDict()
        )
        self._lock = threading.Lock()
        self._refresh_lock = threading.Lock()
        self._scan: _ScanState | None = None
        self._head: Header | None = None
        self._dir_buf = np.empty(0, DIR_DTYPE)
        if isinstance(source, str | os.PathLike):
            self._path = pathlib.Path(source)
            with open(source, "rb") as f:
                if os.fstat(f.fileno()).st_size < HEADER_SIZE:
                    raise errors.FormatError(f"{source}: shorter than header")
                self._mm = mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ)
            self._view = memoryview(self._mm)
        else:
            self._view = memoryview(source).cast("B")
        try:
            self._init_from_view(self._view, partial)
        except BaseException:
            self.close()
            raise

    @property
    def finished(self) -> bool:
        """True if the file has its directory (it will not grow)."""
        return self._scan is None

    def _init_from_view(self, view: memoryview, partial: bool) -> None:
        """Parses the header and directory and validates them."""
        h = Header.parse(view)
        self.item_shape = h.item_shape
        self.n_envs = h.n_envs
        self.block_frames = h.block_frames
        self._k = h.k
        if h.dir_offset == 0:
            if not partial:
                raise errors.FormatError(
                    "block file is unfinished (dir_offset == 0); "
                    "run simscope.io.blockfile.recover() first"
                )
            self._head = h
            self._scan = _ScanState()
            self.n_frames = 0
            self.n_blocks = 0
            self.directory = self._dir_buf
            self._scan_more(view)
            return
        self._install_finished(view, h)

    def _install_finished(self, view: memoryview, h: Header) -> None:
        """Validates the directory of a finished file and adopts it.

        The directory is published before the counts, so a concurrent
        ``read`` never sees a count that its directory does not cover.
        """
        end = h.dir_offset + h.dir_length
        if h.dir_length != 32 * h.n_blocks or end > len(view):
            raise errors.FormatError("block directory is truncated or invalid")
        d = np.frombuffer(view, DIR_DTYPE, h.n_blocks, h.dir_offset).copy()
        self._check_directory(d, len(view), h.n_frames, h.n_blocks)
        self.directory = d
        self.n_blocks = h.n_blocks
        self.n_frames = h.n_frames
        self._scan = None
        self._head = None
        self._dir_buf = np.empty(0, DIR_DTYPE)

    def _scan_more(self, view: memoryview) -> None:
        """Scans blocks appended since the last scan and publishes them."""
        st, h = self._scan, self._head
        assert st is not None and h is not None
        _advance(view, h, st)
        if not st.kept:
            return
        new = np.frombuffer(_dir_bytes(st.kept), DIR_DTYPE)
        st.kept = []
        n = self.n_blocks
        if n + len(new) > len(self._dir_buf):
            grown = np.empty(max(64, 2 * (n + len(new))), DIR_DTYPE)
            grown[:n] = self._dir_buf[:n]
            self._dir_buf = grown
        self._dir_buf[n : n + len(new)] = new
        self.directory = self._dir_buf[: n + len(new)]
        self.n_blocks = n + len(new)
        self.n_frames = st.frames

    def refresh(self) -> int:
        """Picks up windows appended to a file that is still being written.

        Re-reads the header and scans only the bytes after the last known
        good window, so the cost is proportional to the new data. When the
        writer has finalized the file, the reader switches to the finished
        directory. A finished file, or an in-memory source, is left as is.

        Returns:
            The number of frames now readable.

        Raises:
            errors.FormatError: If the file shrank, or a finished file has
                an invalid directory.
            ValueError: If the reader is closed.
        """
        if self._view is None:
            raise ValueError("reader is closed")
        if self._scan is None or self._path is None:
            return self.n_frames
        with self._refresh_lock:
            if self._scan is None:
                return self.n_frames
            head, size = self._read_tail_state(self._path)
            if size < self._scan.end:
                raise errors.FormatError(f"{self._path}: file shrank")
            if size != len(self._view):
                # Also when it shrank (a concurrent recover): never touch
                # mapped pages past the end of the file.
                self._remap()
            view = self._view
            assert view is not None
            if head is not None and head.dir_offset != 0:
                self._install_finished(view, head)
            else:
                self._scan_more(view)
        return self.n_frames

    def _read_tail_state(self, path: pathlib.Path) -> tuple[Header | None, int]:
        """Reads the header, then the size (in that order, see refresh).

        Returns:
            ``(header, size)``. The header is ``None`` if it cannot be
            parsed yet (a torn write); the next refresh retries.
        """
        with open(path, "rb") as f:
            raw = f.read(HEADER_SIZE)
            size = os.fstat(f.fileno()).st_size
        try:
            return Header.parse(raw), size
        except errors.FormatError:
            return None, size

    def _remap(self) -> None:
        """Maps the file again after its size changed.

        The old map is not closed: a concurrent ``read`` may still hold a
        slice of it, and it is unmapped when the last one goes away.
        """
        assert self._path is not None
        with open(self._path, "rb") as f:
            mm = mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ)
        self._view, self._mm = memoryview(mm), mm

    def _check_directory(
        self, d: npt.NDArray, size: int, n_frames: int, n_blocks: int
    ) -> None:
        """Validates the directory against the header, vectorized."""
        e, bf = self.n_envs, self.block_frames
        n_windows = -(-n_frames // bf)
        if n_blocks != n_windows * e:
            raise errors.FormatError("block count does not match n_frames")
        if not np.isin(d["codec"], list(codecs.BLOCK_CODEC_NAMES)).all():
            bad = sorted(
                set(d["codec"].tolist()) - set(codecs.BLOCK_CODEC_NAMES)
            )
            raise errors.FormatError(f"unknown block codec id {bad[0]}")
        idx = np.arange(n_blocks)
        t0 = (idx // e) * bf
        n = np.minimum(bf, n_frames - t0)
        k = self._k
        ulen = np.where(
            d["codec"] == codecs.CODEC_F32S, 4 * k * n, 8 * k + 2 * k * n
        )
        ok = (
            (d["env"] == idx % e).all()
            and (d["t0"] == t0).all()
            and (d["n"] == n).all()
            and (d["ulen"] == ulen).all()
            and (d["offset"] % 8 == 0).all()
            and (d["offset"] + 32 + d["clen"].astype(np.uint64) <= size).all()
        )
        if not ok:
            raise errors.FormatError("block directory is inconsistent")

    def _block(self, idx: int) -> npt.NDArray[np.float32]:
        """Returns the decoded block ``idx`` from the cache or the file."""
        with self._lock:
            hit = self._cache.get(idx)
            if hit is not None:
                self._cache.move_to_end(idx)
                return hit
        assert self._view is not None
        ent = self.directory[idx]
        off, clen, n = int(ent["offset"]), int(ent["clen"]), int(ent["n"])
        head = BLOCK_HEADER.unpack_from(self._view, off)
        if head[0] != BLOCK_MAGIC:
            raise errors.FormatError(f"bad block magic at offset {off}")
        codec = int(ent["codec"])
        if (head[1], head[2], head[3], head[4], head[5]) != (
            codec,
            ent["env"],
            ent["t0"],
            n,
            clen,
        ):
            raise errors.FormatError(
                f"block header at {off} disagrees with directory"
            )
        payload = self._view[off + 32 : off + 32 + clen]
        try:
            if self._verify and zlib.crc32(payload) != head[7]:
                raise errors.FormatError(f"block CRC mismatch at offset {off}")
            out = np.empty((n, self._k), np.float32)
            codecs.decode_block_into(payload, codec, out)
        finally:
            payload.release()
        if self._kind == "pose" and codec == codecs.CODEC_Q16D:
            _renormalize_poses(out)
        out.flags.writeable = False
        with self._lock:
            if self._cache_blocks:
                self._cache[idx] = out
                while len(self._cache) > self._cache_blocks:
                    self._cache.popitem(last=False)
        return out

    def read(
        self,
        t0: int,
        t1: int,
        envs: Sequence[int] | npt.NDArray[np.integer] | None = None,
    ) -> npt.NDArray[np.float32]:
        """Reads frames ``t0 <= t < t1``.

        Args:
            t0: First frame.
            t1: One past the last frame.
            envs: Env indices to read, or ``None`` for all envs.

        Returns:
            A new float32 array ``[t1 - t0, len(envs), *item_shape]``.

        Raises:
            IndexError: If the frame range or an env index is out of range.
            ValueError: If the reader is closed.
        """
        if self._view is None:
            raise ValueError("reader is closed")
        if not 0 <= t0 <= t1 <= self.n_frames:
            raise IndexError(
                f"frame range [{t0}, {t1}) outside [0, {self.n_frames}]"
            )
        env_ids = (
            np.arange(self.n_envs)
            if envs is None
            else np.asarray(envs, dtype=np.int64).reshape(-1)
        )
        if env_ids.size and (env_ids.min() < 0 or env_ids.max() >= self.n_envs):
            raise IndexError(f"env index outside [0, {self.n_envs})")
        out = np.empty((t1 - t0, env_ids.size, self._k), np.float32)
        bf = self.block_frames
        for w in range(t0 // bf, -(-t1 // bf)):
            lo, hi = max(t0, w * bf), min(t1, (w + 1) * bf)
            for j, env in enumerate(env_ids.tolist()):
                blk = self._block(w * self.n_envs + env)
                out[lo - t0 : hi - t0, j] = blk[lo - w * bf : hi - w * bf]
        return out.reshape(t1 - t0, env_ids.size, *self.item_shape)

    def close(self) -> None:
        """Releases the memory map. Safe to call more than once."""
        self._cache.clear()
        if self._view is not None:
            self._view.release()
            self._view = None
        if self._mm is not None:
            self._mm.close()
            self._mm = None

    def __enter__(self) -> "BlockReader":
        """Returns the reader."""
        return self

    def __exit__(
        self,
        exc_type: type[BaseException] | None,
        exc: BaseException | None,
        tb: TracebackType | None,
    ) -> None:
        """Closes the reader."""
        self.close()

finished: bool property

True if the file has its directory (it will not grow).

__enter__() -> BlockReader

Returns the reader.

Source code in src/simscope/io/blockfile.py
769
770
771
def __enter__(self) -> "BlockReader":
    """Returns the reader."""
    return self

__exit__(exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None) -> None

Closes the reader.

Source code in src/simscope/io/blockfile.py
773
774
775
776
777
778
779
780
def __exit__(
    self,
    exc_type: type[BaseException] | None,
    exc: BaseException | None,
    tb: TracebackType | None,
) -> None:
    """Closes the reader."""
    self.close()

__init__(source: os.PathLike[str] | str | bytes | bytearray | memoryview, *, cache_blocks: int = 64, kind: core.StreamKind | None = None, verify: bool = True, partial: bool = False) -> None

Opens a block file or an in-memory block file.

Parameters:

Name Type Description Default
source PathLike[str] | str | bytes | bytearray | memoryview

A path, or the file bytes (for example a pack entry).

required
cache_blocks int

Decoded blocks to keep in the LRU cache.

64
kind StreamKind | None

The stream kind from the manifest. For "pose" streams, quaternions of q16d blocks are renormalized.

None
verify bool

Check each block's payload CRC when it is decoded.

True
partial bool

Accept an unfinished file (dir_offset == 0) by scanning its blocks. A finished file opens as usual.

False

Raises:

Type Description
FormatError

If the file is unfinished and partial is false (use :func:recover), or is corrupt, or uses an unknown version or codec.

Source code in src/simscope/io/blockfile.py
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
def __init__(
    self,
    source: os.PathLike[str] | str | bytes | bytearray | memoryview,
    *,
    cache_blocks: int = 64,
    kind: core.StreamKind | None = None,
    verify: bool = True,
    partial: bool = False,
) -> None:
    """Opens a block file or an in-memory block file.

    Args:
        source: A path, or the file bytes (for example a pack entry).
        cache_blocks: Decoded blocks to keep in the LRU cache.
        kind: The stream kind from the manifest. For ``"pose"`` streams,
            quaternions of q16d blocks are renormalized.
        verify: Check each block's payload CRC when it is decoded.
        partial: Accept an unfinished file (``dir_offset == 0``) by
            scanning its blocks. A finished file opens as usual.

    Raises:
        errors.FormatError: If the file is unfinished and ``partial`` is
            false (use :func:`recover`), or is corrupt, or uses an
            unknown version or codec.
    """
    self._mm: mmap.mmap | None = None
    self._view: memoryview | None = None
    self._path: pathlib.Path | None = None
    self._verify = verify
    self._kind = kind
    self._cache_blocks = max(0, int(cache_blocks))
    self._cache: collections.OrderedDict[int, npt.NDArray[np.float32]] = (
        collections.OrderedDict()
    )
    self._lock = threading.Lock()
    self._refresh_lock = threading.Lock()
    self._scan: _ScanState | None = None
    self._head: Header | None = None
    self._dir_buf = np.empty(0, DIR_DTYPE)
    if isinstance(source, str | os.PathLike):
        self._path = pathlib.Path(source)
        with open(source, "rb") as f:
            if os.fstat(f.fileno()).st_size < HEADER_SIZE:
                raise errors.FormatError(f"{source}: shorter than header")
            self._mm = mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ)
        self._view = memoryview(self._mm)
    else:
        self._view = memoryview(source).cast("B")
    try:
        self._init_from_view(self._view, partial)
    except BaseException:
        self.close()
        raise

close() -> None

Releases the memory map. Safe to call more than once.

Source code in src/simscope/io/blockfile.py
759
760
761
762
763
764
765
766
767
def close(self) -> None:
    """Releases the memory map. Safe to call more than once."""
    self._cache.clear()
    if self._view is not None:
        self._view.release()
        self._view = None
    if self._mm is not None:
        self._mm.close()
        self._mm = None

read(t0: int, t1: int, envs: Sequence[int] | npt.NDArray[np.integer] | None = None) -> npt.NDArray[np.float32]

Reads frames t0 <= t < t1.

Parameters:

Name Type Description Default
t0 int

First frame.

required
t1 int

One past the last frame.

required
envs Sequence[int] | NDArray[integer] | None

Env indices to read, or None for all envs.

None

Returns:

Type Description
NDArray[float32]

A new float32 array [t1 - t0, len(envs), *item_shape].

Raises:

Type Description
IndexError

If the frame range or an env index is out of range.

ValueError

If the reader is closed.

Source code in src/simscope/io/blockfile.py
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
def read(
    self,
    t0: int,
    t1: int,
    envs: Sequence[int] | npt.NDArray[np.integer] | None = None,
) -> npt.NDArray[np.float32]:
    """Reads frames ``t0 <= t < t1``.

    Args:
        t0: First frame.
        t1: One past the last frame.
        envs: Env indices to read, or ``None`` for all envs.

    Returns:
        A new float32 array ``[t1 - t0, len(envs), *item_shape]``.

    Raises:
        IndexError: If the frame range or an env index is out of range.
        ValueError: If the reader is closed.
    """
    if self._view is None:
        raise ValueError("reader is closed")
    if not 0 <= t0 <= t1 <= self.n_frames:
        raise IndexError(
            f"frame range [{t0}, {t1}) outside [0, {self.n_frames}]"
        )
    env_ids = (
        np.arange(self.n_envs)
        if envs is None
        else np.asarray(envs, dtype=np.int64).reshape(-1)
    )
    if env_ids.size and (env_ids.min() < 0 or env_ids.max() >= self.n_envs):
        raise IndexError(f"env index outside [0, {self.n_envs})")
    out = np.empty((t1 - t0, env_ids.size, self._k), np.float32)
    bf = self.block_frames
    for w in range(t0 // bf, -(-t1 // bf)):
        lo, hi = max(t0, w * bf), min(t1, (w + 1) * bf)
        for j, env in enumerate(env_ids.tolist()):
            blk = self._block(w * self.n_envs + env)
            out[lo - t0 : hi - t0, j] = blk[lo - w * bf : hi - w * bf]
    return out.reshape(t1 - t0, env_ids.size, *self.item_shape)

refresh() -> int

Picks up windows appended to a file that is still being written.

Re-reads the header and scans only the bytes after the last known good window, so the cost is proportional to the new data. When the writer has finalized the file, the reader switches to the finished directory. A finished file, or an in-memory source, is left as is.

Returns:

Type Description
int

The number of frames now readable.

Raises:

Type Description
FormatError

If the file shrank, or a finished file has an invalid directory.

ValueError

If the reader is closed.

Source code in src/simscope/io/blockfile.py
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
def refresh(self) -> int:
    """Picks up windows appended to a file that is still being written.

    Re-reads the header and scans only the bytes after the last known
    good window, so the cost is proportional to the new data. When the
    writer has finalized the file, the reader switches to the finished
    directory. A finished file, or an in-memory source, is left as is.

    Returns:
        The number of frames now readable.

    Raises:
        errors.FormatError: If the file shrank, or a finished file has
            an invalid directory.
        ValueError: If the reader is closed.
    """
    if self._view is None:
        raise ValueError("reader is closed")
    if self._scan is None or self._path is None:
        return self.n_frames
    with self._refresh_lock:
        if self._scan is None:
            return self.n_frames
        head, size = self._read_tail_state(self._path)
        if size < self._scan.end:
            raise errors.FormatError(f"{self._path}: file shrank")
        if size != len(self._view):
            # Also when it shrank (a concurrent recover): never touch
            # mapped pages past the end of the file.
            self._remap()
        view = self._view
        assert view is not None
        if head is not None and head.dir_offset != 0:
            self._install_finished(view, head)
        else:
            self._scan_more(view)
    return self.n_frames

BlockWriter

Writes one stream as a block file.

append copies frames into a preallocated [block_frames, n_envs, K] buffer. Each time it fills, the window is encoded (one block per env) and written. Leaving the with block normally calls :meth:finalize; on an exception the file is closed unfinished so :func:recover can salvage it.

Attributes:

Name Type Description
path

Destination path.

item_shape

Shape of one env-frame item.

n_envs

Number of envs.

kind

Stream kind ("pose" streams get sign continuity).

codec

Requested codec name.

block_frames

Frames per block.

Source code in src/simscope/io/blockfile.py
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
class BlockWriter:
    """Writes one stream as a block file.

    ``append`` copies frames into a preallocated ``[block_frames, n_envs, K]``
    buffer. Each time it fills, the window is encoded (one block per env) and
    written. Leaving the ``with`` block normally calls :meth:`finalize`; on an
    exception the file is closed unfinished so :func:`recover` can salvage it.

    Attributes:
        path: Destination path.
        item_shape: Shape of one env-frame item.
        n_envs: Number of envs.
        kind: Stream kind (``"pose"`` streams get sign continuity).
        codec: Requested codec name.
        block_frames: Frames per block.
    """

    def __init__(
        self,
        path: os.PathLike[str] | str,
        *,
        item_shape: Sequence[int],
        n_envs: int,
        kind: core.StreamKind,
        codec: str = "f32s",
        block_frames: int = DEFAULT_BLOCK_FRAMES,
    ) -> None:
        """Creates the file and writes a provisional header.

        Args:
            path: Destination path (overwritten).
            item_shape: Shape of one env-frame item, at most 4 dims, all > 0.
            n_envs: Number of envs (at least 1).
            kind: Stream kind. ``"pose"`` requires a last dim of 7.
            codec: ``"f32s"`` (lossless) or ``"q16d"``.
            block_frames: Frames per block (at least 1).

        Raises:
            ValueError: If an argument is invalid.
            errors.FormatError: If the codec is unknown.
        """
        self.path = pathlib.Path(path)
        self.item_shape = tuple(int(d) for d in item_shape)
        self.n_envs = int(n_envs)
        self.kind = kind
        self.block_frames = int(block_frames)
        self._codec_id = codecs.codec_id(codec)
        self.codec = codecs.BLOCK_CODEC_NAMES[self._codec_id]
        if len(self.item_shape) > _MAX_NDIM or 0 in self.item_shape:
            raise ValueError(f"invalid item_shape {self.item_shape}")
        if self.n_envs < 1 or self.block_frames < 1:
            raise ValueError("n_envs and block_frames must be at least 1")
        if kind == "pose" and self.item_shape[-1:] != (core.POSE_DIM,):
            raise ValueError("pose streams need a last item dim of 7")
        self._k = math.prod(self.item_shape)
        self._buf = np.empty(
            (self.block_frames, self.n_envs, self._k), np.float32
        )
        self._fill = 0
        self._n_frames = 0
        self._prev_quat: npt.NDArray[np.float32] | None = None
        self._entries: list[DirEntry] = []
        self._offset = HEADER_SIZE
        self._finalized = False
        self._file: BinaryIO | None = open(self.path, "wb")  # noqa: SIM115
        self._file.write(self._header(0, 0, 0, 0).pack())
        self._file.flush()  # tailing readers see the header at once

    def _header(
        self, n_frames: int, n_blocks: int, dir_offset: int, dir_len: int
    ) -> Header:
        """Builds a header for the current stream parameters."""
        return Header(
            self.item_shape,
            self.n_envs,
            n_frames,
            self.block_frames,
            n_blocks,
            dir_offset,
            dir_len,
        )

    @property
    def n_frames(self) -> int:
        """Frames appended so far, including the unflushed window."""
        return self._n_frames + self._fill

    def append(self, frames: npt.ArrayLike) -> None:
        """Appends frames to the stream.

        Args:
            frames: Array ``[n, n_envs, *item_shape]`` with n at least 1.
                It is copied; the caller may reuse it.

        Raises:
            ValueError: If the shape is wrong or the writer is finalized.
        """
        if self._file is None or self._finalized:
            raise ValueError("writer is closed")
        a = np.asarray(frames, dtype=np.float32)
        want = (self.n_envs, *self.item_shape)
        if a.ndim != len(want) + 1 or a.shape[1:] != want or a.shape[0] < 1:
            raise ValueError(
                f"expected frames of shape [n>=1, {', '.join(map(str, want))}]"
                f", got {a.shape}"
            )
        a = a.reshape(a.shape[0], self.n_envs, self._k)
        pos = 0
        while pos < a.shape[0]:
            take = min(a.shape[0] - pos, self.block_frames - self._fill)
            dst = self._buf[self._fill : self._fill + take]
            dst[...] = a[pos : pos + take]
            if self.kind == "pose":
                self._fix_signs(dst)
            self._fill += take
            pos += take
            if self._fill == self.block_frames:
                self._flush()

    def _fix_signs(self, chunk: npt.NDArray[np.float32]) -> None:
        """Enforces quaternion sign continuity on a buffer chunk in place."""
        n = chunk.shape[0]
        q = chunk.reshape(n, self.n_envs, -1, core.POSE_DIM)[..., 3:]
        fixed = transforms.enforce_sign_continuity(q, self._prev_quat)
        q[...] = fixed
        self._prev_quat = fixed[-1].copy()

    def _flush(self) -> None:
        """Encodes and writes the buffered window."""
        if self._fill == 0 or self._file is None:
            return
        blocks = encode_window(self._buf[: self._fill], self._codec_id)
        self.write_encoded(self._n_frames, blocks)  # advances _n_frames
        self._fill = 0

    def write_encoded(self, t0: int, blocks: Sequence[EncodedBlock]) -> None:
        """Writes already-encoded blocks of one window.

        Advances the frame count, so ``finalize`` records it.

        Args:
            t0: First frame index of the window.
            blocks: One encoded block per env, in env order.
        """
        assert self._file is not None
        data, entries = pack_window(t0, blocks, self._offset)
        self._file.write(data)
        self._file.flush()  # a tailing reader sees the window right away
        self._entries += entries
        self._offset += len(data)
        if blocks:
            self._n_frames = max(self._n_frames, t0 + blocks[0].n)

    def finalize(self) -> None:
        """Flushes the last window, writes the directory and the header."""
        if self._finalized or self._file is None:
            return
        self._flush()
        directory = _dir_bytes(self._entries)
        self._file.write(directory)
        header = self._header(
            self._n_frames, len(self._entries), self._offset, len(directory)
        )
        self._file.seek(0)
        self._file.write(header.pack())
        self._file.close()
        self._file = None
        self._finalized = True

    def close(self) -> None:
        """Closes the file without finalizing (the file stays recoverable)."""
        if self._file is not None:
            self._file.close()
            self._file = None

    def __enter__(self) -> "BlockWriter":
        """Returns the writer."""
        return self

    def __exit__(
        self,
        exc_type: type[BaseException] | None,
        exc: BaseException | None,
        tb: TracebackType | None,
    ) -> None:
        """Finalizes on success; on error closes and leaves it unfinished."""
        if exc_type is None:
            self.finalize()
        else:
            self.close()

n_frames: int property

Frames appended so far, including the unflushed window.

__enter__() -> BlockWriter

Returns the writer.

Source code in src/simscope/io/blockfile.py
414
415
416
def __enter__(self) -> "BlockWriter":
    """Returns the writer."""
    return self

__exit__(exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None) -> None

Finalizes on success; on error closes and leaves it unfinished.

Source code in src/simscope/io/blockfile.py
418
419
420
421
422
423
424
425
426
427
428
def __exit__(
    self,
    exc_type: type[BaseException] | None,
    exc: BaseException | None,
    tb: TracebackType | None,
) -> None:
    """Finalizes on success; on error closes and leaves it unfinished."""
    if exc_type is None:
        self.finalize()
    else:
        self.close()

__init__(path: os.PathLike[str] | str, *, item_shape: Sequence[int], n_envs: int, kind: core.StreamKind, codec: str = 'f32s', block_frames: int = DEFAULT_BLOCK_FRAMES) -> None

Creates the file and writes a provisional header.

Parameters:

Name Type Description Default
path PathLike[str] | str

Destination path (overwritten).

required
item_shape Sequence[int]

Shape of one env-frame item, at most 4 dims, all > 0.

required
n_envs int

Number of envs (at least 1).

required
kind StreamKind

Stream kind. "pose" requires a last dim of 7.

required
codec str

"f32s" (lossless) or "q16d".

'f32s'
block_frames int

Frames per block (at least 1).

DEFAULT_BLOCK_FRAMES

Raises:

Type Description
ValueError

If an argument is invalid.

FormatError

If the codec is unknown.

Source code in src/simscope/io/blockfile.py
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
def __init__(
    self,
    path: os.PathLike[str] | str,
    *,
    item_shape: Sequence[int],
    n_envs: int,
    kind: core.StreamKind,
    codec: str = "f32s",
    block_frames: int = DEFAULT_BLOCK_FRAMES,
) -> None:
    """Creates the file and writes a provisional header.

    Args:
        path: Destination path (overwritten).
        item_shape: Shape of one env-frame item, at most 4 dims, all > 0.
        n_envs: Number of envs (at least 1).
        kind: Stream kind. ``"pose"`` requires a last dim of 7.
        codec: ``"f32s"`` (lossless) or ``"q16d"``.
        block_frames: Frames per block (at least 1).

    Raises:
        ValueError: If an argument is invalid.
        errors.FormatError: If the codec is unknown.
    """
    self.path = pathlib.Path(path)
    self.item_shape = tuple(int(d) for d in item_shape)
    self.n_envs = int(n_envs)
    self.kind = kind
    self.block_frames = int(block_frames)
    self._codec_id = codecs.codec_id(codec)
    self.codec = codecs.BLOCK_CODEC_NAMES[self._codec_id]
    if len(self.item_shape) > _MAX_NDIM or 0 in self.item_shape:
        raise ValueError(f"invalid item_shape {self.item_shape}")
    if self.n_envs < 1 or self.block_frames < 1:
        raise ValueError("n_envs and block_frames must be at least 1")
    if kind == "pose" and self.item_shape[-1:] != (core.POSE_DIM,):
        raise ValueError("pose streams need a last item dim of 7")
    self._k = math.prod(self.item_shape)
    self._buf = np.empty(
        (self.block_frames, self.n_envs, self._k), np.float32
    )
    self._fill = 0
    self._n_frames = 0
    self._prev_quat: npt.NDArray[np.float32] | None = None
    self._entries: list[DirEntry] = []
    self._offset = HEADER_SIZE
    self._finalized = False
    self._file: BinaryIO | None = open(self.path, "wb")  # noqa: SIM115
    self._file.write(self._header(0, 0, 0, 0).pack())
    self._file.flush()  # tailing readers see the header at once

append(frames: npt.ArrayLike) -> None

Appends frames to the stream.

Parameters:

Name Type Description Default
frames ArrayLike

Array [n, n_envs, *item_shape] with n at least 1. It is copied; the caller may reuse it.

required

Raises:

Type Description
ValueError

If the shape is wrong or the writer is finalized.

Source code in src/simscope/io/blockfile.py
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
def append(self, frames: npt.ArrayLike) -> None:
    """Appends frames to the stream.

    Args:
        frames: Array ``[n, n_envs, *item_shape]`` with n at least 1.
            It is copied; the caller may reuse it.

    Raises:
        ValueError: If the shape is wrong or the writer is finalized.
    """
    if self._file is None or self._finalized:
        raise ValueError("writer is closed")
    a = np.asarray(frames, dtype=np.float32)
    want = (self.n_envs, *self.item_shape)
    if a.ndim != len(want) + 1 or a.shape[1:] != want or a.shape[0] < 1:
        raise ValueError(
            f"expected frames of shape [n>=1, {', '.join(map(str, want))}]"
            f", got {a.shape}"
        )
    a = a.reshape(a.shape[0], self.n_envs, self._k)
    pos = 0
    while pos < a.shape[0]:
        take = min(a.shape[0] - pos, self.block_frames - self._fill)
        dst = self._buf[self._fill : self._fill + take]
        dst[...] = a[pos : pos + take]
        if self.kind == "pose":
            self._fix_signs(dst)
        self._fill += take
        pos += take
        if self._fill == self.block_frames:
            self._flush()

close() -> None

Closes the file without finalizing (the file stays recoverable).

Source code in src/simscope/io/blockfile.py
408
409
410
411
412
def close(self) -> None:
    """Closes the file without finalizing (the file stays recoverable)."""
    if self._file is not None:
        self._file.close()
        self._file = None

finalize() -> None

Flushes the last window, writes the directory and the header.

Source code in src/simscope/io/blockfile.py
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
def finalize(self) -> None:
    """Flushes the last window, writes the directory and the header."""
    if self._finalized or self._file is None:
        return
    self._flush()
    directory = _dir_bytes(self._entries)
    self._file.write(directory)
    header = self._header(
        self._n_frames, len(self._entries), self._offset, len(directory)
    )
    self._file.seek(0)
    self._file.write(header.pack())
    self._file.close()
    self._file = None
    self._finalized = True

write_encoded(t0: int, blocks: Sequence[EncodedBlock]) -> None

Writes already-encoded blocks of one window.

Advances the frame count, so finalize records it.

Parameters:

Name Type Description Default
t0 int

First frame index of the window.

required
blocks Sequence[EncodedBlock]

One encoded block per env, in env order.

required
Source code in src/simscope/io/blockfile.py
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
def write_encoded(self, t0: int, blocks: Sequence[EncodedBlock]) -> None:
    """Writes already-encoded blocks of one window.

    Advances the frame count, so ``finalize`` records it.

    Args:
        t0: First frame index of the window.
        blocks: One encoded block per env, in env order.
    """
    assert self._file is not None
    data, entries = pack_window(t0, blocks, self._offset)
    self._file.write(data)
    self._file.flush()  # a tailing reader sees the window right away
    self._entries += entries
    self._offset += len(data)
    if blocks:
        self._n_frames = max(self._n_frames, t0 + blocks[0].n)

ContentStore

A directory of immutable files named by their SHA-256.

Assets live at assets/<h0h1>/<hex> and scene descriptors at scenes/<h0h1>/<hex>.json under the library root.

Attributes:

Name Type Description
root

The library root directory.

Source code in src/simscope/io/cas.py
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
class ContentStore:
    """A directory of immutable files named by their SHA-256.

    Assets live at ``assets/<h0h1>/<hex>`` and scene descriptors at
    ``scenes/<h0h1>/<hex>.json`` under the library root.

    Attributes:
        root: The library root directory.
    """

    def __init__(self, root: os.PathLike[str] | str) -> None:
        """Creates a store handle. Nothing is created on disk until a put.

        Args:
            root: The library root.
        """
        self.root = pathlib.Path(root)

    def path(self, ref: Ref, kind: Kind = "asset") -> pathlib.Path:
        """Returns the file path for a reference.

        Args:
            ref: The content reference.
            kind: ``"asset"`` (meshes, textures) or ``"scene"``.

        Returns:
            The path, which may not exist.
        """
        subdir, suffix = _KIND_DIRS[kind]
        return self.root / subdir / ref.sha256[:2] / (ref.sha256 + suffix)

    def put(
        self, data: bytes | bytearray | memoryview, kind: Kind = "asset"
    ) -> Ref:
        """Stores bytes, skipping the write if the target already exists.

        Args:
            data: The file contents.
            kind: ``"asset"`` or ``"scene"``.

        Returns:
            The reference to the stored file.
        """
        ref = Ref.of(data)
        target = self.path(ref, kind)
        if not target.exists():
            atomic_write(target, bytes(data))
        return ref

    def has(self, ref: Ref, kind: Kind = "asset") -> bool:
        """Tells whether a file is present.

        Args:
            ref: The content reference.
            kind: ``"asset"`` or ``"scene"``.

        Returns:
            True if the file exists.
        """
        return self.path(ref, kind).is_file()

    def get(
        self, ref: Ref, kind: Kind = "asset", *, verify: bool = False
    ) -> bytes:
        """Reads a file and checks its size.

        Args:
            ref: The content reference.
            kind: ``"asset"`` or ``"scene"``.
            verify: Also check the SHA-256.

        Returns:
            The file contents.

        Raises:
            FileNotFoundError: If the file is missing.
            errors.FormatError: If the size or hash does not match.
        """
        data = self.path(ref, kind).read_bytes()
        if len(data) != ref.size:
            raise errors.FormatError(
                f"size mismatch for {ref.sha256}: file has {len(data)} "
                f"bytes, reference says {ref.size}"
            )
        if verify and hashlib.sha256(data).hexdigest() != ref.sha256:
            raise errors.FormatError(f"hash mismatch for {ref.sha256}")
        return data

__init__(root: os.PathLike[str] | str) -> None

Creates a store handle. Nothing is created on disk until a put.

Parameters:

Name Type Description Default
root PathLike[str] | str

The library root.

required
Source code in src/simscope/io/cas.py
218
219
220
221
222
223
224
def __init__(self, root: os.PathLike[str] | str) -> None:
    """Creates a store handle. Nothing is created on disk until a put.

    Args:
        root: The library root.
    """
    self.root = pathlib.Path(root)

get(ref: Ref, kind: Kind = 'asset', *, verify: bool = False) -> bytes

Reads a file and checks its size.

Parameters:

Name Type Description Default
ref Ref

The content reference.

required
kind Kind

"asset" or "scene".

'asset'
verify bool

Also check the SHA-256.

False

Returns:

Type Description
bytes

The file contents.

Raises:

Type Description
FileNotFoundError

If the file is missing.

FormatError

If the size or hash does not match.

Source code in src/simscope/io/cas.py
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
def get(
    self, ref: Ref, kind: Kind = "asset", *, verify: bool = False
) -> bytes:
    """Reads a file and checks its size.

    Args:
        ref: The content reference.
        kind: ``"asset"`` or ``"scene"``.
        verify: Also check the SHA-256.

    Returns:
        The file contents.

    Raises:
        FileNotFoundError: If the file is missing.
        errors.FormatError: If the size or hash does not match.
    """
    data = self.path(ref, kind).read_bytes()
    if len(data) != ref.size:
        raise errors.FormatError(
            f"size mismatch for {ref.sha256}: file has {len(data)} "
            f"bytes, reference says {ref.size}"
        )
    if verify and hashlib.sha256(data).hexdigest() != ref.sha256:
        raise errors.FormatError(f"hash mismatch for {ref.sha256}")
    return data

has(ref: Ref, kind: Kind = 'asset') -> bool

Tells whether a file is present.

Parameters:

Name Type Description Default
ref Ref

The content reference.

required
kind Kind

"asset" or "scene".

'asset'

Returns:

Type Description
bool

True if the file exists.

Source code in src/simscope/io/cas.py
257
258
259
260
261
262
263
264
265
266
267
def has(self, ref: Ref, kind: Kind = "asset") -> bool:
    """Tells whether a file is present.

    Args:
        ref: The content reference.
        kind: ``"asset"`` or ``"scene"``.

    Returns:
        True if the file exists.
    """
    return self.path(ref, kind).is_file()

path(ref: Ref, kind: Kind = 'asset') -> pathlib.Path

Returns the file path for a reference.

Parameters:

Name Type Description Default
ref Ref

The content reference.

required
kind Kind

"asset" (meshes, textures) or "scene".

'asset'

Returns:

Type Description
Path

The path, which may not exist.

Source code in src/simscope/io/cas.py
226
227
228
229
230
231
232
233
234
235
236
237
def path(self, ref: Ref, kind: Kind = "asset") -> pathlib.Path:
    """Returns the file path for a reference.

    Args:
        ref: The content reference.
        kind: ``"asset"`` (meshes, textures) or ``"scene"``.

    Returns:
        The path, which may not exist.
    """
    subdir, suffix = _KIND_DIRS[kind]
    return self.root / subdir / ref.sha256[:2] / (ref.sha256 + suffix)

put(data: bytes | bytearray | memoryview, kind: Kind = 'asset') -> Ref

Stores bytes, skipping the write if the target already exists.

Parameters:

Name Type Description Default
data bytes | bytearray | memoryview

The file contents.

required
kind Kind

"asset" or "scene".

'asset'

Returns:

Type Description
Ref

The reference to the stored file.

Source code in src/simscope/io/cas.py
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
def put(
    self, data: bytes | bytearray | memoryview, kind: Kind = "asset"
) -> Ref:
    """Stores bytes, skipping the write if the target already exists.

    Args:
        data: The file contents.
        kind: ``"asset"`` or ``"scene"``.

    Returns:
        The reference to the stored file.
    """
    ref = Ref.of(data)
    target = self.path(ref, kind)
    if not target.exists():
        atomic_write(target, bytes(data))
    return ref

PackReader

Read access to a pack, from a file (memory-mapped) or from bytes.

read returns zero-copy memoryview slices of the pack. Close readers opened with :meth:stream before closing the pack.

Source code in src/simscope/io/pack.py
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
class PackReader:
    """Read access to a pack, from a file (memory-mapped) or from bytes.

    ``read`` returns zero-copy memoryview slices of the pack. Close readers
    opened with :meth:`stream` before closing the pack.
    """

    def __init__(
        self, source: os.PathLike[str] | str | bytes | bytearray | memoryview
    ) -> None:
        """Opens a pack and reads its directory.

        Args:
            source: A path, or the pack bytes.

        Raises:
            errors.FormatError: On a bad magic, unknown major version, CRC
                mismatch or invalid directory.
        """
        self._mm: mmap.mmap | None = None
        self._view: memoryview | None = None
        if isinstance(source, str | os.PathLike):
            with open(source, "rb") as f:
                if os.fstat(f.fileno()).st_size < HEADER_SIZE:
                    raise errors.FormatError(f"{source}: shorter than header")
                self._mm = mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ)
            self._view = memoryview(self._mm)
        else:
            self._view = memoryview(source).cast("B")
        try:
            self._entries = self._parse(self._view)
        except BaseException:
            self.close()
            raise

    @staticmethod
    def _parse(view: memoryview) -> dict[str, tuple[int, int]]:
        """Validates the header and returns ``path -> (offset, length)``."""
        if len(view) < HEADER_SIZE:
            raise errors.FormatError("pack shorter than its header")
        magic, major, _minor, n, _res, dir_off, dir_len, crc = (
            HEADER.unpack_from(view, 0)
        )
        if magic != MAGIC:
            raise errors.FormatError(f"bad pack magic {magic!r}")
        if major != MAJOR:
            raise errors.FormatError(f"unknown pack major version {major}")
        if dir_off + dir_len > len(view):
            raise errors.FormatError("pack directory is truncated")
        with view[dir_off : dir_off + dir_len] as raw:
            if zlib.crc32(raw) != crc:
                raise errors.FormatError("pack directory CRC mismatch")
            try:
                records = json.loads(bytes(raw))["entries"]
            except (ValueError, KeyError, TypeError) as exc:
                raise errors.FormatError(f"bad pack directory: {exc}") from exc
        entries: dict[str, tuple[int, int]] = {}
        for rec in records:
            path, off, length = rec["path"], rec["offset"], rec["length"]
            if (
                path.startswith("/")
                or ".." in path.split("/")
                or off + length > len(view)
            ):
                raise errors.FormatError(f"invalid pack entry {path!r}")
            entries[path] = (off, length)
        if len(entries) != n:
            raise errors.FormatError("pack entry count mismatch")
        return entries

    def paths(self) -> list[str]:
        """Returns all entry paths, sorted."""
        return sorted(self._entries)

    def __contains__(self, path: object) -> bool:
        """Tells whether an entry exists."""
        return path in self._entries

    def read(self, path: str) -> memoryview:
        """Returns an entry's bytes as a zero-copy view.

        Args:
            path: Entry path, for example ``runs/walk/rollout.json``.

        Returns:
            A read-only memoryview into the pack.

        Raises:
            KeyError: If the entry does not exist.
            ValueError: If the reader is closed.
        """
        if self._view is None:
            raise ValueError("pack reader is closed")
        off, length = self._entries[path]
        return self._view[off : off + length]

    def runs(self) -> list[str]:
        """Returns the names of the runs in the pack, sorted."""
        return sorted(
            p.split("/")[1]
            for p in self._entries
            if p.startswith("runs/")
            and p.endswith("/" + manifest.MANIFEST_NAME)
        )

    def manifest(self, run: str) -> manifest.RolloutManifest:
        """Parses a run's manifest.

        Args:
            run: Run name.

        Returns:
            The manifest.
        """
        with self.read(f"runs/{run}/{manifest.MANIFEST_NAME}") as raw:
            return manifest.RolloutManifest.from_json(json.loads(bytes(raw)))

    def _asset(self, ref: cas.Ref) -> bytes:
        """Returns a blob's bytes, checking the size."""
        with self.read(asset_path(ref)) as raw:
            if len(raw) != ref.size:
                raise errors.FormatError(f"size mismatch for {ref.sha256}")
            return bytes(raw)

    def scene(self, ref: cas.Ref) -> core.Scene:
        """Loads a scene and its assets from the pack.

        Args:
            ref: Reference from a manifest (``scene`` or ``env_scenes``).

        Returns:
            The scene. Transcoded meshes have no normals.
        """
        with self.read(scene_path(ref)) as raw:
            doc = json.loads(bytes(raw))
        return scene.scene_from_json(doc, self._asset)

    def stream(
        self, run: str, name: str, *, cache_blocks: int = 64
    ) -> blockfile.BlockReader:
        """Opens a stream of a run for random access.

        Args:
            run: Run name.
            name: Stream name from the run's manifest.
            cache_blocks: Decoded blocks to cache.

        Returns:
            A reader over the block file inside the pack, with the stream
            kind set so q16d poses are renormalized.
        """
        info = self.manifest(run).streams[name]
        return blockfile.BlockReader(
            self.read(f"runs/{run}/{info.file}"),
            cache_blocks=cache_blocks,
            kind=info.kind,
        )

    def close(self) -> None:
        """Releases the memory map. Safe to call more than once."""
        if self._view is not None:
            self._view.release()
            self._view = None
        if self._mm is not None:
            # Views still exported: the map closes when they are dropped.
            with contextlib.suppress(BufferError):
                self._mm.close()
            self._mm = None

    def __enter__(self) -> "PackReader":
        """Returns the reader."""
        return self

    def __exit__(
        self,
        exc_type: type[BaseException] | None,
        exc: BaseException | None,
        tb: TracebackType | None,
    ) -> None:
        """Closes the reader."""
        self.close()

__contains__(path: object) -> bool

Tells whether an entry exists.

Source code in src/simscope/io/pack.py
501
502
503
def __contains__(self, path: object) -> bool:
    """Tells whether an entry exists."""
    return path in self._entries

__enter__() -> PackReader

Returns the reader.

Source code in src/simscope/io/pack.py
596
597
598
def __enter__(self) -> "PackReader":
    """Returns the reader."""
    return self

__exit__(exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None) -> None

Closes the reader.

Source code in src/simscope/io/pack.py
600
601
602
603
604
605
606
607
def __exit__(
    self,
    exc_type: type[BaseException] | None,
    exc: BaseException | None,
    tb: TracebackType | None,
) -> None:
    """Closes the reader."""
    self.close()

__init__(source: os.PathLike[str] | str | bytes | bytearray | memoryview) -> None

Opens a pack and reads its directory.

Parameters:

Name Type Description Default
source PathLike[str] | str | bytes | bytearray | memoryview

A path, or the pack bytes.

required

Raises:

Type Description
FormatError

On a bad magic, unknown major version, CRC mismatch or invalid directory.

Source code in src/simscope/io/pack.py
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
def __init__(
    self, source: os.PathLike[str] | str | bytes | bytearray | memoryview
) -> None:
    """Opens a pack and reads its directory.

    Args:
        source: A path, or the pack bytes.

    Raises:
        errors.FormatError: On a bad magic, unknown major version, CRC
            mismatch or invalid directory.
    """
    self._mm: mmap.mmap | None = None
    self._view: memoryview | None = None
    if isinstance(source, str | os.PathLike):
        with open(source, "rb") as f:
            if os.fstat(f.fileno()).st_size < HEADER_SIZE:
                raise errors.FormatError(f"{source}: shorter than header")
            self._mm = mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ)
        self._view = memoryview(self._mm)
    else:
        self._view = memoryview(source).cast("B")
    try:
        self._entries = self._parse(self._view)
    except BaseException:
        self.close()
        raise

close() -> None

Releases the memory map. Safe to call more than once.

Source code in src/simscope/io/pack.py
585
586
587
588
589
590
591
592
593
594
def close(self) -> None:
    """Releases the memory map. Safe to call more than once."""
    if self._view is not None:
        self._view.release()
        self._view = None
    if self._mm is not None:
        # Views still exported: the map closes when they are dropped.
        with contextlib.suppress(BufferError):
            self._mm.close()
        self._mm = None

manifest(run: str) -> manifest.RolloutManifest

Parses a run's manifest.

Parameters:

Name Type Description Default
run str

Run name.

required

Returns:

Type Description
RolloutManifest

The manifest.

Source code in src/simscope/io/pack.py
532
533
534
535
536
537
538
539
540
541
542
def manifest(self, run: str) -> manifest.RolloutManifest:
    """Parses a run's manifest.

    Args:
        run: Run name.

    Returns:
        The manifest.
    """
    with self.read(f"runs/{run}/{manifest.MANIFEST_NAME}") as raw:
        return manifest.RolloutManifest.from_json(json.loads(bytes(raw)))

paths() -> list[str]

Returns all entry paths, sorted.

Source code in src/simscope/io/pack.py
497
498
499
def paths(self) -> list[str]:
    """Returns all entry paths, sorted."""
    return sorted(self._entries)

read(path: str) -> memoryview

Returns an entry's bytes as a zero-copy view.

Parameters:

Name Type Description Default
path str

Entry path, for example runs/walk/rollout.json.

required

Returns:

Type Description
memoryview

A read-only memoryview into the pack.

Raises:

Type Description
KeyError

If the entry does not exist.

ValueError

If the reader is closed.

Source code in src/simscope/io/pack.py
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
def read(self, path: str) -> memoryview:
    """Returns an entry's bytes as a zero-copy view.

    Args:
        path: Entry path, for example ``runs/walk/rollout.json``.

    Returns:
        A read-only memoryview into the pack.

    Raises:
        KeyError: If the entry does not exist.
        ValueError: If the reader is closed.
    """
    if self._view is None:
        raise ValueError("pack reader is closed")
    off, length = self._entries[path]
    return self._view[off : off + length]

runs() -> list[str]

Returns the names of the runs in the pack, sorted.

Source code in src/simscope/io/pack.py
523
524
525
526
527
528
529
530
def runs(self) -> list[str]:
    """Returns the names of the runs in the pack, sorted."""
    return sorted(
        p.split("/")[1]
        for p in self._entries
        if p.startswith("runs/")
        and p.endswith("/" + manifest.MANIFEST_NAME)
    )

scene(ref: cas.Ref) -> core.Scene

Loads a scene and its assets from the pack.

Parameters:

Name Type Description Default
ref Ref

Reference from a manifest (scene or env_scenes).

required

Returns:

Type Description
Scene

The scene. Transcoded meshes have no normals.

Source code in src/simscope/io/pack.py
551
552
553
554
555
556
557
558
559
560
561
562
def scene(self, ref: cas.Ref) -> core.Scene:
    """Loads a scene and its assets from the pack.

    Args:
        ref: Reference from a manifest (``scene`` or ``env_scenes``).

    Returns:
        The scene. Transcoded meshes have no normals.
    """
    with self.read(scene_path(ref)) as raw:
        doc = json.loads(bytes(raw))
    return scene.scene_from_json(doc, self._asset)

stream(run: str, name: str, *, cache_blocks: int = 64) -> blockfile.BlockReader

Opens a stream of a run for random access.

Parameters:

Name Type Description Default
run str

Run name.

required
name str

Stream name from the run's manifest.

required
cache_blocks int

Decoded blocks to cache.

64

Returns:

Type Description
BlockReader

A reader over the block file inside the pack, with the stream

BlockReader

kind set so q16d poses are renormalized.

Source code in src/simscope/io/pack.py
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
def stream(
    self, run: str, name: str, *, cache_blocks: int = 64
) -> blockfile.BlockReader:
    """Opens a stream of a run for random access.

    Args:
        run: Run name.
        name: Stream name from the run's manifest.
        cache_blocks: Decoded blocks to cache.

    Returns:
        A reader over the block file inside the pack, with the stream
        kind set so q16d poses are renormalized.
    """
    info = self.manifest(run).streams[name]
    return blockfile.BlockReader(
        self.read(f"runs/{run}/{info.file}"),
        cache_blocks=cache_blocks,
        kind=info.kind,
    )

Ref dataclass

A reference to a content-addressed file.

Attributes:

Name Type Description
sha256 str

64-character lowercase hex SHA-256 of the file bytes.

size int

File size in bytes.

Source code in src/simscope/io/cas.py
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
@dataclasses.dataclass(frozen=True)
class Ref:
    """A reference to a content-addressed file.

    Attributes:
        sha256: 64-character lowercase hex SHA-256 of the file bytes.
        size: File size in bytes.
    """

    sha256: str
    size: int

    def __post_init__(self) -> None:
        """Validates the hash and size.

        Raises:
            ValueError: If ``sha256`` is not 64 lowercase hex characters or
                ``size`` is negative.
        """
        if not _HEX_RE.match(self.sha256):
            raise ValueError(f"invalid sha256 {self.sha256!r}")
        if self.size < 0:
            raise ValueError(f"invalid size {self.size}")

    def to_json(self) -> dict[str, Any]:
        """Returns the spec's ``{"sha256", "size"}`` object."""
        return {"sha256": self.sha256, "size": self.size}

    @classmethod
    def from_json(cls, obj: dict[str, Any]) -> "Ref":
        """Parses a reference object, ignoring unknown fields.

        Args:
            obj: A mapping with ``sha256`` and ``size``.

        Returns:
            The reference.

        Raises:
            errors.FormatError: If a field is missing or malformed.
        """
        try:
            return cls(str(obj["sha256"]), int(obj["size"]))
        except (KeyError, TypeError, ValueError) as exc:
            raise errors.FormatError(f"bad content reference: {obj!r}") from exc

    @classmethod
    def of(cls, data: bytes | bytearray | memoryview) -> "Ref":
        """Computes the reference of some bytes.

        Args:
            data: The file contents.

        Returns:
            The reference.
        """
        return cls(hashlib.sha256(data).hexdigest(), memoryview(data).nbytes)

__post_init__() -> None

Validates the hash and size.

Raises:

Type Description
ValueError

If sha256 is not 64 lowercase hex characters or size is negative.

Source code in src/simscope/io/cas.py
37
38
39
40
41
42
43
44
45
46
47
def __post_init__(self) -> None:
    """Validates the hash and size.

    Raises:
        ValueError: If ``sha256`` is not 64 lowercase hex characters or
            ``size`` is negative.
    """
    if not _HEX_RE.match(self.sha256):
        raise ValueError(f"invalid sha256 {self.sha256!r}")
    if self.size < 0:
        raise ValueError(f"invalid size {self.size}")

from_json(obj: dict[str, Any]) -> Ref classmethod

Parses a reference object, ignoring unknown fields.

Parameters:

Name Type Description Default
obj dict[str, Any]

A mapping with sha256 and size.

required

Returns:

Type Description
Ref

The reference.

Raises:

Type Description
FormatError

If a field is missing or malformed.

Source code in src/simscope/io/cas.py
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
@classmethod
def from_json(cls, obj: dict[str, Any]) -> "Ref":
    """Parses a reference object, ignoring unknown fields.

    Args:
        obj: A mapping with ``sha256`` and ``size``.

    Returns:
        The reference.

    Raises:
        errors.FormatError: If a field is missing or malformed.
    """
    try:
        return cls(str(obj["sha256"]), int(obj["size"]))
    except (KeyError, TypeError, ValueError) as exc:
        raise errors.FormatError(f"bad content reference: {obj!r}") from exc

of(data: bytes | bytearray | memoryview) -> Ref classmethod

Computes the reference of some bytes.

Parameters:

Name Type Description Default
data bytes | bytearray | memoryview

The file contents.

required

Returns:

Type Description
Ref

The reference.

Source code in src/simscope/io/cas.py
71
72
73
74
75
76
77
78
79
80
81
@classmethod
def of(cls, data: bytes | bytearray | memoryview) -> "Ref":
    """Computes the reference of some bytes.

    Args:
        data: The file contents.

    Returns:
        The reference.
    """
    return cls(hashlib.sha256(data).hexdigest(), memoryview(data).nbytes)

to_json() -> dict[str, Any]

Returns the spec's {"sha256", "size"} object.

Source code in src/simscope/io/cas.py
49
50
51
def to_json(self) -> dict[str, Any]:
    """Returns the spec's ``{"sha256", "size"}`` object."""
    return {"sha256": self.sha256, "size": self.size}

RolloutManifest dataclass

The manifest of one rollout.

Attributes:

Name Type Description
id str

ULID, fixed at creation.

name str

Run name, equal to the run directory name.

created str

UTC creation time, YYYY-MM-DDTHH:MM:SSZ.

status str

"recording" or "complete".

dt float

Seconds between frames.

n_frames int

Frames per stream.

n_envs int

Envs per stream.

n_bodies int

Bodies per pose.

scene Ref

Reference to the scene descriptor.

streams dict[str, StreamInfo]

Streams by name. body_pose is required.

env_scenes tuple[Ref, ...] | None

Optional per-env scene references.

env_origins tuple[Vec3, ...] | None

World offset per env, n_envs triples.

source dict[str, Any]

Provenance (simulator and version).

tags tuple[str, ...]

Free-form tags set at record time.

meta dict[str, Any]

Free-form metadata set at record time.

Source code in src/simscope/io/manifest.py
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
@dataclasses.dataclass
class RolloutManifest:
    """The manifest of one rollout.

    Attributes:
        id: ULID, fixed at creation.
        name: Run name, equal to the run directory name.
        created: UTC creation time, ``YYYY-MM-DDTHH:MM:SSZ``.
        status: ``"recording"`` or ``"complete"``.
        dt: Seconds between frames.
        n_frames: Frames per stream.
        n_envs: Envs per stream.
        n_bodies: Bodies per pose.
        scene: Reference to the scene descriptor.
        streams: Streams by name. ``body_pose`` is required.
        env_scenes: Optional per-env scene references.
        env_origins: World offset per env, ``n_envs`` triples.
        source: Provenance (simulator and version).
        tags: Free-form tags set at record time.
        meta: Free-form metadata set at record time.
    """

    id: str
    name: str
    created: str
    dt: float
    n_frames: int
    n_envs: int
    n_bodies: int
    scene: cas.Ref
    streams: dict[str, StreamInfo]
    status: str = "complete"
    env_scenes: tuple[cas.Ref, ...] | None = None
    env_origins: tuple[core.Vec3, ...] | None = None
    source: dict[str, Any] = dataclasses.field(default_factory=dict)
    tags: tuple[str, ...] = ()
    meta: dict[str, Any] = dataclasses.field(default_factory=dict)

    def to_json(self) -> dict[str, Any]:
        """Returns the JSON object of this manifest."""
        origins = self.env_origins or ((0.0, 0.0, 0.0),) * self.n_envs
        return {
            "format": ROLLOUT_FORMAT,
            "id": self.id,
            "name": self.name,
            "created": self.created,
            "status": self.status,
            "dt": self.dt,
            "n_frames": self.n_frames,
            "n_envs": self.n_envs,
            "n_bodies": self.n_bodies,
            "scene": self.scene.to_json(),
            "env_scenes": (
                None
                if self.env_scenes is None
                else [r.to_json() for r in self.env_scenes]
            ),
            "env_origins": [list(o) for o in origins],
            "streams": {k: v.to_json() for k, v in self.streams.items()},
            "source": self.source,
            "tags": list(self.tags),
            "meta": self.meta,
        }

    @classmethod
    def from_json(cls, obj: dict[str, Any]) -> "RolloutManifest":
        """Parses and validates a manifest, ignoring unknown fields.

        Args:
            obj: The parsed ``rollout.json``.

        Returns:
            The manifest.

        Raises:
            errors.FormatError: On an unknown format or major version, a
                missing field, or an invalid ``body_pose`` stream.
        """
        fmt = obj.get("format")
        if not isinstance(fmt, str) or not fmt.startswith("simscope-rollout/"):
            raise errors.FormatError(f"not a rollout manifest: {fmt!r}")
        if fmt.split("/", 1)[1].split(".")[0] != "1":
            raise errors.FormatError(f"unknown manifest version {fmt!r}")
        try:
            env_scenes = obj.get("env_scenes")
            origins = obj.get("env_origins")
            man = cls(
                id=str(obj["id"]),
                name=str(obj["name"]),
                created=str(obj["created"]),
                status=str(obj["status"]),
                dt=float(obj["dt"]),
                n_frames=int(obj["n_frames"]),
                n_envs=int(obj["n_envs"]),
                n_bodies=int(obj["n_bodies"]),
                scene=cas.Ref.from_json(obj["scene"]),
                streams={
                    str(k): StreamInfo.from_json(v)
                    for k, v in obj["streams"].items()
                },
                env_scenes=(
                    None
                    if env_scenes is None
                    else tuple(cas.Ref.from_json(r) for r in env_scenes)
                ),
                env_origins=(
                    None
                    if origins is None
                    else tuple(_vec3(o) for o in origins)
                ),
                source=dict(obj.get("source") or {}),
                tags=tuple(str(t) for t in obj.get("tags") or ()),
                meta=dict(obj.get("meta") or {}),
            )
        except (KeyError, TypeError, ValueError, AttributeError) as exc:
            if isinstance(exc, errors.FormatError):
                raise
            raise errors.FormatError(f"malformed manifest: {exc!r}") from exc
        man.validate()
        return man

    def validate(self) -> None:
        """Checks internal consistency.

        Raises:
            errors.FormatError: If the run name, status, env data or the
                required ``body_pose`` stream is invalid.
        """
        try:
            validate_run_name(self.name)
        except ValueError as exc:
            raise errors.FormatError(str(exc)) from exc
        if self.status not in ("recording", "complete"):
            raise errors.FormatError(f"invalid status {self.status!r}")
        pose = self.streams.get(BODY_POSE)
        if pose is None:
            raise errors.FormatError("manifest has no body_pose stream")
        if pose.kind != "pose" or pose.item_shape != (
            self.n_bodies,
            core.POSE_DIM,
        ):
            raise errors.FormatError("body_pose must be a pose [n_bodies, 7]")
        if self.env_scenes is not None and len(self.env_scenes) != self.n_envs:
            raise errors.FormatError("env_scenes must have n_envs entries")
        if (
            self.env_origins is not None
            and len(self.env_origins) != self.n_envs
        ):
            raise errors.FormatError("env_origins must have n_envs entries")

from_json(obj: dict[str, Any]) -> RolloutManifest classmethod

Parses and validates a manifest, ignoring unknown fields.

Parameters:

Name Type Description Default
obj dict[str, Any]

The parsed rollout.json.

required

Returns:

Type Description
RolloutManifest

The manifest.

Raises:

Type Description
FormatError

On an unknown format or major version, a missing field, or an invalid body_pose stream.

Source code in src/simscope/io/manifest.py
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
@classmethod
def from_json(cls, obj: dict[str, Any]) -> "RolloutManifest":
    """Parses and validates a manifest, ignoring unknown fields.

    Args:
        obj: The parsed ``rollout.json``.

    Returns:
        The manifest.

    Raises:
        errors.FormatError: On an unknown format or major version, a
            missing field, or an invalid ``body_pose`` stream.
    """
    fmt = obj.get("format")
    if not isinstance(fmt, str) or not fmt.startswith("simscope-rollout/"):
        raise errors.FormatError(f"not a rollout manifest: {fmt!r}")
    if fmt.split("/", 1)[1].split(".")[0] != "1":
        raise errors.FormatError(f"unknown manifest version {fmt!r}")
    try:
        env_scenes = obj.get("env_scenes")
        origins = obj.get("env_origins")
        man = cls(
            id=str(obj["id"]),
            name=str(obj["name"]),
            created=str(obj["created"]),
            status=str(obj["status"]),
            dt=float(obj["dt"]),
            n_frames=int(obj["n_frames"]),
            n_envs=int(obj["n_envs"]),
            n_bodies=int(obj["n_bodies"]),
            scene=cas.Ref.from_json(obj["scene"]),
            streams={
                str(k): StreamInfo.from_json(v)
                for k, v in obj["streams"].items()
            },
            env_scenes=(
                None
                if env_scenes is None
                else tuple(cas.Ref.from_json(r) for r in env_scenes)
            ),
            env_origins=(
                None
                if origins is None
                else tuple(_vec3(o) for o in origins)
            ),
            source=dict(obj.get("source") or {}),
            tags=tuple(str(t) for t in obj.get("tags") or ()),
            meta=dict(obj.get("meta") or {}),
        )
    except (KeyError, TypeError, ValueError, AttributeError) as exc:
        if isinstance(exc, errors.FormatError):
            raise
        raise errors.FormatError(f"malformed manifest: {exc!r}") from exc
    man.validate()
    return man

to_json() -> dict[str, Any]

Returns the JSON object of this manifest.

Source code in src/simscope/io/manifest.py
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
def to_json(self) -> dict[str, Any]:
    """Returns the JSON object of this manifest."""
    origins = self.env_origins or ((0.0, 0.0, 0.0),) * self.n_envs
    return {
        "format": ROLLOUT_FORMAT,
        "id": self.id,
        "name": self.name,
        "created": self.created,
        "status": self.status,
        "dt": self.dt,
        "n_frames": self.n_frames,
        "n_envs": self.n_envs,
        "n_bodies": self.n_bodies,
        "scene": self.scene.to_json(),
        "env_scenes": (
            None
            if self.env_scenes is None
            else [r.to_json() for r in self.env_scenes]
        ),
        "env_origins": [list(o) for o in origins],
        "streams": {k: v.to_json() for k, v in self.streams.items()},
        "source": self.source,
        "tags": list(self.tags),
        "meta": self.meta,
    }

validate() -> None

Checks internal consistency.

Raises:

Type Description
FormatError

If the run name, status, env data or the required body_pose stream is invalid.

Source code in src/simscope/io/manifest.py
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
def validate(self) -> None:
    """Checks internal consistency.

    Raises:
        errors.FormatError: If the run name, status, env data or the
            required ``body_pose`` stream is invalid.
    """
    try:
        validate_run_name(self.name)
    except ValueError as exc:
        raise errors.FormatError(str(exc)) from exc
    if self.status not in ("recording", "complete"):
        raise errors.FormatError(f"invalid status {self.status!r}")
    pose = self.streams.get(BODY_POSE)
    if pose is None:
        raise errors.FormatError("manifest has no body_pose stream")
    if pose.kind != "pose" or pose.item_shape != (
        self.n_bodies,
        core.POSE_DIM,
    ):
        raise errors.FormatError("body_pose must be a pose [n_bodies, 7]")
    if self.env_scenes is not None and len(self.env_scenes) != self.n_envs:
        raise errors.FormatError("env_scenes must have n_envs entries")
    if (
        self.env_origins is not None
        and len(self.env_origins) != self.n_envs
    ):
        raise errors.FormatError("env_origins must have n_envs entries")

StreamInfo dataclass

One stream entry of a manifest.

Attributes:

Name Type Description
file str

Block file name inside the run directory.

kind StreamKind

How viewers draw the stream.

item_shape tuple[int, ...]

Shape of one env-frame item.

labels tuple[str, ...] | None

Optional names for vector components.

units str | None

Optional unit string.

scale float | None

Optional metres drawn per unit of an arrows vector. Viewers use 1 when it is None.

Source code in src/simscope/io/manifest.py
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
@dataclasses.dataclass
class StreamInfo:
    """One stream entry of a manifest.

    Attributes:
        file: Block file name inside the run directory.
        kind: How viewers draw the stream.
        item_shape: Shape of one env-frame item.
        labels: Optional names for vector components.
        units: Optional unit string.
        scale: Optional metres drawn per unit of an ``arrows`` vector.
            Viewers use 1 when it is ``None``.
    """

    file: str
    kind: core.StreamKind
    item_shape: tuple[int, ...] = ()
    labels: tuple[str, ...] | None = None
    units: str | None = None
    scale: float | None = None

    def to_json(self) -> dict[str, Any]:
        """Returns the JSON object of this stream."""
        out: dict[str, Any] = {
            "file": self.file,
            "kind": self.kind,
            "item_shape": list(self.item_shape),
        }
        if self.labels is not None:
            out["labels"] = list(self.labels)
        if self.units is not None:
            out["units"] = self.units
        if self.scale is not None:
            out["scale"] = self.scale
        return out

    @classmethod
    def from_json(cls, obj: dict[str, Any]) -> "StreamInfo":
        """Parses a stream object, ignoring unknown fields.

        Raises:
            errors.FormatError: If a field is missing or invalid.
        """
        try:
            kind = obj["kind"]
            if kind not in core.STREAM_KINDS:
                raise errors.FormatError(f"unknown stream kind {kind!r}")
            labels = obj.get("labels")
            return cls(
                str(obj["file"]),
                kind,
                tuple(int(d) for d in obj["item_shape"]),
                None if labels is None else tuple(str(x) for x in labels),
                obj.get("units"),
                None if obj.get("scale") is None else float(obj["scale"]),
            )
        except errors.FormatError:
            raise
        except (KeyError, TypeError, ValueError) as exc:
            raise errors.FormatError(f"bad stream entry: {obj!r}") from exc

from_json(obj: dict[str, Any]) -> StreamInfo classmethod

Parses a stream object, ignoring unknown fields.

Raises:

Type Description
FormatError

If a field is missing or invalid.

Source code in src/simscope/io/manifest.py
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
@classmethod
def from_json(cls, obj: dict[str, Any]) -> "StreamInfo":
    """Parses a stream object, ignoring unknown fields.

    Raises:
        errors.FormatError: If a field is missing or invalid.
    """
    try:
        kind = obj["kind"]
        if kind not in core.STREAM_KINDS:
            raise errors.FormatError(f"unknown stream kind {kind!r}")
        labels = obj.get("labels")
        return cls(
            str(obj["file"]),
            kind,
            tuple(int(d) for d in obj["item_shape"]),
            None if labels is None else tuple(str(x) for x in labels),
            obj.get("units"),
            None if obj.get("scale") is None else float(obj["scale"]),
        )
    except errors.FormatError:
        raise
    except (KeyError, TypeError, ValueError) as exc:
        raise errors.FormatError(f"bad stream entry: {obj!r}") from exc

to_json() -> dict[str, Any]

Returns the JSON object of this stream.

Source code in src/simscope/io/manifest.py
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
def to_json(self) -> dict[str, Any]:
    """Returns the JSON object of this stream."""
    out: dict[str, Any] = {
        "file": self.file,
        "kind": self.kind,
        "item_shape": list(self.item_shape),
    }
    if self.labels is not None:
        out["labels"] = list(self.labels)
    if self.units is not None:
        out["units"] = self.units
    if self.scale is not None:
        out["scale"] = self.scale
    return out

load_scene(store: cas.ContentStore, ref: cas.Ref) -> core.Scene

Loads a scene and its assets from a content store.

Parameters:

Name Type Description Default
store ContentStore

The content store of the library.

required
ref Ref

Reference of the scene JSON.

required

Returns:

Type Description
Scene

The scene.

Raises:

Type Description
FormatError

If the scene or an asset is malformed.

FileNotFoundError

If a referenced file is missing.

Source code in src/simscope/io/scene.py
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
def load_scene(store: cas.ContentStore, ref: cas.Ref) -> core.Scene:
    """Loads a scene and its assets from a content store.

    Args:
        store: The content store of the library.
        ref: Reference of the scene JSON.

    Returns:
        The scene.

    Raises:
        errors.FormatError: If the scene or an asset is malformed.
        FileNotFoundError: If a referenced file is missing.
    """
    doc = json.loads(store.get(ref, "scene"))
    return scene_from_json(doc, store.get)

new_ulid(now_ms: int | None = None) -> str

Generates a ULID: 48-bit ms timestamp plus 80 random bits.

Parameters:

Name Type Description Default
now_ms int | None

Unix time in milliseconds, or None for the current time.

None

Returns:

Type Description
str

26 Crockford base32 characters.

Source code in src/simscope/io/manifest.py
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
def new_ulid(now_ms: int | None = None) -> str:
    """Generates a ULID: 48-bit ms timestamp plus 80 random bits.

    Args:
        now_ms: Unix time in milliseconds, or ``None`` for the current time.

    Returns:
        26 Crockford base32 characters.
    """
    ms = time.time_ns() // 1_000_000 if now_ms is None else now_ms
    if not 0 <= ms < 1 << 48:
        raise ValueError("timestamp does not fit in 48 bits")
    value = (ms << 80) | int.from_bytes(os.urandom(10), "big")
    return "".join(
        _CROCKFORD[(value >> (5 * i)) & 31] for i in range(25, -1, -1)
    )

put_scene(store: cas.ContentStore, scene: core.Scene) -> cas.Ref

Stores a scene: raw mesh blobs, texture blobs, then the descriptor.

Parameters:

Name Type Description Default
store ContentStore

The content store of the library.

required
scene Scene

The scene to store.

required

Returns:

Type Description
Ref

The reference of the canonical scene JSON under scenes/. Equal

Ref

scenes give equal references.

Source code in src/simscope/io/scene.py
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
def put_scene(store: cas.ContentStore, scene: core.Scene) -> cas.Ref:
    """Stores a scene: raw mesh blobs, texture blobs, then the descriptor.

    Args:
        store: The content store of the library.
        scene: The scene to store.

    Returns:
        The reference of the canonical scene JSON under ``scenes/``. Equal
        scenes give equal references.
    """
    mesh_refs = [store.put(codecs.encode_mesh(m, "raw")) for m in scene.meshes]
    tex_refs = [store.put(t.data) for t in scene.textures]
    doc = scene_to_json(scene, mesh_refs, tex_refs)
    return store.put(canonical_bytes(doc), "scene")

read_manifest(run_dir: os.PathLike[str] | str) -> RolloutManifest

Reads a run's manifest.

Prefers rollout.json; falls back to rollout.json.partial for a run that is still recording (or crashed).

Parameters:

Name Type Description Default
run_dir PathLike[str] | str

The run directory.

required

Returns:

Type Description
RolloutManifest

The manifest.

Raises:

Type Description
FileNotFoundError

If neither file exists.

FormatError

If the manifest is invalid.

Source code in src/simscope/io/manifest.py
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
def read_manifest(run_dir: os.PathLike[str] | str) -> RolloutManifest:
    """Reads a run's manifest.

    Prefers ``rollout.json``; falls back to ``rollout.json.partial`` for a
    run that is still recording (or crashed).

    Args:
        run_dir: The run directory.

    Returns:
        The manifest.

    Raises:
        FileNotFoundError: If neither file exists.
        errors.FormatError: If the manifest is invalid.
    """
    run_dir = pathlib.Path(run_dir)
    path = run_dir / MANIFEST_NAME
    if not path.exists():
        path = run_dir / PARTIAL_NAME
    try:
        obj = json.loads(path.read_bytes())
    except json.JSONDecodeError as exc:
        raise errors.FormatError(f"{path}: invalid JSON: {exc}") from exc
    return RolloutManifest.from_json(obj)

recover(path: os.PathLike[str] | str) -> RecoverResult

Rebuilds the directory of an unfinished block file.

Walks blocks from offset 64, keeps every complete time window (a block for every env) whose header and CRC check out, truncates the rest, then appends the directory and rewrites the header. A finished file is left untouched.

Parameters:

Name Type Description Default
path PathLike[str] | str

The block file.

required

Returns:

Type Description
RecoverResult

What was kept and dropped.

Raises:

Type Description
FormatError

If the file header is missing or corrupt.

Source code in src/simscope/io/blockfile.py
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
def recover(path: os.PathLike[str] | str) -> RecoverResult:
    """Rebuilds the directory of an unfinished block file.

    Walks blocks from offset 64, keeps every complete time window (a block
    for every env) whose header and CRC check out, truncates the rest, then
    appends the directory and rewrites the header. A finished file is left
    untouched.

    Args:
        path: The block file.

    Returns:
        What was kept and dropped.

    Raises:
        errors.FormatError: If the file header is missing or corrupt.
    """
    path = pathlib.Path(path)
    with open(path, "r+b") as f:
        size = os.fstat(f.fileno()).st_size
        if size < HEADER_SIZE:
            raise errors.FormatError(f"{path}: shorter than header")
        with mmap.mmap(f.fileno(), 0) as mm, memoryview(mm) as view:
            h = Header.parse(view)
            if h.dir_offset != 0:
                blocks = h.n_blocks
                return RecoverResult(h.n_frames, blocks, 0, True)
            entries, frames, end = _scan_blocks(view, h)
        dropped = max(0, size - end)
        f.truncate(end)
        directory = _dir_bytes(entries)
        f.seek(end)
        f.write(directory)
        header = dataclasses.replace(
            h,
            n_frames=frames,
            n_blocks=len(entries),
            dir_offset=end,
            dir_length=len(directory),
        )
        f.seek(0)
        f.write(header.pack())
    return RecoverResult(frames, len(entries), dropped)

validate_run_name(name: str) -> str

Checks a run name against ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$.

Parameters:

Name Type Description Default
name str

Candidate run name.

required

Returns:

Type Description
str

The name, unchanged.

Raises:

Type Description
ValueError

If the name is not valid.

Source code in src/simscope/io/manifest.py
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
def validate_run_name(name: str) -> str:
    """Checks a run name against ``^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$``.

    Args:
        name: Candidate run name.

    Returns:
        The name, unchanged.

    Raises:
        ValueError: If the name is not valid.
    """
    if not isinstance(name, str) or not _RUN_NAME_RE.fullmatch(name):
        raise ValueError(
            f"invalid run name {name!r}: must match "
            "[A-Za-z0-9][A-Za-z0-9._-]{0,127}"
        )
    return name

write_manifest(run_dir: os.PathLike[str] | str, manifest: RolloutManifest, *, partial: bool) -> pathlib.Path

Writes a manifest atomically.

With partial=True the file is rollout.json.partial and the status must be "recording". With partial=False the file is rollout.json, the status must be "complete", and any .partial file is deleted afterwards.

Parameters:

Name Type Description Default
run_dir PathLike[str] | str

The run directory (created if missing).

required
manifest RolloutManifest

The manifest.

required
partial bool

Whether this is an in-progress manifest.

required

Returns:

Type Description
Path

The path written.

Raises:

Type Description
ValueError

If the status does not match partial.

FormatError

If the manifest is inconsistent.

Source code in src/simscope/io/manifest.py
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
def write_manifest(
    run_dir: os.PathLike[str] | str,
    manifest: RolloutManifest,
    *,
    partial: bool,
) -> pathlib.Path:
    """Writes a manifest atomically.

    With ``partial=True`` the file is ``rollout.json.partial`` and the status
    must be ``"recording"``. With ``partial=False`` the file is
    ``rollout.json``, the status must be ``"complete"``, and any ``.partial``
    file is deleted afterwards.

    Args:
        run_dir: The run directory (created if missing).
        manifest: The manifest.
        partial: Whether this is an in-progress manifest.

    Returns:
        The path written.

    Raises:
        ValueError: If the status does not match ``partial``.
        errors.FormatError: If the manifest is inconsistent.
    """
    expected = "recording" if partial else "complete"
    if manifest.status != expected:
        raise ValueError(
            f"status is {manifest.status!r} but partial={partial} needs "
            f"{expected!r}"
        )
    manifest.validate()
    run_dir = pathlib.Path(run_dir)
    path = run_dir / (PARTIAL_NAME if partial else MANIFEST_NAME)
    cas.atomic_write(path, manifest_bytes(manifest))
    if not partial:
        cas.remove_file(run_dir / PARTIAL_NAME)
    return path

write_pack(library_root: os.PathLike[str] | str, run_names: list[str], out_path: os.PathLike[str] | str, *, transcode: bool = True, annotations: bool = True, posters: bool = True, envs: Sequence[int] | None = None, derived: Mapping[str, Source] | None = None) -> pathlib.Path

Writes runs and everything they reference into one pack file.

With transcode=True (the export default), pose streams are re-encoded with q16d, raw meshes are re-encoded as q16, and scenes are rewritten with the new mesh references (so their hashes change). Other streams and textures are copied unchanged. Each asset is stored once, however many runs share it. The library is not modified.

Parameters:

Name Type Description Default
library_root PathLike[str] | str

The library directory, or a folder that contains libraries. A run is taken from the nested library that holds it when it is not a direct child of library_root/runs.

required
run_names list[str]

Runs to include (each must be complete).

required
out_path PathLike[str] | str

The pack file to write (replaced atomically).

required
transcode bool

Whether to shrink the pack as described above.

True
annotations bool

Whether to include each run's annotations.json.

True
posters bool

Whether to include each run's poster.png.

True
envs Sequence[int] | None

Keep only these envs of every run, in this order. Streams are re-encoded for them (the source codec, or q16d poses with transcode), env_origins and env_scenes are cut to them, and the events of other envs leave annotations.json.

None
derived Mapping[str, Source] | None

Extra entries by pack path, all under derived/ (contracts 1). A pack that has any records minor version 1.

None

Returns:

Type Description
Path

The output path.

Raises:

Type Description
ValueError

On duplicate run names, a run that is still recording, an invalid envs for a run, or a derived path outside derived/.

FileNotFoundError

If a run or referenced file is missing.

Source code in src/simscope/io/pack.py
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
def write_pack(
    library_root: os.PathLike[str] | str,
    run_names: list[str],
    out_path: os.PathLike[str] | str,
    *,
    transcode: bool = True,
    annotations: bool = True,
    posters: bool = True,
    envs: Sequence[int] | None = None,
    derived: Mapping[str, Source] | None = None,
) -> pathlib.Path:
    """Writes runs and everything they reference into one pack file.

    With ``transcode=True`` (the export default), pose streams are re-encoded
    with ``q16d``, raw meshes are re-encoded as q16, and scenes are rewritten
    with the new mesh references (so their hashes change). Other streams and
    textures are copied unchanged. Each asset is stored once, however many
    runs share it. The library is not modified.

    Args:
        library_root: The library directory, or a folder that contains
            libraries. A run is taken from the nested library that holds it
            when it is not a direct child of ``library_root/runs``.
        run_names: Runs to include (each must be complete).
        out_path: The pack file to write (replaced atomically).
        transcode: Whether to shrink the pack as described above.
        annotations: Whether to include each run's ``annotations.json``.
        posters: Whether to include each run's ``poster.png``.
        envs: Keep only these envs of every run, in this order. Streams are
            re-encoded for them (the source codec, or q16d poses with
            ``transcode``), ``env_origins`` and ``env_scenes`` are cut to
            them, and the events of other envs leave ``annotations.json``.
        derived: Extra entries by pack path, all under ``derived/``
            (contracts 1). A pack that has any records minor version 1.

    Returns:
        The output path.

    Raises:
        ValueError: On duplicate run names, a run that is still recording,
            an invalid ``envs`` for a run, or a ``derived`` path outside
            ``derived/``.
        FileNotFoundError: If a run or referenced file is missing.
    """
    root = pathlib.Path(library_root)
    out_path = pathlib.Path(out_path)
    if len(set(run_names)) != len(run_names):
        raise ValueError("duplicate run names")
    for path in derived or {}:
        if not path.startswith(DERIVED_PREFIX) or ".." in path.split("/"):
            raise ValueError(f"derived entries live under derived/: {path!r}")
    col = _Collector(cas.ContentStore(root), transcode)
    found = discover.by_name(root)
    stores = {root: col.store}
    with tempfile.TemporaryDirectory(prefix="simscope-pack-") as tmp_name:
        tmp = pathlib.Path(tmp_name)
        for name in run_names:
            hit = found.get(name)
            lib_root = root if hit is None else hit.library
            store = stores.get(lib_root)
            if store is None:
                store = cas.ContentStore(lib_root)
                stores[lib_root] = store
            _add_run(
                col,
                lib_root,
                manifest.validate_run_name(name),
                tmp,
                sidecars=_sidecars(annotations=annotations, posters=posters),
                envs=envs,
                store=store,
            )
        col.entries.update(derived or {})
        out_path.parent.mkdir(parents=True, exist_ok=True)
        fd, tmp_out = cas.create_temp(out_path.parent)
        try:
            with os.fdopen(fd, "wb") as out:
                out.write(bytes(HEADER_SIZE))
                records = _write_entries(out, col.entries)
                out.write(bytes(-out.tell() % 8))
                dir_offset = out.tell()
                directory = json.dumps(
                    {"entries": records},
                    separators=(",", ":"),
                    ensure_ascii=False,
                ).encode("utf-8")
                out.write(directory)
                out.seek(0)
                out.write(
                    HEADER.pack(
                        MAGIC,
                        MAJOR,
                        MINOR_DERIVED if derived else MINOR,
                        len(records),
                        0,
                        dir_offset,
                        len(directory),
                        zlib.crc32(directory),
                    )
                )
            os.replace(tmp_out, out_path)
        except BaseException:
            pathlib.Path(tmp_out).unlink(missing_ok=True)
            raise
    return out_path