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.
- datahive/__init__.py +31 -0
- datahive/annotate.py +123 -0
- datahive/attachments.py +71 -0
- datahive/attachments.yaml +124 -0
- datahive/check.py +192 -0
- datahive/cli.py +567 -0
- datahive/collect.py +158 -0
- datahive/config.py +200 -0
- datahive/consistency.py +313 -0
- datahive/episode.py +378 -0
- datahive/errors.py +65 -0
- datahive/hf_limits.py +48 -0
- datahive/hub.py +118 -0
- datahive/index.py +236 -0
- datahive/interface/__init__.py +0 -0
- datahive/interface/api.py +748 -0
- datahive/interface/app.py +32 -0
- datahive/interface/static/app.js +2678 -0
- datahive/interface/static/automatic.js +242 -0
- datahive/interface/static/favicon.svg +9 -0
- datahive/interface/static/guide.js +116 -0
- datahive/interface/static/hiveboard-logo.svg +43 -0
- datahive/interface/static/index.html +350 -0
- datahive/interface/static/nav.js +34 -0
- datahive/interface/static/runner.js +1137 -0
- datahive/interface/static/style.css +1828 -0
- datahive/interface/static/tasks/big_valve_3d.png +0 -0
- datahive/interface/static/tasks/box_3d.png +0 -0
- datahive/interface/static/tasks/button_3d.png +0 -0
- datahive/interface/static/tasks/key_3d.png +0 -0
- datahive/interface/static/tasks/lamp_3d.png +0 -0
- datahive/interface/static/tasks/m30_3d.png +0 -0
- datahive/interface/static/tasks/m8_3d.png +0 -0
- datahive/interface/static/tasks/peg_and_hole_3d.png +0 -0
- datahive/interface/static/tasks/small_valve_3d.png +0 -0
- datahive/interface/static/tasks/spring_3d.png +0 -0
- datahive/interface/static/tasks/switch_3d.png +0 -0
- datahive/interface/static/tasks/torque_valve_noFriction_3d.png +0 -0
- datahive/ops.py +384 -0
- datahive/paths.py +143 -0
- datahive/preflight.py +111 -0
- datahive/profile.py +530 -0
- datahive/runner.py +485 -0
- datahive/schema.py +251 -0
- datahive/skill_install.py +45 -0
- datahive/skills/datahive-auto-collect/SKILL.md +108 -0
- datahive/skills/datahive-auto-collect/reference/adapting.md +152 -0
- datahive/skills/datahive-auto-collect/reference/protocol.md +73 -0
- datahive/skills/datahive-data-prep/SKILL.md +108 -0
- datahive/skills/datahive-data-prep/reference/annotation.md +84 -0
- datahive/skills/datahive-data-prep/reference/layout-and-format.md +144 -0
- datahive/skills/datahive-data-prep/reference/robot-profile.md +92 -0
- datahive/skills/datahive-data-prep/reference/troubleshooting.md +68 -0
- datahive/trials.py +120 -0
- datahive/validate.py +125 -0
- datahive_tools-0.1.0.dist-info/METADATA +117 -0
- datahive_tools-0.1.0.dist-info/RECORD +61 -0
- datahive_tools-0.1.0.dist-info/WHEEL +5 -0
- datahive_tools-0.1.0.dist-info/entry_points.txt +2 -0
- datahive_tools-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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
|
datahive/attachments.py
ADDED
|
@@ -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]
|