Skip to content

Commit 5477f97

Browse files
committed
real robot: a fixed-plan explorer, and a record of every look
Three pieces for exercising the online loop on hardware, where the cost is wall-clock rather than compute. fixed_plan explorer. The planning explorers cost minutes per episode, so a full cycle is expensive to run for the sake of testing the loop around it. This one replays a plan file every episode and costs nothing, so a run that goes wrong is the loop's fault and not the planner's. It reads replay_plan's format, which means a plan dumped by probe_real_scene --dump-plan and already verified through replay_plan can be handed straight to the loop. Grounded per episode, because a human reset rebuilds the task and hands back fresh Objects. Every look is logged, not only the ones over tolerance. A run whose looks all behaved previously said nothing at all, so there was no way to distinguish "perception was healthy" from "nobody checked". The line carries the per-object breakdown, which is what tells a single knocked domino apart from a table-height offset shared by all of them -- the max alone cannot. Looks can be dumped to JSON (real_robot_observation_dump_dir, off by default). Each file records what was perceived beside what the twin predicted, so a session can be re-examined offline without the robot. Write failures are logged and swallowed: losing a dump must not take the arm down mid-episode. The learner is untouched.
1 parent 509361c commit 5477f97

5 files changed

Lines changed: 330 additions & 2 deletions

File tree

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
"""An explorer that replays one fixed option plan from a file.
2+
3+
A stand-in for the planning explorers when what is being tested is the online
4+
loop itself rather than what the agent chooses. The LLM-backed explorers cost
5+
minutes per episode, which makes a full cycle expensive to exercise on
6+
hardware; this one costs nothing and does the same thing every episode, so a
7+
run that goes wrong is the loop's fault and not the planner's.
8+
9+
The plan file is ``replay_plan.py``'s format -- one grounded option per line,
10+
``-> {...}`` subgoals optional and ignored::
11+
12+
Pick(robot:robot, domino_1:domino)[0.0657]
13+
Place(robot:robot)[0.70, 1.16, 0.55, 1.75]
14+
Wait(robot:robot)[]
15+
16+
so a plan dumped by ``probe_real_scene --dump-plan`` and verified through
17+
``replay_plan`` can be handed straight to the loop.
18+
"""
19+
20+
import logging
21+
import re
22+
from typing import Dict, List, Tuple
23+
24+
import numpy as np
25+
26+
from predicators import utils
27+
from predicators.explorers import BaseExplorer
28+
from predicators.settings import CFG
29+
from predicators.structs import ExplorationStrategy, Object, State, _Option
30+
31+
# Same grammar as scripts/domino_debug/replay_plan.py.
32+
_LINE = re.compile(r"^\s*(\w+)\s*\(([^)]*)\)\s*\[([^\]]*)\]")
33+
34+
35+
def _parse_plan(text: str) -> List[Tuple[str, List[str], List[float]]]:
36+
"""[(option_name, [obj_names], [param_floats]), ...] from the plan text."""
37+
steps: List[Tuple[str, List[str], List[float]]] = []
38+
for raw in text.splitlines():
39+
line = raw.split("->", 1)[0].strip()
40+
if not line or line.startswith("#"):
41+
continue
42+
match = _LINE.match(line)
43+
if not match:
44+
continue
45+
objs = [
46+
a.split(":", 1)[0].strip() for a in match.group(2).split(",")
47+
if a.strip()
48+
]
49+
floats = [float(v) for v in match.group(3).split(",") if v.strip()]
50+
steps.append((match.group(1), objs, floats))
51+
return steps
52+
53+
54+
class FixedPlanExplorer(BaseExplorer):
55+
"""Replays the plan at ``CFG.fixed_plan_explorer_path`` every episode."""
56+
57+
@classmethod
58+
def get_name(cls) -> str:
59+
return "fixed_plan"
60+
61+
def _ground(self, state: State) -> List[_Option]:
62+
"""Ground the plan's options against the objects in ``state``.
63+
64+
Grounded per episode rather than once, because a human reset
65+
rebuilds the task and hands back fresh ``Object`` instances.
66+
"""
67+
path = CFG.fixed_plan_explorer_path
68+
assert path, "fixed_plan explorer needs fixed_plan_explorer_path"
69+
with open(path, encoding="utf-8") as f:
70+
steps = _parse_plan(f.read())
71+
assert steps, f"no plan steps parsed from {path}"
72+
options: Dict[str, object] = {o.name: o for o in self._options}
73+
by_name: Dict[str, Object] = {o.name: o for o in state}
74+
plan = []
75+
for name, obj_names, params in steps:
76+
option = options[name]
77+
objs = [by_name[n] for n in obj_names]
78+
plan.append(
79+
option.ground( # type: ignore[attr-defined]
80+
objs, np.array(params, dtype=np.float32)))
81+
return plan
82+
83+
def _get_exploration_strategy(self, train_task_idx: int,
84+
timeout: int) -> ExplorationStrategy:
85+
del timeout # the plan is fixed; there is nothing to search
86+
state = self._train_tasks[train_task_idx].init
87+
plan = self._ground(state)
88+
logging.info("fixed_plan explorer: %s", [o.simple_str() for o in plan])
89+
policy = utils.option_plan_to_policy(
90+
plan,
91+
abstract_function=lambda s: utils.abstract(s, self._predicates))
92+
# Never terminate early: the plan running out raises
93+
# OptionExecutionFailure, which the interaction loop already handles,
94+
# and stopping sooner would cut the episode short of the plan.
95+
termination_function = lambda _: False
96+
return policy, termination_function

predicators/pybullet_helpers/real_robot_executor.py

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,10 @@
1616
"""
1717
from __future__ import annotations
1818

19+
import json
1920
import logging
20-
from typing import Any, List, Optional, Protocol, cast
21+
import os
22+
from typing import Any, Dict, List, Optional, Protocol, Tuple, cast
2123

2224
import numpy as np
2325

@@ -97,6 +99,8 @@ class TwinCorrector:
9799
def __init__(self, env: PyBulletEnv, divergence_atol: float) -> None:
98100
self._env = env
99101
self._divergence_atol = divergence_atol
102+
# Looks so far, used to name the dumps in the order they happened.
103+
self._look_count = 0
100104
# Largest per-object position disagreement between the twin and the
101105
# real scene at the last look, in metres; None before the first look.
102106
self.last_divergence: Optional[float] = None
@@ -118,20 +122,92 @@ def absorb(self, observation: Any) -> Observation:
118122
domain = cast(_DomainHooks, self._env)
119123
perceived = domain.state_from_observation(observation, predicted)
120124
self.last_divergence = _max_position_divergence(predicted, perceived)
125+
self._look_count += 1
126+
per_object = _per_object_divergence(predicted, perceived)
127+
# Log every look, not only the ones over tolerance: a run whose looks
128+
# all behaved should still say so, and the per-object breakdown is
129+
# what distinguishes one bad capture from a systematic offset.
130+
logging.info(
131+
"real robot: look %d, worst %.4f m (tolerance %.3f m); %s",
132+
self._look_count, self.last_divergence or float("nan"),
133+
self._divergence_atol, ", ".join(f"{obj.name} {dist:.4f}"
134+
for obj, dist in per_object))
121135
if self.last_divergence is not None and \
122136
self.last_divergence > self._divergence_atol:
123137
logging.warning(
124138
"real robot: the scene is %.3f m from where the twin "
125139
"predicted (tolerance %.3f m); the twin is being corrected, "
126140
"but the current plan was made against the prediction",
127141
self.last_divergence, self._divergence_atol)
142+
_dump_look(self._look_count, predicted, perceived, per_object,
143+
self.last_divergence)
128144
self._env.sync_to_state(perceived)
129145
# No need to refresh the env's cached observation by hand:
130146
# PyBulletEnv.get_observation re-reads the state out of PyBullet, so
131147
# this picks the corrected world up (and re-caches it).
132148
return self._env.get_observation()
133149

134150

151+
def _per_object_divergence(predicted: State,
152+
perceived: State) -> List[Tuple[Any, float]]:
153+
"""Per-object ``(object, distance)``, worst first.
154+
155+
``_max_position_divergence`` answers "how bad", which is what the
156+
tolerance is checked against; this answers "which object", which is
157+
what tells a knocked domino apart from a table-height offset shared
158+
by all of them.
159+
"""
160+
out = []
161+
for obj in predicted.data:
162+
if obj not in perceived.data:
163+
continue
164+
if not {"x", "y", "z"}.issubset(obj.type.feature_names):
165+
continue
166+
delta = np.array(
167+
[predicted.get(obj, f) - perceived.get(obj, f) for f in "xyz"])
168+
out.append((obj, float(np.linalg.norm(delta))))
169+
return sorted(out, key=lambda pair: pair[1], reverse=True)
170+
171+
172+
def _dump_look(index: int, predicted: State, perceived: State,
173+
per_object: List[Tuple[Any,
174+
float]], worst: Optional[float]) -> None:
175+
"""Write one look to ``CFG.real_robot_observation_dump_dir`` as JSON.
176+
177+
Records the twin's prediction beside what was perceived, so a
178+
session can be re-examined offline -- including the looks that
179+
raised no warning. Failing to write must never take the arm down
180+
mid-episode, so any error here is logged and swallowed.
181+
"""
182+
out_dir = CFG.real_robot_observation_dump_dir
183+
if not out_dir:
184+
return
185+
186+
def _poses(state: State) -> Dict[str, List[float]]:
187+
return {
188+
obj.name: [float(state.get(obj, f)) for f in "xyz"]
189+
for obj in state.data
190+
if {"x", "y", "z"}.issubset(obj.type.feature_names)
191+
}
192+
193+
record = {
194+
"look": index,
195+
"worst_divergence": worst,
196+
"divergence_atol": CFG.real_robot_divergence_atol,
197+
"predicted": _poses(predicted),
198+
"perceived": _poses(perceived),
199+
"per_object": {obj.name: dist
200+
for obj, dist in per_object},
201+
}
202+
try:
203+
os.makedirs(out_dir, exist_ok=True)
204+
path = os.path.join(out_dir, f"look_{index:04d}.json")
205+
with open(path, "w", encoding="utf-8") as f:
206+
json.dump(record, f, indent=2, sort_keys=True)
207+
except OSError as exc: # pragma: no cover - disk trouble only
208+
logging.warning("real robot: could not write %s (%s)", out_dir, exc)
209+
210+
135211
class RealRobotExecutor:
136212
"""Ships each option's trajectory to the arm and corrects the twin.
137213

predicators/settings.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -585,6 +585,16 @@ class GlobalSettings:
585585
# How far (metres) the scene may be from where the twin predicted before
586586
# the disagreement is worth logging.
587587
real_robot_divergence_atol = 0.02
588+
# Write every option-boundary look to this directory as JSON: what was
589+
# perceived, what the twin predicted, and the per-object disagreement.
590+
# Empty disables it. The divergence WARNING only fires above tolerance, so
591+
# without this a run leaves no record of the looks that behaved -- and no
592+
# way to tell a systematic offset from one bad capture after the fact.
593+
real_robot_observation_dump_dir = ""
594+
# Plan file the "fixed_plan" explorer replays every episode, in
595+
# replay_plan.py's format. Lets the online loop be exercised without
596+
# paying for a planning explorer.
597+
fixed_plan_explorer_path = ""
588598
# Between episodes, home the arm and wait for a human to rearrange the
589599
# scene, then rebuild that episode's task from what the cameras then see.
590600
# False keeps the captured scene, which is what a fixed-plan
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
"""Test cases for the fixed plan explorer class."""
2+
import pytest
3+
4+
from predicators import utils
5+
from predicators.envs.cover import CoverEnv
6+
from predicators.explorers import create_explorer
7+
from predicators.explorers.fixed_plan_explorer import _parse_plan
8+
from predicators.ground_truth_models import get_gt_options
9+
10+
11+
def _write_plan(tmp_path, text):
12+
path = tmp_path / "plan.txt"
13+
path.write_text(text, encoding="utf-8")
14+
return str(path)
15+
16+
17+
def test_parse_plan_matches_replay_plans_format():
18+
"""The explorer reads what probe_real_scene --dump-plan writes, so a plan
19+
verified through replay_plan can be handed straight to the loop.
20+
21+
That includes the '#' header those files carry and the '-> {...}'
22+
subgoal tail a solved plan may carry.
23+
"""
24+
steps = _parse_plan("# scene : whatever.json\n"
25+
"# solved : False\n"
26+
"\n"
27+
"Pick(robot:robot, domino_1:domino)[0.0657]\n"
28+
"Place(robot:robot)[0.70, 1.16] -> {Holding(x)}\n"
29+
"Wait(robot:robot)[]\n")
30+
assert steps == [
31+
("Pick", ["robot", "domino_1"], [0.0657]),
32+
("Place", ["robot"], [0.70, 1.16]),
33+
("Wait", ["robot"], []),
34+
]
35+
36+
37+
def test_fixed_plan_explorer_replays_the_file(tmp_path):
38+
"""The explorer's policy executes the plan on the file, not a search."""
39+
# cover's only option is PickPlace(), one param and no objects.
40+
plan_path = _write_plan(tmp_path, "PickPlace()[0.75]\n")
41+
utils.reset_config({
42+
"env": "cover",
43+
"seed": 0,
44+
"explorer": "fixed_plan",
45+
"fixed_plan_explorer_path": plan_path,
46+
})
47+
env = CoverEnv()
48+
train_tasks = [t.task for t in env.get_train_tasks()]
49+
explorer = create_explorer("fixed_plan", env.predicates,
50+
get_gt_options("cover"), env.types,
51+
env.action_space, train_tasks)
52+
policy, termination_function = explorer.get_exploration_strategy(0, 500)
53+
54+
# Never terminates on its own; the plan running out is what ends it.
55+
assert not termination_function(train_tasks[0].init)
56+
act = policy(train_tasks[0].init)
57+
assert env.action_space.contains(act.arr)
58+
assert act.get_option().name == "PickPlace"
59+
60+
61+
def test_fixed_plan_explorer_needs_a_path():
62+
"""A missing path fails loudly rather than exploring some other way."""
63+
utils.reset_config({
64+
"env": "cover",
65+
"seed": 0,
66+
"explorer": "fixed_plan",
67+
"fixed_plan_explorer_path": "",
68+
})
69+
env = CoverEnv()
70+
train_tasks = [t.task for t in env.get_train_tasks()]
71+
explorer = create_explorer("fixed_plan", env.predicates,
72+
get_gt_options("cover"), env.types,
73+
env.action_space, train_tasks)
74+
with pytest.raises(AssertionError, match="fixed_plan_explorer_path"):
75+
explorer.get_exploration_strategy(0, 500)

tests/pybullet_helpers/test_real_robot_executor.py

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
"""
1414
import ast
1515
import inspect
16+
import json
1617
from typing import Any, List, Optional, cast
1718

1819
import numpy as np
@@ -23,7 +24,8 @@
2324
from predicators.envs.pybullet_env import PyBulletEnv
2425
from predicators.pybullet_helpers.real_robot_bridge import GripperJointLayout
2526
from predicators.pybullet_helpers.real_robot_executor import \
26-
OptionBoundaryBuffer, RealRobotExecutor, attach_real_robot
27+
OptionBoundaryBuffer, RealRobotExecutor, _dump_look, \
28+
_per_object_divergence, attach_real_robot
2729
from predicators.structs import Action, Object, ParameterizedOption, State, \
2830
Type
2931

@@ -486,3 +488,72 @@ def test_attach_rejects_a_non_pybullet_env():
486488
with pytest.raises(TypeError) as exc:
487489
attach_real_robot(cast(Any, object()), _StubRobot())
488490
assert "PyBullet" in str(exc.value)
491+
492+
493+
# -- per-look observability --------------------------------------------------
494+
495+
_OTHER = Object("block1", _BLOCK_TYPE)
496+
497+
498+
def _two_object_state(x0: float, x1: float) -> State:
499+
"""A two-object state, so a per-object breakdown has something to break
500+
down."""
501+
return State({
502+
_BLOCK: np.array([x0, 0.0, 0.0]),
503+
_OTHER: np.array([x1, 0.0, 0.0]),
504+
})
505+
506+
507+
def test_per_object_divergence_names_the_object_and_orders_by_distance():
508+
"""The max alone cannot tell a knocked object from a shared offset.
509+
510+
``_max_position_divergence`` answers "how bad", which is what the
511+
tolerance tests; this answers "which", which is what a human reads.
512+
"""
513+
predicted = _two_object_state(0.0, 0.0)
514+
perceived = _two_object_state(0.01, 0.05)
515+
516+
result = _per_object_divergence(predicted, perceived)
517+
518+
assert [obj.name for obj, _ in result] == ["block1", "block0"]
519+
assert result[0][1] == pytest.approx(0.05)
520+
assert result[1][1] == pytest.approx(0.01)
521+
522+
523+
def test_dump_look_writes_both_sides_of_the_comparison(tmp_path):
524+
"""A dumped look records the prediction beside the perception.
525+
526+
Recording only the divergence would leave a session unable to say
527+
WHERE things were, which is what makes a capture re-examinable
528+
offline.
529+
"""
530+
utils.reset_config({
531+
"seed": 0,
532+
"real_robot_observation_dump_dir": str(tmp_path),
533+
})
534+
predicted = _two_object_state(0.0, 0.0)
535+
perceived = _two_object_state(0.01, 0.05)
536+
537+
_dump_look(3, predicted, perceived,
538+
_per_object_divergence(predicted, perceived), 0.05)
539+
540+
written = sorted(tmp_path.glob("*.json"))
541+
assert [f.name for f in written] == ["look_0003.json"]
542+
record = json.loads(written[0].read_text(encoding="utf-8"))
543+
assert record["look"] == 3
544+
assert record["worst_divergence"] == pytest.approx(0.05)
545+
assert record["predicted"]["block1"] == [0.0, 0.0, 0.0]
546+
assert record["perceived"]["block1"] == [0.05, 0.0, 0.0]
547+
assert record["per_object"]["block1"] == pytest.approx(0.05)
548+
549+
550+
def test_dump_look_is_off_by_default(tmp_path):
551+
"""Dumping is opt-in: an unset directory writes nothing at all."""
552+
utils.reset_config({"seed": 0, "real_robot_observation_dump_dir": ""})
553+
predicted = _two_object_state(0.0, 0.0)
554+
perceived = _two_object_state(0.01, 0.0)
555+
556+
_dump_look(1, predicted, perceived,
557+
_per_object_divergence(predicted, perceived), 0.01)
558+
559+
assert not list(tmp_path.iterdir())

0 commit comments

Comments
 (0)