simscope 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. simscope/__init__.py +6 -0
  2. simscope/__main__.py +8 -0
  3. simscope/_assets/simscope-app.css +2 -0
  4. simscope/_assets/simscope-app.js +4311 -0
  5. simscope/_assets/simscope-player.js +4325 -0
  6. simscope/_assets/simscope-web.LICENSES.txt +407 -0
  7. simscope/_icon.py +22 -0
  8. simscope/_mjviser.py +203 -0
  9. simscope/annotations.py +1132 -0
  10. simscope/cli.py +482 -0
  11. simscope/core.py +257 -0
  12. simscope/derived.py +697 -0
  13. simscope/export.py +799 -0
  14. simscope/highlights.py +947 -0
  15. simscope/importers.py +874 -0
  16. simscope/index.py +579 -0
  17. simscope/io/__init__.py +45 -0
  18. simscope/io/blockfile.py +938 -0
  19. simscope/io/cas.py +294 -0
  20. simscope/io/codecs.py +566 -0
  21. simscope/io/errors.py +9 -0
  22. simscope/io/manifest.py +358 -0
  23. simscope/io/pack.py +563 -0
  24. simscope/io/scene.py +239 -0
  25. simscope/isaaclab.py +1460 -0
  26. simscope/library.py +705 -0
  27. simscope/mujoco.py +578 -0
  28. simscope/py.typed +0 -0
  29. simscope/recorder.py +784 -0
  30. simscope/server/__init__.py +9 -0
  31. simscope/server/app.py +149 -0
  32. simscope/server/blocks.py +191 -0
  33. simscope/server/jobs.py +166 -0
  34. simscope/server/routes.py +707 -0
  35. simscope/server/security.py +218 -0
  36. simscope/server/state.py +751 -0
  37. simscope/server/static.py +84 -0
  38. simscope/transforms.py +147 -0
  39. simscope-0.1.1.dist-info/METADATA +132 -0
  40. simscope-0.1.1.dist-info/RECORD +45 -0
  41. simscope-0.1.1.dist-info/WHEEL +4 -0
  42. simscope-0.1.1.dist-info/entry_points.txt +3 -0
  43. simscope-0.1.1.dist-info/licenses/LICENSE.md +201 -0
  44. simscope-0.1.1.dist-info/licenses/THIRD_PARTY_NOTICES.md +267 -0
  45. simscope-0.1.1.dist-info/licenses/src/simscope/_assets/simscope-web.LICENSES.txt +407 -0
@@ -0,0 +1,1132 @@
1
+ """Curation sidecars, the event-type registry and run groups (annotations v1).
2
+
3
+ ``runs/<name>/annotations.json`` holds marks, notes, ratings, events and
4
+ spatial annotations for one run. ``.simscope/event_types.json`` holds the
5
+ library's event vocabulary and ``.simscope/groups.json`` the ordered list of
6
+ run groups (a run belongs to at most one, ``marks.group``). All are
7
+ pretty-printed with sorted keys and written atomically, so git can diff and
8
+ merge them (decision D8).
9
+
10
+ Every record carries a ULID ``id`` plus ``author``, ``created`` and
11
+ ``updated``. Lists are sorted by ``id`` when written, so two people's
12
+ appends merge cleanly; :func:`merge` is the programmatic equivalent.
13
+ """
14
+
15
+ import copy
16
+ import dataclasses
17
+ import getpass
18
+ import json
19
+ import logging
20
+ import math
21
+ import os
22
+ import pathlib
23
+ import re
24
+ from typing import Any, Literal, Self, cast
25
+
26
+ from simscope.io import cas, errors, manifest
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ ANNOTATIONS_FORMAT = "simscope-annotations/1"
31
+ EVENT_TYPES_FORMAT = "simscope-event-types/1"
32
+ GROUPS_FORMAT = "simscope-groups/1"
33
+ FILE_NAME = "annotations.json"
34
+ MAX_GROUP_NAME = 64
35
+ """Longest group name, in characters."""
36
+ DEFAULT_CRITERION = "overall"
37
+
38
+ RatingScale = Literal["stars5", "score100", "thumb"]
39
+ RATING_SCALES: tuple[RatingScale, ...] = ("stars5", "score100", "thumb")
40
+ _SCALE_RANGES: dict[str, tuple[float, float]] = {
41
+ "stars5": (1, 5),
42
+ "score100": (0, 100),
43
+ "thumb": (-1, 1),
44
+ }
45
+ SPATIAL_KINDS = ("point", "box")
46
+ PROP_TYPES = ("text", "number", "bool", "select")
47
+
48
+ _COLOR_RE = re.compile(r"^#[0-9a-fA-F]{6}$")
49
+ _FRAME_RE = re.compile(r"^(world|body:\d+)$")
50
+
51
+
52
+ def default_author() -> str:
53
+ """Returns the OS user name, which is the default record author."""
54
+ try:
55
+ return getpass.getuser()
56
+ except (OSError, KeyError, ImportError):
57
+ return "unknown"
58
+
59
+
60
+ def _dump(obj: dict[str, Any]) -> bytes:
61
+ """Serializes to the spec's on-disk form (indent 2, sorted, newline)."""
62
+ text = json.dumps(
63
+ obj, indent=2, sort_keys=True, ensure_ascii=False, allow_nan=False
64
+ )
65
+ return (text + "\n").encode("utf-8")
66
+
67
+
68
+ def _read_json(path: pathlib.Path) -> dict[str, Any]:
69
+ """Reads a JSON object from ``path``.
70
+
71
+ Raises:
72
+ errors.FormatError: If the file is not a JSON object.
73
+ """
74
+ try:
75
+ obj = json.loads(path.read_bytes())
76
+ except (json.JSONDecodeError, UnicodeDecodeError) as exc:
77
+ raise errors.FormatError(f"{path}: invalid JSON: {exc}") from exc
78
+ if not isinstance(obj, dict):
79
+ raise errors.FormatError(f"{path}: expected a JSON object")
80
+ return obj
81
+
82
+
83
+ def _check_format(obj: dict[str, Any], prefix: str, path: object) -> None:
84
+ """Rejects files whose format tag or major version is unknown."""
85
+ fmt = obj.get("format")
86
+ if not isinstance(fmt, str) or not fmt.startswith(prefix + "/"):
87
+ raise errors.FormatError(f"{path}: not a {prefix} file: {fmt!r}")
88
+ if fmt.split("/", 1)[1].split(".")[0] != "1":
89
+ raise errors.FormatError(f"{path}: unknown version {fmt!r}")
90
+
91
+
92
+ # ---------------------------------------------------------------- records
93
+
94
+
95
+ @dataclasses.dataclass(kw_only=True)
96
+ class _JsonRecord:
97
+ """Base: dataclass <-> JSON with unknown-field preservation.
98
+
99
+ Attributes:
100
+ extra: Fields the reader did not know, written back unchanged so
101
+ newer tools do not lose data.
102
+ """
103
+
104
+ extra: dict[str, Any] = dataclasses.field(default_factory=dict)
105
+
106
+ def to_json(self) -> dict[str, Any]:
107
+ """Returns the JSON object, known fields over unknown ones."""
108
+ out = copy.deepcopy(self.extra)
109
+ for f in dataclasses.fields(self):
110
+ if f.name != "extra":
111
+ out[f.name] = copy.deepcopy(getattr(self, f.name))
112
+ return out
113
+
114
+ @classmethod
115
+ def from_json(cls, obj: dict[str, Any]) -> Self:
116
+ """Parses a JSON object, keeping unknown fields.
117
+
118
+ Raises:
119
+ errors.FormatError: If a required field is missing or invalid.
120
+ """
121
+ names = {f.name for f in dataclasses.fields(cls)}
122
+ names.discard("extra")
123
+ known = {k: v for k, v in obj.items() if k in names}
124
+ extra = {k: v for k, v in obj.items() if k not in names}
125
+ try:
126
+ return cls(**known, extra=extra)
127
+ except (TypeError, ValueError) as exc:
128
+ raise errors.FormatError(
129
+ f"bad {cls.__name__} record {obj!r}: {exc}"
130
+ ) from exc
131
+
132
+
133
+ @dataclasses.dataclass(kw_only=True)
134
+ class Record(_JsonRecord):
135
+ """Fields common to every list record.
136
+
137
+ Attributes:
138
+ id: ULID, unique within the file.
139
+ author: Who made the record.
140
+ created: RFC 3339 UTC creation time.
141
+ updated: RFC 3339 UTC time of the last change.
142
+ """
143
+
144
+ id: str
145
+ author: str
146
+ created: str
147
+ updated: str
148
+
149
+
150
+ @dataclasses.dataclass(kw_only=True)
151
+ class Marks(_JsonRecord):
152
+ """Run-level marks. The last write wins.
153
+
154
+ ``flag``, ``status`` and ``tags`` are deprecated since viewer v3.1
155
+ (decision D23): they are read and written back unchanged so that no data
156
+ is lost, but nothing sets them and the viewer does not show them.
157
+
158
+ Attributes:
159
+ favorite: Whether the run is starred.
160
+ group: Name of the group the run belongs to, or ``None``.
161
+ flag: Deprecated. Free-text flag such as ``"review"``, or ``None``.
162
+ status: Deprecated. Free-text status, or ``None``.
163
+ tags: Deprecated. Curation tags, stored sorted and unique.
164
+ extra: Unknown fields read from disk.
165
+ """
166
+
167
+ favorite: bool = False
168
+ group: str | None = None
169
+ flag: str | None = None
170
+ status: str | None = None
171
+ tags: list[str] = dataclasses.field(default_factory=list)
172
+ extra: dict[str, Any] = dataclasses.field(default_factory=dict)
173
+
174
+ def __post_init__(self) -> None:
175
+ """Normalizes tags to a sorted, unique list."""
176
+ self.favorite = bool(self.favorite)
177
+ self.tags = sorted({str(t) for t in self.tags})
178
+
179
+
180
+ @dataclasses.dataclass(kw_only=True)
181
+ class Note(Record):
182
+ """A Markdown note.
183
+
184
+ Attributes:
185
+ text: The note body, Markdown.
186
+ """
187
+
188
+ text: str = ""
189
+
190
+
191
+ @dataclasses.dataclass(kw_only=True)
192
+ class Rating(Record):
193
+ """A rating on one criterion.
194
+
195
+ Attributes:
196
+ criterion: Free string, ``"overall"`` by default.
197
+ scale: ``"stars5"``, ``"score100"`` or ``"thumb"``.
198
+ value: Number on that scale.
199
+ rationale: Optional explanation.
200
+ """
201
+
202
+ criterion: str = DEFAULT_CRITERION
203
+ scale: RatingScale = "stars5"
204
+ value: float = 0
205
+ rationale: str | None = None
206
+
207
+
208
+ @dataclasses.dataclass(kw_only=True)
209
+ class Event(Record):
210
+ """A time instant or segment on the timeline.
211
+
212
+ Attributes:
213
+ type: Key into the event-type registry, or ``""`` if untyped.
214
+ label: Short display string.
215
+ t0: Start in seconds.
216
+ t1: End in seconds (``t0 == t1`` for an instant).
217
+ f0: Frame nearest ``t0``.
218
+ f1: Frame nearest ``t1``.
219
+ env: Env index, or ``None`` for all envs.
220
+ props: Typed property values.
221
+ """
222
+
223
+ type: str = ""
224
+ label: str = ""
225
+ t0: float = 0.0
226
+ t1: float = 0.0
227
+ f0: int = 0
228
+ f1: int = 0
229
+ env: int | None = None
230
+ props: dict[str, Any] = dataclasses.field(default_factory=dict)
231
+
232
+
233
+ @dataclasses.dataclass(kw_only=True)
234
+ class Spatial(Record):
235
+ """A 3D point or box at a moment in time.
236
+
237
+ Attributes:
238
+ kind: ``"point"`` or ``"box"``.
239
+ frame: ``"world"`` or ``"body:<index>"``.
240
+ t: Time in seconds.
241
+ f: Frame nearest ``t``.
242
+ env: Env index.
243
+ position: ``[x, y, z]`` in metres.
244
+ size: Box half-extents ``[hx, hy, hz]``, or ``None`` for a point.
245
+ label: Short display string.
246
+ event: Optional id of a linked event.
247
+ """
248
+
249
+ kind: str = "point"
250
+ frame: str = "world"
251
+ t: float = 0.0
252
+ f: int = 0
253
+ env: int = 0
254
+ position: list[float] = dataclasses.field(default_factory=lambda: [0.0] * 3)
255
+ size: list[float] | None = None
256
+ label: str = ""
257
+ event: str | None = None
258
+
259
+
260
+ _LIST_TYPES: dict[str, type[Record]] = {
261
+ "notes": Note,
262
+ "ratings": Rating,
263
+ "events": Event,
264
+ "spatial": Spatial,
265
+ }
266
+
267
+
268
+ # --------------------------------------------------------- event registry
269
+
270
+
271
+ @dataclasses.dataclass(kw_only=True)
272
+ class EventType(_JsonRecord):
273
+ """One entry of the event vocabulary.
274
+
275
+ Attributes:
276
+ type_id: The key the entry is stored under, such as ``"fall"``.
277
+ name: Display name.
278
+ color: ``#rrggbb`` hex color.
279
+ key: Optional single-character hotkey.
280
+ props: Property specs by name; each has a ``type`` of ``"text"``,
281
+ ``"number"``, ``"bool"`` or ``"select"`` (with ``options``).
282
+ extra: Unknown fields read from disk.
283
+ """
284
+
285
+ type_id: str
286
+ name: str
287
+ color: str = "#888888"
288
+ key: str | None = None
289
+ props: dict[str, dict[str, Any]] = dataclasses.field(default_factory=dict)
290
+ extra: dict[str, Any] = dataclasses.field(default_factory=dict)
291
+
292
+ def __post_init__(self) -> None:
293
+ """Validates color, hotkey and property specs.
294
+
295
+ Raises:
296
+ ValueError: If a field is invalid.
297
+ """
298
+ if not _COLOR_RE.match(self.color):
299
+ raise ValueError(f"event type {self.type_id!r}: bad color")
300
+ if self.key is not None and len(self.key) != 1:
301
+ raise ValueError(f"event type {self.type_id!r}: key must be 1 char")
302
+ for pname, spec in self.props.items():
303
+ ptype = spec.get("type")
304
+ if ptype not in PROP_TYPES:
305
+ raise ValueError(
306
+ f"event type {self.type_id!r} prop {pname!r}: "
307
+ f"unknown type {ptype!r}"
308
+ )
309
+ if ptype == "select" and not isinstance(spec.get("options"), list):
310
+ raise ValueError(
311
+ f"event type {self.type_id!r} prop {pname!r}: "
312
+ "select needs options"
313
+ )
314
+
315
+ def to_json(self) -> dict[str, Any]:
316
+ """Returns the JSON object (without ``type_id``)."""
317
+ out = copy.deepcopy(self.extra)
318
+ out.update(
319
+ name=self.name,
320
+ color=self.color,
321
+ key=self.key,
322
+ props=copy.deepcopy(self.props),
323
+ )
324
+ return out
325
+
326
+ @classmethod
327
+ def from_json(cls, obj: dict[str, Any], type_id: str = "") -> Self:
328
+ """Parses one entry.
329
+
330
+ Args:
331
+ obj: The entry's JSON object.
332
+ type_id: The key it was stored under.
333
+
334
+ Raises:
335
+ errors.FormatError: If the entry is invalid.
336
+ """
337
+ known = {"name", "color", "key", "props"}
338
+ try:
339
+ return cls(
340
+ type_id=type_id,
341
+ name=str(obj.get("name", type_id)),
342
+ color=str(obj.get("color", "#888888")),
343
+ key=obj.get("key"),
344
+ props=dict(obj.get("props") or {}),
345
+ extra={k: v for k, v in obj.items() if k not in known},
346
+ )
347
+ except (TypeError, ValueError, AttributeError) as exc:
348
+ raise errors.FormatError(
349
+ f"bad event type {type_id!r}: {exc}"
350
+ ) from exc
351
+
352
+
353
+ def default_event_types() -> dict[str, EventType]:
354
+ """Returns the built-in vocabulary used when the file is missing."""
355
+ severity = {"type": "select", "options": ["minor", "major"]}
356
+ return {
357
+ t.type_id: t
358
+ for t in (
359
+ EventType(
360
+ type_id="fall",
361
+ name="Fall",
362
+ color="#d33b3b",
363
+ key="f",
364
+ props={"severity": severity},
365
+ ),
366
+ EventType(type_id="slip", name="Slip", color="#e59a1c", key="s"),
367
+ EventType(
368
+ type_id="success", name="Success", color="#2f9e44", key="g"
369
+ ),
370
+ EventType(type_id="note", name="Note", color="#4c6ef5", key="o"),
371
+ )
372
+ }
373
+
374
+
375
+ def event_types_path(root: os.PathLike[str] | str) -> pathlib.Path:
376
+ """Returns ``<root>/.simscope/event_types.json``."""
377
+ return pathlib.Path(root) / ".simscope" / "event_types.json"
378
+
379
+
380
+ def load_event_types(
381
+ root: os.PathLike[str] | str,
382
+ ) -> tuple[dict[str, EventType], dict[str, Any]]:
383
+ """Loads the event vocabulary of a library.
384
+
385
+ Args:
386
+ root: The library root.
387
+
388
+ Returns:
389
+ ``(types, extra)``: the entries by id, and unknown top-level fields.
390
+ The built-in default (and no extra) when the file is missing.
391
+
392
+ Raises:
393
+ errors.FormatError: If the file is invalid.
394
+ """
395
+ path = event_types_path(root)
396
+ if not path.is_file():
397
+ return default_event_types(), {}
398
+ obj = _read_json(path)
399
+ _check_format(obj, "simscope-event-types", path)
400
+ raw = obj.get("types") or {}
401
+ if not isinstance(raw, dict):
402
+ raise errors.FormatError(f"{path}: 'types' must be an object")
403
+ types = {
404
+ str(k): EventType.from_json(v, str(k)) for k, v in sorted(raw.items())
405
+ }
406
+ extra = {k: v for k, v in obj.items() if k not in ("format", "types")}
407
+ return types, extra
408
+
409
+
410
+ def save_event_types(
411
+ root: os.PathLike[str] | str,
412
+ types: dict[str, EventType],
413
+ extra: dict[str, Any] | None = None,
414
+ ) -> pathlib.Path:
415
+ """Writes the event vocabulary atomically.
416
+
417
+ Args:
418
+ root: The library root.
419
+ types: Entries by id.
420
+ extra: Unknown top-level fields to keep.
421
+
422
+ Returns:
423
+ The path written.
424
+ """
425
+ obj = dict(extra or {})
426
+ obj["format"] = EVENT_TYPES_FORMAT
427
+ obj["types"] = {k: v.to_json() for k, v in sorted(types.items())}
428
+ path = event_types_path(root)
429
+ cas.atomic_write(path, _dump(obj))
430
+ return path
431
+
432
+
433
+ # ------------------------------------------------------------------ groups
434
+
435
+
436
+ def check_group_name(name: object) -> str:
437
+ """Validates a group name and returns it without surrounding spaces.
438
+
439
+ Args:
440
+ name: The proposed name.
441
+
442
+ Returns:
443
+ The name, 1 to ``MAX_GROUP_NAME`` printable characters.
444
+
445
+ Raises:
446
+ ValueError: If it is not a string, is empty or too long, or has
447
+ characters that cannot be printed (such as a newline).
448
+ """
449
+ if not isinstance(name, str):
450
+ raise ValueError("a group name must be a string")
451
+ name = name.strip()
452
+ if not 1 <= len(name) <= MAX_GROUP_NAME:
453
+ raise ValueError(f"a group name has 1 to {MAX_GROUP_NAME} characters")
454
+ if not name.isprintable():
455
+ raise ValueError("a group name must be printable text")
456
+ return name
457
+
458
+
459
+ @dataclasses.dataclass(kw_only=True)
460
+ class Group(_JsonRecord):
461
+ """One entry of ``.simscope/groups.json``.
462
+
463
+ Attributes:
464
+ name: The group's name, unique case-insensitively.
465
+ created: RFC 3339 UTC creation time.
466
+ extra: Unknown fields read from disk.
467
+ """
468
+
469
+ name: str
470
+ created: str = ""
471
+ extra: dict[str, Any] = dataclasses.field(default_factory=dict)
472
+
473
+
474
+ def groups_path(root: os.PathLike[str] | str) -> pathlib.Path:
475
+ """Returns ``<root>/.simscope/groups.json``."""
476
+ return pathlib.Path(root) / ".simscope" / "groups.json"
477
+
478
+
479
+ def load_groups(
480
+ root: os.PathLike[str] | str,
481
+ ) -> tuple[list[Group], dict[str, Any]]:
482
+ """Loads the ordered groups of a library.
483
+
484
+ A group whose name repeats an earlier one (ignoring case) is dropped.
485
+
486
+ Args:
487
+ root: The library root.
488
+
489
+ Returns:
490
+ ``(groups, extra)``: the groups in library order, and unknown
491
+ top-level fields. No groups when the file is missing.
492
+
493
+ Raises:
494
+ errors.FormatError: If the file is invalid.
495
+ """
496
+ path = groups_path(root)
497
+ if not path.is_file():
498
+ return [], {}
499
+ obj = _read_json(path)
500
+ _check_format(obj, "simscope-groups", path)
501
+ raw = obj.get("groups") or []
502
+ if not isinstance(raw, list) or not all(isinstance(g, dict) for g in raw):
503
+ raise errors.FormatError(f"{path}: 'groups' must be a list of objects")
504
+ groups: list[Group] = []
505
+ seen: set[str] = set()
506
+ for entry in raw:
507
+ group = Group.from_json(entry)
508
+ try:
509
+ group.name = check_group_name(group.name)
510
+ except ValueError as exc:
511
+ raise errors.FormatError(f"{path}: {exc}") from exc
512
+ if group.name.casefold() not in seen:
513
+ seen.add(group.name.casefold())
514
+ groups.append(group)
515
+ extra = {k: v for k, v in obj.items() if k not in ("format", "groups")}
516
+ return groups, extra
517
+
518
+
519
+ def save_groups(
520
+ root: os.PathLike[str] | str,
521
+ groups: list[Group],
522
+ extra: dict[str, Any] | None = None,
523
+ ) -> pathlib.Path:
524
+ """Writes the ordered groups atomically.
525
+
526
+ Args:
527
+ root: The library root.
528
+ groups: The groups in library order.
529
+ extra: Unknown top-level fields to keep.
530
+
531
+ Returns:
532
+ The path written.
533
+ """
534
+ obj = dict(extra or {})
535
+ obj["format"] = GROUPS_FORMAT
536
+ obj["groups"] = [g.to_json() for g in groups]
537
+ path = groups_path(root)
538
+ cas.atomic_write(path, _dump(obj))
539
+ return path
540
+
541
+
542
+ def utc_now() -> str:
543
+ """Returns the current UTC time as RFC 3339 with seconds precision."""
544
+ return manifest.utc_now()
545
+
546
+
547
+ def _finite(name: str, value: float) -> float:
548
+ """Returns ``value`` as a finite float.
549
+
550
+ Raises:
551
+ ValueError: If it is NaN or infinite.
552
+ """
553
+ v = float(value)
554
+ if not math.isfinite(v):
555
+ raise ValueError(f"{name} must be finite, got {value!r}")
556
+ return v
557
+
558
+
559
+ # ------------------------------------------------------------ Annotations
560
+
561
+
562
+ class Annotations:
563
+ """The curation sidecar of one run, loaded into memory.
564
+
565
+ Mutators change the in-memory state and mark it dirty; :meth:`save`
566
+ writes it. ``author`` arguments default to the OS user name.
567
+
568
+ Attributes:
569
+ path: Location of ``annotations.json``.
570
+ run_id: The run's id from ``rollout.json``.
571
+ dt: Seconds per frame, used to snap events to frames.
572
+ n_frames: Frames in the run, used to clip snapped frames.
573
+ marks: Run-level marks.
574
+ notes: Notes, sorted by id.
575
+ ratings: Ratings, sorted by id.
576
+ events: Events, sorted by id.
577
+ spatial: Spatial annotations, sorted by id.
578
+ extra: Unknown top-level fields read from disk.
579
+ id_mismatch: True if the file on disk belonged to another run id.
580
+ """
581
+
582
+ def __init__(
583
+ self,
584
+ path: os.PathLike[str] | str,
585
+ run_id: str,
586
+ dt: float,
587
+ n_frames: int,
588
+ ) -> None:
589
+ """Creates an empty annotation set (nothing is read or written).
590
+
591
+ Args:
592
+ path: Location of ``annotations.json``.
593
+ run_id: The run's id.
594
+ dt: Seconds per frame (positive).
595
+ n_frames: Frames in the run.
596
+
597
+ Raises:
598
+ ValueError: If ``dt`` is not positive.
599
+ """
600
+ if not dt > 0:
601
+ raise ValueError(f"dt must be positive, got {dt!r}")
602
+ self.path = pathlib.Path(path)
603
+ self.run_id = run_id
604
+ self.dt = float(dt)
605
+ self.n_frames = int(n_frames)
606
+ self.marks = Marks()
607
+ self.notes: list[Note] = []
608
+ self.ratings: list[Rating] = []
609
+ self.events: list[Event] = []
610
+ self.spatial: list[Spatial] = []
611
+ self.extra: dict[str, Any] = {}
612
+ self.id_mismatch = False
613
+ self._dirty = False
614
+
615
+ # -- persistence --
616
+
617
+ @classmethod
618
+ def load(
619
+ cls,
620
+ run_dir: os.PathLike[str] | str,
621
+ run_id: str,
622
+ dt: float,
623
+ n_frames: int,
624
+ ) -> Self:
625
+ """Loads ``annotations.json`` from a run directory.
626
+
627
+ A missing file means "no annotations". If the file's ``run_id``
628
+ differs from ``run_id`` a warning is logged and ``id_mismatch`` is
629
+ set; the records are kept and ``save`` will write the current id.
630
+
631
+ Args:
632
+ run_dir: The run directory.
633
+ run_id: The run's id.
634
+ dt: Seconds per frame.
635
+ n_frames: Frames in the run.
636
+
637
+ Returns:
638
+ The annotations.
639
+
640
+ Raises:
641
+ errors.FormatError: If the file exists but is invalid.
642
+ """
643
+ ann = cls(pathlib.Path(run_dir) / FILE_NAME, run_id, dt, n_frames)
644
+ if ann.path.is_file():
645
+ ann._fill(_read_json(ann.path))
646
+ return ann
647
+
648
+ def _fill(self, obj: dict[str, Any]) -> None:
649
+ """Populates this object from a parsed file."""
650
+ _check_format(obj, "simscope-annotations", self.path)
651
+ file_id = obj.get("run_id")
652
+ if file_id not in (None, self.run_id):
653
+ logger.warning(
654
+ "%s belongs to run %s, not %s", self.path, file_id, self.run_id
655
+ )
656
+ self.id_mismatch = True
657
+ marks = obj.get("marks")
658
+ self.marks = (
659
+ Marks.from_json(marks) if isinstance(marks, dict) else Marks()
660
+ )
661
+ for key, rec_type in _LIST_TYPES.items():
662
+ items = obj.get(key) or []
663
+ if not isinstance(items, list):
664
+ raise errors.FormatError(f"{self.path}: {key!r} must be a list")
665
+ records = [rec_type.from_json(o) for o in items]
666
+ records.sort(key=lambda r: r.id)
667
+ getattr(self, key)[:] = records
668
+ known = {"format", "run_id", "marks", *_LIST_TYPES}
669
+ self.extra = {k: v for k, v in obj.items() if k not in known}
670
+
671
+ def to_json(self) -> dict[str, Any]:
672
+ """Returns the file contents, lists sorted by id."""
673
+ out = copy.deepcopy(self.extra)
674
+ out["format"] = ANNOTATIONS_FORMAT
675
+ out["run_id"] = self.run_id
676
+ out["marks"] = self.marks.to_json()
677
+ for key in _LIST_TYPES:
678
+ records = sorted(getattr(self, key), key=lambda r: r.id)
679
+ out[key] = [r.to_json() for r in records]
680
+ return out
681
+
682
+ @property
683
+ def dirty(self) -> bool:
684
+ """True if there are changes that ``save`` has not written."""
685
+ return self._dirty
686
+
687
+ def save(self) -> bool:
688
+ """Writes ``annotations.json`` atomically.
689
+
690
+ Nothing is written if there are no changes and no file exists yet,
691
+ because writers create the file on the first change.
692
+
693
+ Returns:
694
+ True if the file was written.
695
+ """
696
+ if not self._dirty and not self.path.exists():
697
+ return False
698
+ cas.atomic_write(self.path, _dump(self.to_json()))
699
+ self._dirty = False
700
+ self.id_mismatch = False
701
+ return True
702
+
703
+ # -- helpers --
704
+
705
+ def _touch(self) -> str:
706
+ """Marks the state dirty and returns the current timestamp."""
707
+ self._dirty = True
708
+ return utc_now()
709
+
710
+ def _new(
711
+ self, rec_type: type[Record], author: str | None, **fields: Any
712
+ ) -> Any:
713
+ """Builds a record with a fresh id and timestamps and appends it."""
714
+ now = self._touch()
715
+ rec = rec_type(
716
+ id=manifest.new_ulid(),
717
+ author=author or default_author(),
718
+ created=now,
719
+ updated=now,
720
+ **fields,
721
+ )
722
+ for key, cls in _LIST_TYPES.items():
723
+ if cls is rec_type:
724
+ getattr(self, key).append(rec)
725
+ return rec
726
+
727
+ def snap(self, t: float) -> int:
728
+ """Returns the frame nearest to ``t`` seconds, clipped to the run.
729
+
730
+ Uses ``floor(t / dt + 0.5)`` (round half up, as JavaScript's
731
+ ``Math.round``) so the Python and player sides agree.
732
+
733
+ Args:
734
+ t: Time in seconds.
735
+
736
+ Returns:
737
+ A frame index in ``[0, max(n_frames - 1, 0)]``.
738
+ """
739
+ f = math.floor(_finite("t", t) / self.dt + 0.5)
740
+ return int(min(max(f, 0), max(self.n_frames - 1, 0)))
741
+
742
+ def get(self, record_id: str) -> Any:
743
+ """Returns the record with this id.
744
+
745
+ Raises:
746
+ KeyError: If there is none.
747
+ """
748
+ for key in _LIST_TYPES:
749
+ for rec in getattr(self, key):
750
+ if rec.id == record_id:
751
+ return rec
752
+ raise KeyError(record_id)
753
+
754
+ def remove(self, record_id: str) -> bool:
755
+ """Removes the record with this id.
756
+
757
+ Spatial annotations linked to a removed event keep their ``event``
758
+ field; readers treat a dangling link as unlinked.
759
+
760
+ Args:
761
+ record_id: A note, rating, event or spatial id.
762
+
763
+ Returns:
764
+ True if a record was removed.
765
+ """
766
+ for key in _LIST_TYPES:
767
+ records = getattr(self, key)
768
+ for i, rec in enumerate(records):
769
+ if rec.id == record_id:
770
+ del records[i]
771
+ self._dirty = True
772
+ return True
773
+ return False
774
+
775
+ # -- marks --
776
+
777
+ def set_favorite(self, value: bool = True) -> None:
778
+ """Sets or clears the favorite mark."""
779
+ self.marks.favorite = bool(value)
780
+ self._dirty = True
781
+
782
+ def set_group(self, group: str | None) -> None:
783
+ """Moves the run into a group, or out of every group with ``None``.
784
+
785
+ Args:
786
+ group: The group's name (see :func:`check_group_name`), or
787
+ ``None``.
788
+
789
+ Raises:
790
+ ValueError: If the name is invalid.
791
+ """
792
+ self.marks.group = None if group is None else check_group_name(group)
793
+ self._dirty = True
794
+
795
+ # -- notes --
796
+
797
+ def add_note(self, text: str, *, author: str | None = None) -> Note:
798
+ """Adds a Markdown note.
799
+
800
+ Args:
801
+ text: The note body.
802
+ author: Defaults to the OS user name.
803
+
804
+ Returns:
805
+ The new note.
806
+ """
807
+ return self._new(Note, author, text=text)
808
+
809
+ def update_note(self, note_id: str, text: str) -> Note:
810
+ """Replaces a note's text.
811
+
812
+ Raises:
813
+ KeyError: If there is no note with this id.
814
+ """
815
+ for note in self.notes:
816
+ if note.id == note_id:
817
+ note.text = text
818
+ note.updated = self._touch()
819
+ return note
820
+ raise KeyError(note_id)
821
+
822
+ # -- ratings --
823
+
824
+ def rate(
825
+ self,
826
+ value: float,
827
+ *,
828
+ criterion: str = DEFAULT_CRITERION,
829
+ scale: RatingScale = "stars5",
830
+ rationale: str | None = None,
831
+ author: str | None = None,
832
+ ) -> Rating:
833
+ """Rates the run, replacing the author's rating for the criterion.
834
+
835
+ A replacement keeps the old ``id`` and ``created``.
836
+
837
+ Args:
838
+ value: A number on ``scale``: 1-5 for ``stars5``, 0-100 for
839
+ ``score100``, -1 or 1 for ``thumb``.
840
+ criterion: What is rated.
841
+ scale: The scale of ``value``.
842
+ rationale: Optional explanation.
843
+ author: Defaults to the OS user name.
844
+
845
+ Returns:
846
+ The new or updated rating.
847
+
848
+ Raises:
849
+ ValueError: If the scale or value is invalid.
850
+ """
851
+ if scale not in _SCALE_RANGES:
852
+ raise ValueError(f"unknown rating scale {scale!r}")
853
+ value = _finite("value", value)
854
+ lo, hi = _SCALE_RANGES[scale]
855
+ if (
856
+ not lo <= value <= hi
857
+ or (scale == "stars5" and value % 1)
858
+ or (scale == "thumb" and value not in (-1, 1))
859
+ ):
860
+ raise ValueError(f"value {value!r} is invalid for scale {scale!r}")
861
+ num: float = int(value) if value == int(value) else value
862
+ who = author or default_author()
863
+ for rating in self.ratings:
864
+ if rating.author == who and rating.criterion == criterion:
865
+ rating.scale, rating.value = scale, num
866
+ rating.rationale = rationale
867
+ rating.updated = self._touch()
868
+ return rating
869
+ return self._new(
870
+ Rating,
871
+ who,
872
+ criterion=criterion,
873
+ scale=scale,
874
+ value=num,
875
+ rationale=rationale,
876
+ )
877
+
878
+ # -- events --
879
+
880
+ def _event_times(self, t0: float, t1: float | None) -> tuple[float, float]:
881
+ """Validates ``t0 <= t1`` and defaults ``t1`` to ``t0``."""
882
+ a = _finite("t0", t0)
883
+ b = a if t1 is None else _finite("t1", t1)
884
+ if a > b:
885
+ raise ValueError(f"event needs t0 <= t1, got {a} > {b}")
886
+ return a, b
887
+
888
+ def add_event(
889
+ self,
890
+ type: str = "", # noqa: A002
891
+ *,
892
+ t0: float,
893
+ t1: float | None = None,
894
+ label: str = "",
895
+ env: int | None = None,
896
+ props: dict[str, Any] | None = None,
897
+ author: str | None = None,
898
+ ) -> Event:
899
+ """Adds an event, snapping ``f0`` and ``f1`` to the nearest frames.
900
+
901
+ Args:
902
+ type: Key into the event-type registry, or ``""``.
903
+ t0: Start in seconds.
904
+ t1: End in seconds; defaults to ``t0`` (an instant).
905
+ label: Short display string.
906
+ env: Env index, or ``None`` for all envs.
907
+ props: Typed property values.
908
+ author: Defaults to the OS user name.
909
+
910
+ Returns:
911
+ The new event.
912
+
913
+ Raises:
914
+ ValueError: If ``t0 > t1`` or a time is not finite.
915
+ """
916
+ a, b = self._event_times(t0, t1)
917
+ return self._new(
918
+ Event,
919
+ author,
920
+ type=type,
921
+ label=label,
922
+ t0=a,
923
+ t1=b,
924
+ f0=self.snap(a),
925
+ f1=self.snap(b),
926
+ env=env,
927
+ props=dict(props or {}),
928
+ )
929
+
930
+ def update_event(self, event_id: str, **changes: Any) -> Event:
931
+ """Changes fields of an event and re-snaps its frames.
932
+
933
+ Args:
934
+ event_id: The event's id.
935
+ **changes: Any of ``type``, ``label``, ``t0``, ``t1``, ``env``
936
+ and ``props``.
937
+
938
+ Returns:
939
+ The updated event.
940
+
941
+ Raises:
942
+ KeyError: If there is no event with this id.
943
+ ValueError: If a field is unknown or the times are invalid.
944
+ """
945
+ allowed = {"type", "label", "t0", "t1", "env", "props"}
946
+ bad = set(changes) - allowed
947
+ if bad:
948
+ raise ValueError(f"cannot update event fields {sorted(bad)}")
949
+ for ev in self.events:
950
+ if ev.id != event_id:
951
+ continue
952
+ a, b = self._event_times(
953
+ changes.get("t0", ev.t0), changes.get("t1", ev.t1)
954
+ )
955
+ for name in ("type", "label", "env"):
956
+ if name in changes:
957
+ setattr(ev, name, changes[name])
958
+ if "props" in changes:
959
+ ev.props = dict(changes["props"])
960
+ ev.t0, ev.t1 = a, b
961
+ ev.f0, ev.f1 = self.snap(a), self.snap(b)
962
+ ev.updated = self._touch()
963
+ return ev
964
+ raise KeyError(event_id)
965
+
966
+ # -- spatial --
967
+
968
+ def add_spatial(
969
+ self,
970
+ kind: str,
971
+ position: tuple[float, float, float] | list[float],
972
+ *,
973
+ t: float,
974
+ env: int = 0,
975
+ frame: str = "world",
976
+ size: tuple[float, float, float] | list[float] | None = None,
977
+ label: str = "",
978
+ event: str | None = None,
979
+ author: str | None = None,
980
+ ) -> Spatial:
981
+ """Adds a 3D point or box, snapping ``f`` to the nearest frame.
982
+
983
+ Args:
984
+ kind: ``"point"`` or ``"box"``.
985
+ position: ``[x, y, z]`` in metres.
986
+ t: Time in seconds.
987
+ env: Env index.
988
+ frame: ``"world"`` or ``"body:<index>"``.
989
+ size: Half-extents of a box (required for ``"box"``).
990
+ label: Short display string.
991
+ event: Optional id of a linked event.
992
+ author: Defaults to the OS user name.
993
+
994
+ Returns:
995
+ The new annotation.
996
+
997
+ Raises:
998
+ ValueError: If an argument is invalid.
999
+ """
1000
+ if kind not in SPATIAL_KINDS:
1001
+ raise ValueError(f"spatial kind must be one of {SPATIAL_KINDS}")
1002
+ if not _FRAME_RE.match(frame):
1003
+ raise ValueError(f"frame must be 'world' or 'body:<i>': {frame!r}")
1004
+ pos = [_finite("position", v) for v in position]
1005
+ if len(pos) != 3:
1006
+ raise ValueError("position needs 3 components")
1007
+ dims = None
1008
+ if kind == "box":
1009
+ if size is None:
1010
+ raise ValueError("a box needs a size")
1011
+ dims = [_finite("size", v) for v in size]
1012
+ if len(dims) != 3:
1013
+ raise ValueError("size needs 3 components")
1014
+ elif size is not None:
1015
+ raise ValueError("a point has no size")
1016
+ t = _finite("t", t)
1017
+ return self._new(
1018
+ Spatial,
1019
+ author,
1020
+ kind=kind,
1021
+ frame=frame,
1022
+ t=t,
1023
+ f=self.snap(t),
1024
+ env=int(env),
1025
+ position=pos,
1026
+ size=dims,
1027
+ label=label,
1028
+ event=event,
1029
+ )
1030
+
1031
+
1032
+ # ------------------------------------------------------------------ merge
1033
+
1034
+
1035
+ def _newer(a: Record, b: Record) -> Record:
1036
+ """Picks the record with the later ``updated`` (ties: larger JSON)."""
1037
+ ka = (a.updated, json.dumps(a.to_json(), sort_keys=True))
1038
+ kb = (b.updated, json.dumps(b.to_json(), sort_keys=True))
1039
+ return a if ka >= kb else b
1040
+
1041
+
1042
+ def _merge_lists(
1043
+ a: list[Any], b: list[Any], base: list[Any] | None
1044
+ ) -> list[Any]:
1045
+ """Unions two record lists by id; newer ``updated`` wins on a clash.
1046
+
1047
+ With a ``base``, an id that was in the base but is absent from one side
1048
+ counts as deleted there and is dropped from the result.
1049
+ """
1050
+ a_by, b_by = {r.id: r for r in a}, {r.id: r for r in b}
1051
+ dropped: set[str] = set()
1052
+ if base is not None:
1053
+ base_ids = {r.id for r in base}
1054
+ dropped = (base_ids - a_by.keys()) | (base_ids - b_by.keys())
1055
+ out = []
1056
+ for rid in sorted(a_by.keys() | b_by.keys()):
1057
+ if rid in dropped:
1058
+ continue
1059
+ if rid in a_by and rid in b_by:
1060
+ out.append(copy.deepcopy(_newer(a_by[rid], b_by[rid])))
1061
+ else:
1062
+ out.append(copy.deepcopy(a_by.get(rid) or b_by[rid]))
1063
+ return out
1064
+
1065
+
1066
+ def _dedupe_ratings(ratings: list[Rating]) -> list[Rating]:
1067
+ """Keeps one rating per (author, criterion): the most recently updated."""
1068
+ best: dict[tuple[str, str], Rating] = {}
1069
+ for r in ratings:
1070
+ key = (r.author, r.criterion)
1071
+ best[key] = cast(Rating, _newer(best[key], r)) if key in best else r
1072
+ return sorted(best.values(), key=lambda r: r.id)
1073
+
1074
+
1075
+ def _merge_marks(a: Marks, b: Marks, base: Marks | None) -> Marks:
1076
+ """Merges marks; favorite is OR-ed and deprecated tags are unioned.
1077
+
1078
+ ``group`` (like the deprecated ``flag`` and ``status``) takes the side
1079
+ that changed relative to ``base``; without a base (or if both changed)
1080
+ ``b``'s value wins when it is set.
1081
+ """
1082
+ out = copy.deepcopy(a)
1083
+ out.extra = {**b.extra, **a.extra}
1084
+ out.favorite = a.favorite or b.favorite
1085
+ tags = set(a.tags) | set(b.tags)
1086
+ if base is not None:
1087
+ tags -= set(base.tags) - (set(a.tags) & set(b.tags))
1088
+ out.tags = sorted(tags)
1089
+ for name in ("group", "flag", "status"):
1090
+ va, vb = getattr(a, name), getattr(b, name)
1091
+ if base is not None:
1092
+ v0 = getattr(base, name)
1093
+ chosen = va if vb == v0 else vb
1094
+ else:
1095
+ chosen = vb if vb is not None else va
1096
+ setattr(out, name, chosen)
1097
+ return out
1098
+
1099
+
1100
+ def merge(
1101
+ a: Annotations, b: Annotations, base: Annotations | None = None
1102
+ ) -> Annotations:
1103
+ """Merges two annotation sets that diverged from a common base (D8).
1104
+
1105
+ List entries merge by id: the union of both sides, and where the same id
1106
+ exists on both sides the one with the later ``updated`` wins. Ratings are
1107
+ then reduced to one per author and criterion. This is what a textual git
1108
+ merge of the two sorted files would do, without conflicts.
1109
+
1110
+ Args:
1111
+ a: One side. The result takes its path, run id and dt.
1112
+ b: The other side ("theirs"; wins mark conflicts).
1113
+ base: The common ancestor, if known. It lets the merge propagate
1114
+ deletions and tell which side changed a mark.
1115
+
1116
+ Returns:
1117
+ A new, dirty ``Annotations``. Neither input is modified.
1118
+ """
1119
+ out = Annotations(a.path, a.run_id, a.dt, a.n_frames)
1120
+ out.marks = _merge_marks(a.marks, b.marks, base.marks if base else None)
1121
+ for key in _LIST_TYPES:
1122
+ merged = _merge_lists(
1123
+ getattr(a, key),
1124
+ getattr(b, key),
1125
+ getattr(base, key) if base is not None else None,
1126
+ )
1127
+ if key == "ratings":
1128
+ merged = _dedupe_ratings(merged)
1129
+ getattr(out, key)[:] = merged
1130
+ out.extra = {**copy.deepcopy(b.extra), **copy.deepcopy(a.extra)}
1131
+ out._dirty = True
1132
+ return out