datahive-tools 0.1.0__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 (61) hide show
  1. datahive/__init__.py +31 -0
  2. datahive/annotate.py +123 -0
  3. datahive/attachments.py +71 -0
  4. datahive/attachments.yaml +124 -0
  5. datahive/check.py +192 -0
  6. datahive/cli.py +567 -0
  7. datahive/collect.py +158 -0
  8. datahive/config.py +200 -0
  9. datahive/consistency.py +313 -0
  10. datahive/episode.py +378 -0
  11. datahive/errors.py +65 -0
  12. datahive/hf_limits.py +48 -0
  13. datahive/hub.py +118 -0
  14. datahive/index.py +236 -0
  15. datahive/interface/__init__.py +0 -0
  16. datahive/interface/api.py +748 -0
  17. datahive/interface/app.py +32 -0
  18. datahive/interface/static/app.js +2678 -0
  19. datahive/interface/static/automatic.js +242 -0
  20. datahive/interface/static/favicon.svg +9 -0
  21. datahive/interface/static/guide.js +116 -0
  22. datahive/interface/static/hiveboard-logo.svg +43 -0
  23. datahive/interface/static/index.html +350 -0
  24. datahive/interface/static/nav.js +34 -0
  25. datahive/interface/static/runner.js +1137 -0
  26. datahive/interface/static/style.css +1828 -0
  27. datahive/interface/static/tasks/big_valve_3d.png +0 -0
  28. datahive/interface/static/tasks/box_3d.png +0 -0
  29. datahive/interface/static/tasks/button_3d.png +0 -0
  30. datahive/interface/static/tasks/key_3d.png +0 -0
  31. datahive/interface/static/tasks/lamp_3d.png +0 -0
  32. datahive/interface/static/tasks/m30_3d.png +0 -0
  33. datahive/interface/static/tasks/m8_3d.png +0 -0
  34. datahive/interface/static/tasks/peg_and_hole_3d.png +0 -0
  35. datahive/interface/static/tasks/small_valve_3d.png +0 -0
  36. datahive/interface/static/tasks/spring_3d.png +0 -0
  37. datahive/interface/static/tasks/switch_3d.png +0 -0
  38. datahive/interface/static/tasks/torque_valve_noFriction_3d.png +0 -0
  39. datahive/ops.py +384 -0
  40. datahive/paths.py +143 -0
  41. datahive/preflight.py +111 -0
  42. datahive/profile.py +530 -0
  43. datahive/runner.py +485 -0
  44. datahive/schema.py +251 -0
  45. datahive/skill_install.py +45 -0
  46. datahive/skills/datahive-auto-collect/SKILL.md +108 -0
  47. datahive/skills/datahive-auto-collect/reference/adapting.md +152 -0
  48. datahive/skills/datahive-auto-collect/reference/protocol.md +73 -0
  49. datahive/skills/datahive-data-prep/SKILL.md +108 -0
  50. datahive/skills/datahive-data-prep/reference/annotation.md +84 -0
  51. datahive/skills/datahive-data-prep/reference/layout-and-format.md +144 -0
  52. datahive/skills/datahive-data-prep/reference/robot-profile.md +92 -0
  53. datahive/skills/datahive-data-prep/reference/troubleshooting.md +68 -0
  54. datahive/trials.py +120 -0
  55. datahive/validate.py +125 -0
  56. datahive_tools-0.1.0.dist-info/METADATA +117 -0
  57. datahive_tools-0.1.0.dist-info/RECORD +61 -0
  58. datahive_tools-0.1.0.dist-info/WHEEL +5 -0
  59. datahive_tools-0.1.0.dist-info/entry_points.txt +2 -0
  60. datahive_tools-0.1.0.dist-info/licenses/LICENSE +21 -0
  61. datahive_tools-0.1.0.dist-info/top_level.txt +1 -0
datahive/__init__.py ADDED
@@ -0,0 +1,31 @@
1
+ """datahive-tools: client package for collecting, validating, annotating and
2
+ uploading HiveBoard manipulation episodes.
3
+
4
+ Public library API (also used internally by the CLI and the local GUI, so
5
+ all three surfaces stay in sync):
6
+
7
+ from datahive import EpisodeWriter, RobotProfile, TrialAnnotation
8
+ """
9
+
10
+ from datahive.episode import EpisodeWriter, read_header, read_trajectory, resolve_episode
11
+ from datahive.errors import DatahiveError
12
+ from datahive.profile import RobotProfile, load_profile, write_profile_skeleton
13
+ from datahive.schema import EpisodeHeader, FailureCause, Outcome, Strategy, TrialAnnotation
14
+
15
+ __all__ = [
16
+ "EpisodeWriter",
17
+ "read_header",
18
+ "read_trajectory",
19
+ "resolve_episode",
20
+ "DatahiveError",
21
+ "RobotProfile",
22
+ "load_profile",
23
+ "write_profile_skeleton",
24
+ "EpisodeHeader",
25
+ "FailureCause",
26
+ "Outcome",
27
+ "Strategy",
28
+ "TrialAnnotation",
29
+ ]
30
+
31
+ __version__ = "0.1.0"
datahive/annotate.py ADDED
@@ -0,0 +1,123 @@
1
+ """`datahive annotate`: fills outcome/failure_cause/etc. for an episode's
2
+ trial, shared by the CLI's interactive prompts and the GUI's validation
3
+ form (both end up calling `annotate_episode`)."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from datahive.attachments import is_composed_assembly
11
+ from datahive.episode import read_header
12
+ from datahive.errors import AnnotationError
13
+ from datahive.paths import resolve_episode_paths
14
+ from datahive.schema import ANNOTATION_SCHEMA_CURRENT, TrialAnnotation
15
+ from datahive.trials import upsert_row
16
+
17
+
18
+ def annotate_episode(
19
+ samples_root: Path, episode_id: str, fields: dict[str, Any], *, validate_after: bool = True
20
+ ) -> TrialAnnotation:
21
+ """`fields` is a dict of TrialAnnotation column values (trial_id/lab_id/
22
+ platform_id/attachment_id/date are filled in automatically from the
23
+ episode header + config when omitted). Writes the row to trials.csv and,
24
+ by default, runs full validation afterward so the index status reflects
25
+ whether the episode is now `validated`."""
26
+ paths = resolve_episode_paths(samples_root, episode_id)
27
+ header = read_header(paths.h5)
28
+
29
+ data = dict(fields)
30
+ data.setdefault("trial_id", header.trial_id)
31
+ data.setdefault("lab_id", header.lab_id)
32
+ data.setdefault("platform_id", header.platform_id)
33
+ if "date" not in data or data["date"] is None:
34
+ from datetime import datetime, timezone
35
+
36
+ data["date"] = datetime.now(timezone.utc).date().isoformat()
37
+ for name_field in ("operator_name", "annotator_name", "failure_cause_detail"):
38
+ if data.get(name_field) is None:
39
+ data[name_field] = ""
40
+
41
+ if data.get("outcome") == "success" and data.get("completion_time_s") is None:
42
+ from datahive.episode import episode_stats
43
+
44
+ duration = episode_stats(paths.h5).get("duration_s")
45
+ if duration is not None:
46
+ data["completion_time_s"] = round(duration, 3)
47
+ data.setdefault("completion_source", "hdf5")
48
+
49
+ from datetime import datetime, timezone
50
+
51
+ data["annotated_at"] = datetime.now(timezone.utc).isoformat()
52
+ data["schema_version"] = ANNOTATION_SCHEMA_CURRENT
53
+
54
+ attachment_id = data.get("attachment_id")
55
+ composed = is_composed_assembly(attachment_id or "", samples_root)
56
+ context = {} if composed is None else {"composed_assembly": composed}
57
+
58
+ try:
59
+ annotation = TrialAnnotation.model_validate(data, context=context)
60
+ except Exception as e:
61
+ raise AnnotationError(f"Invalid annotation: {e}") from e
62
+
63
+ upsert_row(paths.trials_csv, annotation)
64
+ write_h5_annotation(paths.h5, annotation)
65
+
66
+ if validate_after:
67
+ from datahive.validate import validate_episode
68
+
69
+ try:
70
+ validate_episode(samples_root, episode_id)
71
+ except Exception:
72
+ pass
73
+
74
+ return annotation
75
+
76
+
77
+ def _annotator_key(name: str) -> str:
78
+ return (name or "unknown").strip().replace("/", "_") or "unknown"
79
+
80
+
81
+ def write_h5_annotation(h5_path: Path, annotation: TrialAnnotation) -> None:
82
+ """Stores the annotation inside the episode as
83
+ episode_annotations/<annotator>/ (attrs), stamped with schema_version, so
84
+ the file stays self-describing if trials.csv is lost or the episode moved."""
85
+ import json
86
+
87
+ import h5py
88
+
89
+ row = annotation.to_csv_row()
90
+ key = _annotator_key(annotation.annotator_name)
91
+ with h5py.File(h5_path, "r+") as f:
92
+ root = f.require_group("episode_annotations")
93
+ if key in root:
94
+ del root[key]
95
+ grp = root.create_group(key)
96
+ grp.attrs["schema_version"] = annotation.schema_version
97
+ grp.attrs["source"] = "human"
98
+ grp.attrs["timestamp"] = row["annotated_at"]
99
+ grp.attrs["annotation"] = json.dumps(row, sort_keys=True)
100
+
101
+
102
+ def read_h5_annotations(h5_path: Path) -> dict[str, dict]:
103
+ """{annotator: row-dict} from the episode file. Groups without a
104
+ schema_version are upcast to the legacy version; nothing is rewritten."""
105
+ import json
106
+
107
+ import h5py
108
+
109
+ from datahive.schema import ANNOTATION_SCHEMA_LEGACY
110
+
111
+ out: dict[str, dict] = {}
112
+ with h5py.File(h5_path, "r") as f:
113
+ root = f.get("episode_annotations")
114
+ if root is None:
115
+ return out
116
+ for name, grp in root.items():
117
+ try:
118
+ row = json.loads(grp.attrs.get("annotation", "{}"))
119
+ except (TypeError, ValueError):
120
+ row = {}
121
+ row["schema_version"] = grp.attrs.get("schema_version") or row.get("schema_version") or ANNOTATION_SCHEMA_LEGACY
122
+ out[name] = row
123
+ return out
@@ -0,0 +1,71 @@
1
+ """Attachment registry: which attachment_ids are "composed-assembly"
2
+ (multi-stage) attachments, for which stage_reached is meaningful.
3
+
4
+ Bundled defaults live in datahive/attachments.yaml; a lab can add or
5
+ override entries with samples/attachments.yaml.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from functools import lru_cache
12
+ from pathlib import Path
13
+
14
+ import yaml
15
+
16
+ from datahive.paths import attachments_override_path
17
+
18
+ _BUNDLED_PATH = Path(__file__).parent / "attachments.yaml"
19
+
20
+
21
+ @dataclass
22
+ class AttachmentInfo:
23
+ attachment_id: str
24
+ name: str
25
+ composed_assembly: bool
26
+ n_stages: int | None = None
27
+ family: str | None = None
28
+ timeout: int | None = None
29
+ success: str | None = None
30
+ reset: str | None = None
31
+ stages: list[str] | None = None
32
+ image: str | None = None
33
+
34
+
35
+ @lru_cache(maxsize=1)
36
+ def _bundled() -> dict:
37
+ return yaml.safe_load(_BUNDLED_PATH.read_text(encoding="utf-8")) or {}
38
+
39
+
40
+ def load_registry(samples_root: Path | None = None) -> dict[str, AttachmentInfo]:
41
+ raw: dict = dict(_bundled())
42
+ if samples_root is not None:
43
+ override_path = attachments_override_path(samples_root)
44
+ if override_path.is_file():
45
+ override = yaml.safe_load(override_path.read_text(encoding="utf-8")) or {}
46
+ raw.update(override)
47
+ return {
48
+ aid: AttachmentInfo(
49
+ attachment_id=aid,
50
+ name=info.get("name", aid),
51
+ composed_assembly=bool(info.get("composed_assembly", False)),
52
+ n_stages=info.get("n_stages"),
53
+ family=info.get("family"),
54
+ timeout=info.get("timeout"),
55
+ success=info.get("success"),
56
+ reset=info.get("reset"),
57
+ stages=info.get("stages"),
58
+ image=info.get("image"),
59
+ )
60
+ for aid, info in raw.items()
61
+ }
62
+
63
+
64
+ def is_composed_assembly(attachment_id: str, samples_root: Path | None = None) -> bool | None:
65
+ """Returns True/False if the attachment is known, or None if unknown
66
+ (caller should treat this as 'no constraint, but warn')."""
67
+ registry = load_registry(samples_root)
68
+ info = registry.get(attachment_id)
69
+ if info is None:
70
+ return None
71
+ return info.composed_assembly
@@ -0,0 +1,124 @@
1
+ # Bundled HiveBoard attachment (task) registry.
2
+ #
3
+ # This mirrors the 13 official HiveBoard evaluation conditions from
4
+ # HiveBoard's Evaluation Runner
5
+ # (https://hiveboard-bench.github.io/hivedocs/benchmark/evaluation-runner),
6
+ # so `attachment_id` here means the same thing as `attachment_id` in a
7
+ # HiveBoard submission. `composed_assembly: true` marks attachments made
8
+ # of multiple stages that must be completed in sequence (stage_reached is
9
+ # meaningful for these and required in the trial annotation).
10
+ #
11
+ # Labs can override/extend this list by placing an attachments.yaml with
12
+ # the same shape at the root of their samples/ directory.
13
+ valve_ball:
14
+ name: Ball valve
15
+ family: Torque
16
+ timeout: 60
17
+ composed_assembly: false
18
+ success: Rotate the handle 90° from the closed state to the open state.
19
+ reset: Return the handle to the closed position and confirm that the attachment is fully seated.
20
+ image: torque_valve_noFriction_3d.png
21
+ valve_ball_ring:
22
+ name: Ball valve with friction ring
23
+ family: Torque
24
+ timeout: 90
25
+ composed_assembly: false
26
+ success: Rotate the handle 90° from closed to open with the friction ring fitted.
27
+ reset: Fit the friction ring, return the handle to closed, and confirm that the attachment is seated.
28
+ image: torque_valve_noFriction_3d.png
29
+ valve_gate_small:
30
+ name: Small gate valve
31
+ family: Torque
32
+ timeout: 90
33
+ composed_assembly: false
34
+ success: Complete one full turn of the valve stem.
35
+ reset: Return the stem to the marked initial orientation without changing the board position.
36
+ image: small_valve_3d.png
37
+ valve_gate_large:
38
+ name: Large gate valve
39
+ family: Torque
40
+ timeout: 120
41
+ composed_assembly: false
42
+ success: Complete one full turn of the valve stem.
43
+ reset: Return the stem to the marked initial orientation without changing the board position.
44
+ image: big_valve_3d.png
45
+ circuit_breaker:
46
+ name: Circuit breaker
47
+ family: Torque
48
+ timeout: 60
49
+ composed_assembly: false
50
+ success: Move the toggle to the opposite state and hold it there.
51
+ reset: Return the toggle to its initial state and confirm that it moves freely.
52
+ image: switch_3d.png
53
+ light_bulb:
54
+ name: Light bulb and socket
55
+ family: Precision
56
+ timeout: 120
57
+ composed_assembly: false
58
+ success: Thread the bulb into the socket until it is seated.
59
+ reset: Remove the bulb, restore the documented starting pose, and inspect the thread.
60
+ image: lamp_3d.png
61
+ thread_m8:
62
+ name: M8 threaded fastener
63
+ family: Precision
64
+ timeout: 120
65
+ composed_assembly: false
66
+ success: Thread the bolt along the available length.
67
+ reset: Return the bolt to the documented initial engagement and check that the thread is clear.
68
+ image: m8_3d.png
69
+ thread_m30:
70
+ name: M30 threaded fastener
71
+ family: Precision
72
+ timeout: 120
73
+ composed_assembly: false
74
+ success: Thread the bolt along the available length.
75
+ reset: Return the bolt to the documented initial engagement and check that the thread is clear.
76
+ image: m30_3d.png
77
+ peg_insertion:
78
+ name: Threaded peg insertion
79
+ family: Precision
80
+ timeout: 120
81
+ composed_assembly: false
82
+ success: Thread the free 8 mm peg into the empty socket until it is seated.
83
+ reset: Remove the peg and return it to the initial pose next to the empty socket.
84
+ image: peg_and_hole_3d.png
85
+ button:
86
+ name: Covered button
87
+ family: Composed assembly
88
+ timeout: 60
89
+ composed_assembly: true
90
+ n_stages: 2
91
+ stages: [Open cover, Press button]
92
+ success: Open the cover and press the button.
93
+ reset: Close the cover and confirm that the button has returned.
94
+ image: button_3d.png
95
+ lock:
96
+ name: Lock and key
97
+ family: Composed assembly
98
+ timeout: 180
99
+ composed_assembly: true
100
+ n_stages: 3
101
+ stages: [Grasp key, Insert key vertically, Rotate to unlock]
102
+ success: Grasp the key, insert it vertically, and rotate it to unlock.
103
+ reset: Remove the key, return the lock to its initial state, and restore the key pose.
104
+ image: key_3d.png
105
+ drawer:
106
+ name: Sliding drawer
107
+ family: Composed assembly
108
+ timeout: 120
109
+ composed_assembly: true
110
+ n_stages: 3
111
+ stages: [Grasp handle, Pull open, Push closed]
112
+ success: Grasp the handle, pull the drawer open, and push it closed.
113
+ reset: Return the drawer to the fully closed initial position.
114
+ image: box_3d.png
115
+ shock_absorber:
116
+ name: Shock absorber
117
+ family: Composed assembly
118
+ timeout: 180
119
+ composed_assembly: true
120
+ n_stages: 3
121
+ stages: [Grasp pin, Align with hole, Insert fully]
122
+ success: Grasp the pin, align it with the hole, and insert it fully.
123
+ reset: Remove the pin, restore its starting pose, and check both occupied board cells.
124
+ image: spring_3d.png
datahive/check.py ADDED
@@ -0,0 +1,192 @@
1
+ """Pre-annotation health check for collected episodes.
2
+
3
+ Checks recording integrity (HDF5 structure, proprioception sample rate,
4
+ monotonic timestamps, timestamp jitter/gaps, and camera video files)
5
+ without requiring an annotation row in trials.csv.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ import h5py
14
+ import numpy as np
15
+
16
+ from datahive.episode import episode_stats, read_header, sample_rate_hz
17
+ from datahive.errors import DatahiveError, EpisodeNotFound
18
+ from datahive.paths import resolve_episode_paths
19
+ from datahive.profile import camera_consistency_problems, load_profile
20
+ from datahive.validate import MIN_SAMPLE_RATE_HZ
21
+
22
+
23
+ def check_episode(samples_root: Path, episode_id: str) -> dict[str, Any]:
24
+ """Runs a quick pre-annotation health check on a recorded episode.
25
+
26
+ Checks:
27
+ 1. HDF5 exists and is a valid readable file.
28
+ 2. Episode header attributes parse and contain required hardware metadata.
29
+ 3. /proprioception/timestamp exists, has >= 2 steps, and is monotonically increasing.
30
+ 4. Proprioception sample rate meets the HiveBoard 100 Hz minimum.
31
+ 5. No severe timestamp dropouts/gaps.
32
+ 6. All declared camera video files (.mp4) exist and are non-empty.
33
+ 7. Consistency with robot_profile.yaml (if profile exists).
34
+
35
+ Returns a dict with:
36
+ ok: bool
37
+ episode_id: str
38
+ problems: list[str] # hard failures preventing valid rollout
39
+ warnings: list[str] # non-fatal recommendations / notices
40
+ stats: dict[str, Any]
41
+ """
42
+ problems: list[str] = []
43
+ warnings: list[str] = []
44
+ stats: dict[str, Any] = {
45
+ "n_steps": 0,
46
+ "duration_s": None,
47
+ "sample_rate_hz": None,
48
+ "cameras": [],
49
+ "timestamp_gaps": 0,
50
+ }
51
+
52
+ try:
53
+ paths = resolve_episode_paths(samples_root, episode_id)
54
+ except EpisodeNotFound:
55
+ return {
56
+ "ok": False,
57
+ "episode_id": episode_id,
58
+ "problems": [f"Episode '{episode_id}' not found under {samples_root}."],
59
+ "warnings": [],
60
+ "stats": stats,
61
+ }
62
+ except Exception as e:
63
+ return {
64
+ "ok": False,
65
+ "episode_id": episode_id,
66
+ "problems": [f"Could not resolve episode paths: {e}"],
67
+ "warnings": [],
68
+ "stats": stats,
69
+ }
70
+
71
+ if not paths.h5.is_file():
72
+ return {
73
+ "ok": False,
74
+ "episode_id": episode_id,
75
+ "problems": [f"HDF5 file does not exist: {paths.h5}"],
76
+ "warnings": [],
77
+ "stats": stats,
78
+ }
79
+
80
+ # 1. HDF5 readability & header parsing
81
+ try:
82
+ header = read_header(paths.h5)
83
+ except Exception as e:
84
+ return {
85
+ "ok": False,
86
+ "episode_id": episode_id,
87
+ "problems": [f"Could not read header from {paths.h5}: {e}"],
88
+ "warnings": [],
89
+ "stats": stats,
90
+ }
91
+
92
+ if not header.low_level.get("mode"):
93
+ problems.append("Header has no low_level.mode recorded.")
94
+ if not header.cameras:
95
+ problems.append("Header lists no cameras.")
96
+ problems.extend(camera_consistency_problems(header.cameras))
97
+
98
+ # 2. Proprioception datasets & timestamp sanity
99
+ try:
100
+ with h5py.File(paths.h5, "r") as f:
101
+ proprio = f.get("proprioception")
102
+ if proprio is None:
103
+ problems.append("Missing /proprioception group in HDF5 file.")
104
+ elif "timestamp" not in proprio:
105
+ problems.append("Missing /proprioception/timestamp dataset.")
106
+ else:
107
+ ts = np.asarray(proprio["timestamp"][:]).reshape(-1)
108
+ n_steps = int(len(ts))
109
+ stats["n_steps"] = n_steps
110
+ if n_steps < 2:
111
+ problems.append(f"Episode has only {n_steps} step(s); minimum is 2.")
112
+ else:
113
+ duration = float(ts[-1] - ts[0])
114
+ stats["duration_s"] = duration
115
+ if duration <= 0:
116
+ problems.append(f"Invalid duration: {duration:.3f}s (timestamps not increasing).")
117
+
118
+ diffs = np.diff(ts)
119
+ negative_diffs = np.sum(diffs < 0)
120
+ if negative_diffs > 0:
121
+ problems.append(f"Timestamps are not monotonically increasing ({negative_diffs} non-positive step(s)).")
122
+
123
+ median_dt = np.median(diffs)
124
+ if median_dt > 0:
125
+ rate = 1.0 / median_dt
126
+ stats["sample_rate_hz"] = float(rate)
127
+ if rate < MIN_SAMPLE_RATE_HZ * 0.99:
128
+ problems.append(
129
+ f"Proprioception sample rate is {rate:.1f} Hz, below the required {MIN_SAMPLE_RATE_HZ:.0f} Hz."
130
+ )
131
+ # Check for large gaps (> 3x median dt)
132
+ large_gaps = int(np.sum(diffs > 3.0 * median_dt))
133
+ stats["timestamp_gaps"] = large_gaps
134
+ if large_gaps > 0:
135
+ problems.append(f"Detected {large_gaps} severe timestamp gap(s) greater than 3x normal sampling interval.")
136
+ except Exception as e:
137
+ problems.append(f"Error inspecting datasets in {paths.h5}: {e}")
138
+
139
+ # 3. Camera video files
140
+ cam_names = []
141
+ for cam in header.cameras:
142
+ cname = cam.get("name") or "unnamed"
143
+ cam_names.append(cname)
144
+ fname = cam.get("file")
145
+ if not fname:
146
+ problems.append(f"Camera '{cname}' video file missing: no mp4 file recorded in header.")
147
+ continue
148
+ vpath = paths.h5.parent / fname
149
+ if not vpath.exists():
150
+ problems.append(f"Camera '{cname}' video file missing: {fname}")
151
+ elif vpath.stat().st_size == 0:
152
+ problems.append(f"Camera '{cname}' video file is empty (0 bytes): {fname}")
153
+ stats["cameras"] = cam_names
154
+
155
+ # 4. Consistency with robot_profile.yaml
156
+ try:
157
+ profile = load_profile(samples_root)
158
+ profile_cams = {c.name for c in profile.cameras}
159
+ header_cams = set(cam_names)
160
+ if profile_cams != header_cams:
161
+ warnings.append(
162
+ f"Camera names in header ({sorted(header_cams)}) differ from profile ({sorted(profile_cams)})."
163
+ )
164
+ profile_joints = set(profile.manipulator.joint_names or [])
165
+ header_joints = set(header.manipulator.get("joint_names") or [])
166
+ if profile_joints and header_joints and profile_joints != header_joints:
167
+ warnings.append(
168
+ f"Joint names in header ({sorted(header_joints)}) differ from profile ({sorted(profile_joints)})."
169
+ )
170
+ except Exception:
171
+ # Profile might not exist yet if just testing raw episodes; that's fine as a warning
172
+ pass
173
+
174
+ return {
175
+ "ok": len(problems) == 0,
176
+ "episode_id": episode_id,
177
+ "problems": problems,
178
+ "warnings": warnings,
179
+ "stats": stats,
180
+ }
181
+
182
+
183
+ def check_all_episodes(samples_root: Path) -> list[dict[str, Any]]:
184
+ """Runs health check on all episodes found under samples_root."""
185
+ from datahive.index import Index
186
+
187
+ with Index(samples_root) as idx:
188
+ idx.scan()
189
+ records = idx.all()
190
+ episode_ids = [r.episode_id for r in records]
191
+
192
+ return [check_episode(samples_root, eid) for eid in episode_ids]