| """Reusable success conditions for the YAM task suite. |
| |
| Modelled on RoboLab's ``robolab/core/task/conditionals.py``: a task declares *what* counts as |
| success as a list of small predicates, instead of open-coding the geometry test in the demo |
| script. Each predicate takes the world state dict produced by a task script and returns a bool, |
| so the same condition can be reused by the runner, by an eval harness, or by a policy's reward. |
| |
| State dict shape (env-local metres, table top at ``TABLE_TOP``):: |
| |
| {"objects": {"grape": np.array([x, y, z]), ...}, |
| "quats": {"pot": np.array([w, x, y, z]), ...}, # optional |
| "regions": {"basket": {"xy": (x, y), "half": 0.085, "top_z": 0.52}}, |
| "start_z": {"grape": 0.449}} |
| """ |
| from __future__ import annotations |
|
|
| from dataclasses import dataclass |
| from typing import Callable |
|
|
| import numpy as np |
|
|
| TABLE_TOP = 0.45 |
|
|
|
|
| def _pos(state, name): |
| p = state.get("objects", {}).get(name) |
| if p is None: |
| raise KeyError(f"object {name!r} not in state; have {sorted(state.get('objects', {}))}") |
| return np.asarray(p, dtype=float) |
|
|
|
|
| def _region(state, name): |
| r = state.get("regions", {}).get(name) |
| if r is None: |
| raise KeyError(f"region {name!r} not in state; have {sorted(state.get('regions', {}))}") |
| return r |
|
|
|
|
| def object_in_region(object: str, region: str, pad: float = 0.02, below_top: float = 0.16): |
| """Object's centre is inside the region footprint and low enough to be *in* it, not on top.""" |
| def cond(state): |
| p = _pos(state, object); r = _region(state, region) |
| half = float(r["half"])+pad |
| dx, dy = abs(p[0]-r["xy"][0]), abs(p[1]-r["xy"][1]) |
| ok_xy = dx < half and dy < half |
| ok_z = p[2] < TABLE_TOP+below_top |
| |
| |
| if not (ok_xy and ok_z): |
| print(f"[cond] {object} at ({p[0]:+.3f},{p[1]:+.3f},{p[2]:.3f}) vs {region} " |
| f"centre ({r['xy'][0]:+.3f},{r['xy'][1]:+.3f}) half={half:.3f}: " |
| f"dx={dx:.3f} dy={dy:.3f} {'' if ok_xy else 'OUTSIDE-XY '}" |
| f"{'' if ok_z else 'TOO-HIGH(sitting on the rim?)'}", flush=True) |
| return ok_xy and ok_z |
| return cond |
|
|
|
|
| def object_on_surface(object: str, region: str, pad: float = 0.03, tol: float = 0.01): |
| """Object rests ON the region's top surface (a scale, plate, coaster).""" |
| def cond(state): |
| p = _pos(state, object); r = _region(state, region) |
| half = float(r["half"])+pad |
| return (abs(p[0]-r["xy"][0]) < half and abs(p[1]-r["xy"][1]) < half |
| and p[2] > float(r["top_z"])-tol) |
| return cond |
|
|
|
|
| def object_not_in_region(object: str, region: str, pad: float = 0.02): |
| """A distractor must be left alone -- the negative half of a semantic-selection task.""" |
| inner = object_in_region(object, region, pad=pad) |
| return lambda state: not inner(state) |
|
|
|
|
| def object_lifted(object: str, min_dz: float = 0.05): |
| """Object rose by at least ``min_dz`` from where it started (a real grasp, not a nudge).""" |
| def cond(state): |
| z0 = state.get("start_z", {}).get(object) |
| peak = state.get("peak_z", {}).get(object, _pos(state, object)[2]) |
| return z0 is not None and (float(peak)-float(z0)) > min_dz |
| return cond |
|
|
|
|
| def object_seated_in_hole(object: str, region: str, xy_tol: float = 0.02, half_h: float = 0.065): |
| """Peg inserted: centred on the hole AND down at table level, not perched on the socket.""" |
| def cond(state): |
| p = _pos(state, object); r = _region(state, region) |
| return (abs(p[0]-r["xy"][0]) < xy_tol and abs(p[1]-r["xy"][1]) < xy_tol |
| and p[2] < TABLE_TOP+half_h+0.02) |
| return cond |
|
|
|
|
| def objects_stacked(lower: str, upper: str, min_dz: float = 0.03, xy_tol: float = 0.03): |
| """``upper`` sits on top of ``lower``, roughly aligned.""" |
| def cond(state): |
| a = _pos(state, lower); b = _pos(state, upper) |
| return (b[2]-a[2]) > min_dz and abs(b[0]-a[0]) < xy_tol and abs(b[1]-a[1]) < xy_tol |
| return cond |
|
|
|
|
| def _up_axis(q): |
| w, x, y, z = [float(v) for v in q] |
| return np.array([2*(x*z+y*w), 2*(y*z-x*w), 1-2*(x*x+y*y)]) |
|
|
|
|
| def object_level(object: str, max_tilt_deg: float = 25.0): |
| """Object has not tilted away from HOW IT WAS RESTING. |
| |
| Not "its local +z points up": a mesh that needs a 90 deg roll to stand upright (most RoboTwin |
| assets) has its local +z horizontal by construction, so comparing to world up marks a |
| perfectly level pot as 90 deg tilted. |
| """ |
| def cond(state): |
| q = state.get("quats", {}).get(object) |
| q0 = state.get("start_quats", {}).get(object) |
| if q is None: |
| return False |
| now = _up_axis(q) |
| ref = _up_axis(q0) if q0 is not None else np.array([0.0, 0.0, 1.0]) |
| cosang = float(np.dot(now, ref)/((np.linalg.norm(now)*np.linalg.norm(ref))+1e-9)) |
| return float(np.degrees(np.arccos(np.clip(cosang, -1.0, 1.0)))) < max_tilt_deg |
| return cond |
|
|
|
|
| def count_in_region(objects: list[str], region: str, at_least: int, pad: float = 0.03, |
| below_top: float = 0.10): |
| """At least N of the listed objects ended up in the region (beads swept/poured). |
| |
| `below_top` is not optional in practice: without a height bound this counts a bead that is |
| still sitting in the vessel being carried over the target, which reports a successful pour |
| for an episode that never poured. |
| """ |
| def cond(state): |
| r = _region(state, region); half = float(r["half"])+pad |
| top = float(r.get("top_z", TABLE_TOP)) |
| n, where = 0, [] |
| for o in objects: |
| p = state.get("objects", {}).get(o) |
| if p is None: |
| where.append(f"{o}=missing") |
| continue |
| p = np.asarray(p, dtype=float) |
| ok = (abs(p[0]-r["xy"][0]) < half and abs(p[1]-r["xy"][1]) < half |
| and p[2] < top+below_top) |
| where.append(f"{o}=({p[0]:+.3f},{p[1]:+.3f},{p[2]:.3f}){'IN' if ok else ''}") |
| n += int(ok) |
| print(f"[cond] {region} half={half:.3f} top={top:.3f} -> {n}/{len(objects)} in: " |
| + " ".join(where), flush=True) |
| return n >= at_least |
| return cond |
|
|
|
|
| def object_moved_to(object: str, region: str, tol: float = 0.07): |
| """Object ended near the target -- used by push/slide, where nothing is grasped.""" |
| def cond(state): |
| p = _pos(state, object); r = _region(state, region) |
| return float(np.hypot(p[0]-r["xy"][0], p[1]-r["xy"][1])) < tol |
| return cond |
|
|
|
|
| def joint_moved(fixture: str, index: int = 0, by: float = 0.5): |
| """A jointed fixture's joint travelled at least `by` from where the episode started. |
| |
| Radians for a hinge (door, laptop lid, toggle), metres for a slider (drawer). Signed |
| magnitude, so it reads the same whether the task is to open or to close. |
| """ |
| def cond(state): |
| q0 = state.get("start_joints", {}).get((fixture, index)) |
| q = state.get("joints", {}).get((fixture, index)) |
| if q is None or q0 is None: |
| return False |
| return abs(float(q)-float(q0)) >= float(by) |
| return cond |
|
|
|
|
| def link_span_at_least(fixture: str, a: int, b: int, min_span: float): |
| """Two links of a chain ended at least `min_span` apart -- i.e. the rope is taut. |
| |
| Measured on the rope's own geometry rather than on where the hands are: a hand can be in the |
| right place while the rope hangs slack between them. |
| """ |
| def cond(state): |
| pa = state.get("links", {}).get((fixture, a)) |
| pb = state.get("links", {}).get((fixture, b)) |
| if pa is None or pb is None: |
| print(f"[cond] {fixture}: link {a}/{b} not in state", flush=True); return False |
| d = float(np.linalg.norm(np.asarray(pa)-np.asarray(pb))) |
| print(f"[cond] {fixture} span link{a}-link{b} = {d:.3f} m (need {min_span:.3f})", flush=True) |
| return d >= float(min_span) |
| return cond |
|
|
|
|
| def link_above(fixture: str, index: int, min_z: float = 0.04): |
| """One link of a chain is at least `min_z` above the table.""" |
| def cond(state): |
| p = state.get("links", {}).get((fixture, index)) |
| ok = p is not None and float(p[2]) >= TABLE_TOP+float(min_z) |
| print(f"[cond] {fixture} link{index} z=" |
| f"{'?' if p is None else round(float(p[2])-TABLE_TOP,3)} m above the table " |
| f"(need {min_z})", flush=True) |
| return ok |
| return cond |
|
|
|
|
| def joint_driven(fixture: str, index: int = 0, by: float = 0.3): |
| """The ROBOT moved this joint by `by`, measured across the manipulation itself. |
| |
| joint_moved() compares against a baseline the env recorded earlier, which also counts the |
| fixture settling under gravity and any pre-action blow-up. This reads the delta the solver |
| measured between "about to act" and "finished acting", and refuses a joint that ended |
| outside its own limits. |
| """ |
| def cond(state): |
| d = state.get("joint_delta", {}).get((fixture, index)) |
| sane = state.get("joint_sane", {}).get(fixture, True) |
| print(f"[cond] {fixture} joint {index}: robot moved it " |
| f"{'none' if d is None else round(float(d), 3)} (need {by}), " |
| f"within limits={sane}", flush=True) |
| return d is not None and float(d) >= float(by) and sane |
| return cond |
|
|
|
|
| def joint_at_least(fixture: str, index: int = 0, value: float = 0.6): |
| """A jointed fixture reached an absolute joint value (a door actually standing open).""" |
| def cond(state): |
| q = state.get("joints", {}).get((fixture, index)) |
| return q is not None and float(q) >= float(value) |
| return cond |
|
|
|
|
| def object_tipped_over(object: str, region: str, min_tilt_deg: float = 75.0, xy_tol: float = 0.12): |
| """Vessel was brought over a target and rotated far enough to pour out of. |
| |
| This is the honest success test for pouring in a rigid-body sim. A vessel that has to be |
| LIFTED must be a convex dynamic body -- PhysX will not simulate a hollow carried mesh -- so |
| its interior is solid and anything "inside" is pushed out to the table before the episode |
| starts. What can be checked is the pour itself: the vessel above the target, tipped past the |
| angle at which its contents would leave it. |
| """ |
| def cond(state): |
| p = _pos(state, object); r = _region(state, region) |
| q = state.get("peak_tilt", {}).get(object) |
| d = float(np.hypot(p[0]-r["xy"][0], p[1]-r["xy"][1])) |
| ok = q is not None and float(q) >= float(min_tilt_deg) and d < float(xy_tol) |
| print(f"[cond] {object}: peak tilt {q if q is None else round(float(q),1)} deg " |
| f"(need {min_tilt_deg}), ended {d:.3f} m from {region} (need <{xy_tol})", flush=True) |
| return ok |
| return cond |
|
|
|
|
| def object_settled_on(object: str, region: str, max_dz: float = 0.03): |
| """Object came to rest ON a fixture's base rather than hanging up partway. |
| |
| A ring balanced on the tip of a post satisfies "centred over the post" just as well as one |
| that slid all the way down, so the height above the base is what separates them. |
| """ |
| def cond(state): |
| p = _pos(state, object); r = _region(state, region) |
| return p[2]-float(r["top_z"]) < float(max_dz) |
| return cond |
|
|
|
|
| def object_clear_of(object: str, region: str, extent: float, margin: float = 0.0): |
| """Object has been drawn ENTIRELY out of a fixture -- the inverse of object_moved_to. |
| |
| `extent` is the object's own half-length along the pull axis, so the check is "its trailing |
| end is past the fixture's mouth", not merely "its centre moved a bit". |
| """ |
| def cond(state): |
| p = _pos(state, object); r = _region(state, region) |
| d = float(np.hypot(p[0]-r["xy"][0], p[1]-r["xy"][1])) |
| return d > float(r.get("half", 0.0))+float(extent)-float(margin) |
| return cond |
|
|
|
|
| @dataclass |
| class Subtask: |
| """A named step whose ``conditions`` must ALL hold (RoboLab's Subtask, trimmed).""" |
| name: str |
| conditions: list[Callable] |
|
|
| def satisfied(self, state) -> bool: |
| return all(c(state) for c in self.conditions) |
|
|
|
|
| def evaluate(subtasks: list[Subtask], state) -> tuple[bool, dict[str, bool]]: |
| """Returns (all_passed, per-subtask results).""" |
| per = {s.name: s.satisfied(state) for s in subtasks} |
| return all(per.values()), per |
|
|
|
|
| def labelled(label: str, cond): |
| """Attach a human-readable label to a predicate, for the per-condition PASS/FAIL report.""" |
| cond.label = label |
| return cond |
|
|