diff --git a/docs/RENDERING.md b/docs/RENDERING.md index cac26276f..6c10e5ac4 100644 --- a/docs/RENDERING.md +++ b/docs/RENDERING.md @@ -92,7 +92,7 @@ The Bridge selection behind Figure 2's act panel and Figure 1's lift illustratio The selected Domino, Fan, Balloons and Boil runs are declared in `scripts/paper_figures/export_static_scenes.py`; `--domains` re-exports a subset. Domino, Fan and Balloons declare their environment settings there, and Boil restores its run's settings from the launch command in `info.log`. Boil shows the two-jug test episode of the run behind its trajectory stripe, so its final frame matches the stripe's last frame. -Balloons draws its ceiling, the height at which balloons burst, as a red cap over the chute instead of the environment's translucent plate over the table, and draws the strings of tied balloons, which the environment otherwise draws only when it renders an image. +Balloons writes its ceiling, the height at which balloons burst, as the red cap over the chute that the environment draws, and draws the strings of tied balloons, which the environment otherwise draws only when it renders an image. Boil draws water no higher than the jug rim and redraws the spill puddle that restoring a state omits. Figure 1 also draws the test task's cyan jug in the red that the same jug has in training, since the ambient response washes cyan out; the Boil stripe keeps the recorded cyan. Figure 1 also colors Boil water by the heat that its recorded bubbling level fixes, as the environment does, since restoring a state resets the hidden heat and would draw boiled water blue. @@ -221,6 +221,20 @@ Balloons shares Figure 1's red chute cap and balloon strings. Boil liquid is drawn no higher than the jug rim, because the environment lets water rise above the rim before it overflows, which reads as an upturned jug. The Bridge model frame draws the glue the model remembers as the environment's glue patches. +## Run videos in the current layouts + +The harness's `run.mp4` replays a run's actions in the environment of the run's own code. +The Balloons and Fan scenes changed after their paper runs were recorded, so replaying those actions in the current environment would not reproduce the runs. +`render_run_videos.py` instead restores every recorded state, moves it into the current layout as the figures do, and renders it with the environment's PyBullet camera beside the harness video's panel. +It renders the runs behind the Balloons and Fan stripes, at the stride and frame rate of each run's own video: + +```bash +PYTHONPATH=.:scripts/paper_figures python scripts/paper_figures/render_run_videos.py \ + --domains Balloons Fan --out-dir logs/paper_run_videos +``` + +A frame takes about a second, so render on a compute node; the project page uses these videos. + ## Real-world trajectory The last row of Figure 3 shows the real-robot Fan-Domino cascade run of 2026-09-22 (`exp_20260922_134142`). diff --git a/predicators/envs/pybullet_balloons_base.py b/predicators/envs/pybullet_balloons_base.py index b69cca874..ad86e1e0f 100644 --- a/predicators/envs/pybullet_balloons_base.py +++ b/predicators/envs/pybullet_balloons_base.py @@ -31,8 +31,9 @@ says what opening one does. - The ``band``: a translucent slab crossing the box's chute, spanning the heights the goal wants the box to float at. -- The ``ceiling``: a plate drawn over the table, at ``ceiling_z``. - Nothing in this file says what reaching it does to a balloon. +- The ``ceiling``: a red cap drawn over the box's chute at + ``ceiling_z`` (a plate over the table in the hatch scene). Nothing in + this file says what reaching it does to a balloon. """ from typing import Any, ClassVar, Dict, FrozenSet, List, Optional, Set, Tuple @@ -155,12 +156,17 @@ def get_base_sim_source_files(cls) -> List[str]: clip_slider_color: ClassVar[Tuple[float, float, float, float]] = (0.92, 0.92, 0.94, 1.0) - # The ceiling: a plate drawn over the table. + # The ceiling: a picture of the burst height. The chute scene draws it + # as an opaque red cap across the chute, the only column the balloons + # rise in; the hatch scene draws a translucent plate over the table. + # Both are ceiling_half_extents[2] thick, centred on ceiling_z. ceiling_z: ClassVar[float] = table_height + 0.78 ceiling_half_extents: ClassVar[Tuple[float, float, float]] = (0.45, 0.35, 0.004) ceiling_color: ClassVar[Tuple[float, float, float, float]] = (0.85, 0.85, 0.90, 0.35) + ceiling_cap_color: ClassVar[Tuple[float, float, float, + float]] = (0.80, 0.16, 0.14, 1.0) # The chute: two fixed vertical walls flanking the box's column, a # slot the box must rise through to reach the band. They collide ONLY @@ -302,6 +308,22 @@ def obstacle_geometry(cls) -> List[Tuple[Pose3D, Pose3D]]: return [(half, (cls.box_xy[0] + sign * (cls.chute_half_gap + half[0]), cls.box_xy[1], z)) for sign in (-1.0, 1.0)] + @classmethod + def ceiling_geometry( + cls) -> Tuple[Pose3D, Pose3D, Tuple[float, float, float, float]]: + """Half extents, centre and colour of the ceiling picture. + + In the chute scene it is a cap spanning the chute walls; in the + hatch scene, a plate over the table. + """ + if CFG.balloons_scene == "hatch": + return (cls.ceiling_half_extents, (cls.x_mid, 1.35, cls.ceiling_z), + cls.ceiling_color) + half = (cls.chute_half_gap + 2 * cls.chute_wall_half_thickness, + cls.chute_wall_half_depth, cls.ceiling_half_extents[2]) + return (half, (cls.box_xy[0], cls.box_xy[1], cls.ceiling_z), + cls.ceiling_cap_color) + @classmethod def box_top_point(cls, state: State, box: Object) -> Tuple[float, float, float]: @@ -487,16 +509,16 @@ def initialize_pybullet( # The ceiling is a picture, not a body: the arm swings through # its height, and what a balloon does there is a rule of the # concrete env, read off the balloon's height. - ceiling_visual = p.createVisualShape( - p.GEOM_BOX, - halfExtents=cls.ceiling_half_extents, - rgbaColor=cls.ceiling_color, - physicsClientId=physics_client_id) + ceiling_half, ceiling_position, ceiling_color = cls.ceiling_geometry() + ceiling_visual = p.createVisualShape(p.GEOM_BOX, + halfExtents=ceiling_half, + rgbaColor=ceiling_color, + physicsClientId=physics_client_id) bodies["ceiling_id"] = p.createMultiBody( baseMass=0.0, baseCollisionShapeIndex=-1, baseVisualShapeIndex=ceiling_visual, - basePosition=(cls.x_mid, 1.35, cls.ceiling_z), + basePosition=ceiling_position, physicsClientId=physics_client_id) # The chute walls: real collision bodies, but filtered below to # collide only with the box. Centred on the box's column, one on diff --git a/predicators/envs/pybullet_fan.py b/predicators/envs/pybullet_fan.py index 03b8c9981..15a5982a6 100644 --- a/predicators/envs/pybullet_fan.py +++ b/predicators/envs/pybullet_fan.py @@ -25,6 +25,21 @@ from predicators.structs import Action, EnvironmentTask, GroundAtom, Object, \ Predicate, State, StepOption, TaskEvaluator, Type +# The flags that turn the default Fan domain, the paper's exposed deck and +# ramp, back into the historical uniform/maze arena. Its tasks and physics +# are unchanged; these were the defaults before September 28, 2026. +HISTORICAL_ARENA_FLAGS: Dict[str, Any] = { + "fan_exposed_transfer": False, + "fan_inertial_transfer": False, + "fan_ramp_transfer": False, + "fan_ramp_rise": 0.004, + "fan_ramp_landing_extension": 0.0, + "fan_test_num_pos_x": 6, + "fan_test_num_pos_y": 6, + "fan_train_num_walls_per_task": [1], + "fan_test_num_walls_per_task": [2, 3], +} + class FanTransferEvaluator(TaskEvaluator): """Require settled arrival; a ball falling off the deck ends the level.""" diff --git a/predicators/settings.py b/predicators/settings.py index 591c4b5ca..a575371dd 100644 --- a/predicators/settings.py +++ b/predicators/settings.py @@ -1152,29 +1152,34 @@ class GlobalSettings: fan_known_controls_relation = True fan_combine_switch_on_off = False fan_use_kinematic = False - # Separate pilot: a protected calibration tray and exposed L-shaped deck. - # Historical uniform/maze tasks and their physics remain unchanged. - fan_exposed_transfer = False - # Separate inertial pilot; requires exposed transfer. Preserve r1 physics. - fan_inertial_transfer = False - # Optional visible downhill ramp, with a protected training counterpart. - fan_ramp_transfer = False + # The defaults below are the paper's Fan domain (scripts/configs/empiric + # and the paper figures): a protected calibration tray for training, an + # exposed L-shaped deck with a downhill ramp for the test, no walls, and + # a 3 x 3 grid. HISTORICAL_ARENA_FLAGS in pybullet_fan.py turns the + # transfers off and restores the walls and the 6 x 6 test grid of the + # historical uniform/maze arena, whose tasks and physics are unchanged. + fan_exposed_transfer = True + # Inertial ball dynamics; requires exposed transfer. + fan_inertial_transfer = True + # A visible downhill ramp, with a protected training counterpart. + fan_ramp_transfer = True # Visible elevation drop shared by training and test ramps, in metres. - fan_ramp_rise = 0.004 + fan_ramp_rise = 0.003 # Extra exposed landing length, without moving the target or adding walls. - fan_ramp_landing_extension = 0.0 + fan_ramp_landing_extension = 0.10 fan_train_num_pos_x = 3 fan_train_num_pos_y = 3 - # The historical 6 x 6 uniform test split. The loc bounds in + # The historical arena's test split was 6 x 6. The loc bounds in # pybullet_fan.py admit at most 10 x 9 cells at the 8 cm pitch, which # fills the arena up to the fan rows; the historical maze split used # that full grid. - fan_test_num_pos_x = 6 - fan_test_num_pos_y = 6 - fan_train_num_walls_per_task = [1] + fan_test_num_pos_x = 3 + fan_test_num_pos_y = 3 + # The historical arena used [1] for training and [2, 3] for the test. + fan_train_num_walls_per_task = [0] # Under "maze" generation this is the number of wall cells; the walls # are laid as straight segments (see fan_maze_max_segment_len). - fan_test_num_walls_per_task = [2, 3] + fan_test_num_walls_per_task = [0] # How ball, target and walls are laid out, per split: # "uniform": ball, target and walls at uniformly random cells, kept # only if a cardinal path exists (the historical generator); diff --git a/scripts/paper_figures/export_static_scenes.py b/scripts/paper_figures/export_static_scenes.py index f5adc7863..17685be1b 100644 --- a/scripts/paper_figures/export_static_scenes.py +++ b/scripts/paper_figures/export_static_scenes.py @@ -6,8 +6,9 @@ - Fan and Balloons states move into the current scene layouts; motion relative to the platforms or chute is unchanged. - Balloons draws its burst height as a red cap over the chute. The - environment pictures that height as a translucent plate over the whole - table, but balloons only rise with the box, inside the chute. + environment now draws the same cap in place of its old translucent + plate over the whole table; the export still writes the cap's values + and its note, so the committed scenes are unchanged. - Boil liquid is drawn no higher than the jug rim. The environment lets water rise above the rim before it overflows, which reads as an upturned jug. Its spill puddle, which restoring a state omits, is @@ -27,7 +28,7 @@ import shlex import sys from pathlib import Path -from typing import Any, Dict, Tuple +from typing import Any, Dict, List, Tuple from unittest.mock import patch import pybullet as p @@ -156,21 +157,34 @@ def _migrate_balloons_layout(env: Any, state: State) -> State: def _cap_chute(env: Any, scene: Dict[str, Any]) -> None: - """Redraw an exported Balloons ceiling as a red cap over the chute. + """Write the Balloons ceiling cap into an exported scene. - The cap keeps the plate's height and thickness, so its underside is - still the burst height, and spans the chute walls. + The environment draws this cap across the chute walls, with its + underside at the burst height. Writing its exact values keeps the + committed scenes, exported when the environment still drew a plate, + unchanged. """ assert CFG.balloons_scene == "chute" ceiling, = (shape for shape in scene["shapes"] if shape["body"] == env._ceiling_id) # pylint: disable=protected-access - half = (env.chute_half_gap + 2 * env.chute_wall_half_thickness, - env.chute_wall_half_depth, env.ceiling_half_extents[2]) + half, position, _ = env.ceiling_geometry() ceiling.update(dimensions=[2 * h for h in half], - position=[*env.box_xy, env.ceiling_z], + position=list(position), rgba=list(CHUTE_CAP_RGBA)) +def _current_flags(argv: List[str]) -> List[str]: + """Drop the ``--name value`` overrides that the current code no longer + defines; nothing reads them.""" + _, overrides = utils.create_arg_parser().parse_known_args(argv[1:]) + kept = list(argv) + for flag, _ in zip(overrides[:-1:2], overrides[1::2]): + if flag[2:] not in CFG.__dict__: + at = kept.index(flag) + del kept[at:at + 2] + return kept + + def _load_run_config(run: Path) -> None: """Restore the flags of a trusted local run from its launch command.""" info = re.sub(r"\x1b\[[0-9;]*m", "", (run / "info.log").read_text()) @@ -179,7 +193,7 @@ def _load_run_config(run: Path) -> None: if "Running command:" in line) argv = sys.argv try: - sys.argv = shlex.split(command)[1:] + sys.argv = _current_flags(shlex.split(command)[1:]) utils.reset_config(utils.parse_args()) finally: sys.argv = argv diff --git a/scripts/paper_figures/render_run_videos.py b/scripts/paper_figures/render_run_videos.py new file mode 100644 index 000000000..2e32ffe32 --- /dev/null +++ b/scripts/paper_figures/render_run_videos.py @@ -0,0 +1,229 @@ +"""Render labelled run videos from recorded states in the current scene +layouts. + +The harness's run.mp4 (predicators/run/continual_video.py) replays a +run's actions through the env of the run's own code. The Balloons and +Fan scenes have changed since their paper runs were recorded: the Fan +platforms, fan banks and camera moved, and the Balloons chute moved and +gained a red cap for its ceiling. Replaying those actions in the current +env would not reproduce the runs. This script instead restores every +recorded state, moves it into the current layout exactly as the paper +figures do (export_static_scenes.py), renders it with the env's own +PyBullet camera, and writes the harness video's labelled frames: the +render with a panel showing the level, the skill and the agent's note +for it, the steps and the resets. + +Usage (from the repository root, with PYTHONPATH=.): + python scripts/paper_figures/render_run_videos.py --domains Fan \ + --out-dir logs/paper_run_videos +""" +import argparse +import logging +import pickle +from pathlib import Path +from typing import Any, Callable, Dict, Iterator, List, Sequence, Tuple + +import numpy as np +from export_static_scenes import LOGS, _load_run_config, \ + _migrate_balloons_layout, _migrate_fan_layout +from export_trajectory_scenes import _canonical_state + +from predicators.envs import create_new_env +from predicators.run import paths +from predicators.run.continual_video import PANEL_ACCENT, PANEL_BAD, \ + PANEL_GOOD, PANEL_WARN, EpisodeRecord, FrameLabel, _Writer, \ + compose_frame, read_level_episodes, reset_cost_of +from predicators.run.episode import EpisodeState +from predicators.run.scorecard import LevelCard, RunCard +from predicators.settings import CFG +from predicators.structs import State + +# The runs behind the paper's trajectory figures (data/trajectories/ +# stripes.json), whose videos the project page shows. +RUNS: Dict[str, str] = { + "Balloons": "balloons-mb_opus_benchmark_r2/seed3/run_20260919_124956", + "Fan": "fan_ramp-mb_opus_ramp_skill_repair_r1/seed2/run_20260921_090827", +} + +Adjust = Callable[[Any, State, State], State] + + +def _move_fan(env: Any, state: State, initial: State) -> State: + return _migrate_fan_layout(env, state, initial) + + +def _move_balloons(env: Any, state: State, initial: State) -> State: + del initial # The chute layout needs no reference state. + return _migrate_balloons_layout(env, state) + + +ADJUST: Dict[str, Adjust] = {"Balloons": _move_balloons, "Fan": _move_fan} + + +def _show(env: Any, recorded: State, initial: State, + adjust: Adjust) -> np.ndarray: + """Render one recorded state in the current layout.""" + state = adjust(env, _canonical_state(env, recorded), initial) + env._set_state(state) # pylint: disable=protected-access + env._current_observation = state # pylint: disable=protected-access + return np.asarray(env.render()[0]) + + +def _level_frames(env: Any, card: RunCard, level: LevelCard, + episodes: Sequence[EpisodeRecord], + recorded: Dict[int, Dict[str, Any]], adjust: Adjust, + stride: int, hold: int) -> Iterator[Tuple[np.ndarray, int]]: + """Yield ``(frame, repeat)`` pairs for one level, choosing frames and + labels as continual_video.iter_level_frames does.""" + run_steps_before = sum(lv.steps for lv in card.levels[:level.index]) + run_resets_before = sum(lv.resets for lv in card.levels[:level.index]) + reset_cost = reset_cost_of(level) + resets_allowed = level.split == "train" or bool( + CFG.continual_allow_test_resets) + initial = env.reset(level.split, level.task_idx) + level_steps = 0 + level_resets = 0 + for ep in episodes: + states: List[State] = recorded[ep.index]["states"] + end = str(recorded[ep.index]["end"]) + assert len(states) == len(ep.actions) + 1, (level.index, ep.index) + if ep.opened_by == "agent": + level_resets += 1 + level_steps += reset_cost + + def label(step: int, + outcome: EpisodeState = EpisodeState.NOT_FINISHED, + reason: str = "", + banner: str = "", + color: Tuple[int, int, int] = PANEL_ACCENT, + ep: EpisodeRecord = ep) -> FrameLabel: + inv = ep.invocation_at(step) + ended = None + if inv is None and step > 0: + ended = ep.invocation_at(step - 1) + cur = inv if inv is not None else ended + return FrameLabel( + env=card.env, + arm=card.arm, + seed=card.seed, + level_index=level.index, + levels_total=card.levels_total, + split=level.split, + task_idx=level.task_idx, + goal_nl=level.goal_nl, + goal=list(level.goal), + episode=ep.index, + opened_by=ep.opened_by, + skill=cur.skill if cur is not None else "", + note=cur.note if cur is not None else "", + skill_status=(ended.status if ended is not None else ""), + level_steps=level_steps, + run_steps=run_steps_before + level_steps, + step_cap=card.step_cap, + level_resets=level_resets, + run_resets=run_resets_before + level_resets, + reset_cost=reset_cost, + resets_allowed=resets_allowed, + state=outcome.value, + reason=reason, + banner=banner, + banner_color=color, + ) + + first = _show(env, states[0], initial, adjust) + if ep.opened_by == "level_start": + banner = f"Level {level.index + 1}: {level.split} task " \ + f"{level.task_idx}" + yield compose_frame(first, label(0, banner=banner)), hold + elif ep.opened_by == "agent": + yield compose_frame( + first, label(0, banner="RESET by agent", + color=PANEL_WARN)), hold + else: + yield compose_frame( + first, label(0, banner="RESET by harness", + color=PANEL_WARN)), hold + n = len(ep.actions) + for i in range(n): + level_steps += 1 + last = i + 1 == n + boundary = any(inv.end == i + 1 for inv in ep.invocations) + if not (last or boundary or (i + 1) % stride == 0): + continue + render = _show(env, states[i + 1], initial, adjust) + if last and end == "win": + yield compose_frame( + render, + label(i, + EpisodeState.WIN, + banner="LEVEL WON", + color=PANEL_GOOD)), 2 * hold + elif last and end.startswith("game_over"): + reason = end.split(":", 1)[1] if ":" in end else "" + yield compose_frame( + render, + label(i, + EpisodeState.GAME_OVER, + reason, + banner=f"GAME OVER: {reason}", + color=PANEL_BAD)), 2 * hold + else: + yield compose_frame(render, label(i)), 1 + + +def render_run(domain: str, out_dir: Path, stride: int, fps: int) -> Path: + """Write ``out_dir/.mp4`` for the domain's paper run.""" + run_dir = LOGS / RUNS[domain] + _load_run_config(run_dir) + card = RunCard.load(paths.scorecard_path(str(run_dir))) + env: Any = create_new_env(CFG.env, do_cache=False, use_gui=False) + out = out_dir / f"{domain.lower()}.mp4" + writer = _Writer(str(out), fps) + hold = max(1, fps) + try: + for level in card.levels: + level_dir = Path(paths.level_dir(str(run_dir), level.index)) + if not level.attempted or not level_dir.is_dir(): + continue + episodes = read_level_episodes(str(level_dir)) + # Trusted local experiment record. + with open(level_dir / "episodes.pkl", "rb") as f: + recorded = {int(ep["episode"]): ep for ep in pickle.load(f)} + for frame, repeat in _level_frames(env, card, level, episodes, + recorded, ADJUST[domain], + stride, hold): + writer.append(frame, repeat) + logging.info("%s level %d: %d frames so far", domain, + level.index + 1, writer.frames) + finally: + writer.close() + env.dispose() + print(f"Wrote {out} ({writer.frames} frames at {fps} fps)", flush=True) + return out + + +def main() -> None: + """Render the selected domains' run videos.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--domains", nargs="+", default=list(RUNS)) + parser.add_argument("--out-dir", + type=Path, + default=LOGS.parent / "paper_run_videos") + parser.add_argument("--stride", + type=int, + default=None, + help="steps per frame; defaults to the run's own") + parser.add_argument("--fps", type=int, default=None) + args = parser.parse_args() + logging.basicConfig(level=logging.INFO) + for domain in args.domains: + # The run's own flags decide the stride and frame rate unless + # given here, as for the harness video. + _load_run_config(LOGS / RUNS[domain]) + stride = args.stride or int(CFG.continual_video_stride) + fps = args.fps or int(CFG.video_fps) + render_run(domain, args.out_dir, stride, fps) + + +if __name__ == "__main__": + main() diff --git a/tests/code_sim_learning/test_fan_gt_simulator.py b/tests/code_sim_learning/test_fan_gt_simulator.py index 5094c74fe..14625f8a9 100644 --- a/tests/code_sim_learning/test_fan_gt_simulator.py +++ b/tests/code_sim_learning/test_fan_gt_simulator.py @@ -21,7 +21,8 @@ from predicators.code_sim_learning.utils import apply_rules, \ has_physics_rules, merge_updates from predicators.envs import create_new_env -from predicators.envs.pybullet_fan import PyBulletFanEnv +from predicators.envs.pybullet_fan import HISTORICAL_ARENA_FLAGS, \ + PyBulletFanEnv from predicators.ground_truth_models import get_gt_simulator from predicators.structs import Action @@ -33,11 +34,13 @@ @pytest.fixture(scope="module", name="fan_setup") def _fan_setup(): utils.reset_config({ + **HISTORICAL_ARENA_FLAGS, "env": "pybullet_fan", "seed": 0, "fan_use_skill_factories": True, - # The assertions below are written against the curated seed-0 - # task (ball/wall/target aligned in the center column). + # The assertions below are written against the historical arena's + # curated seed-0 task (ball/wall/target aligned in the center + # column). "fan_3x3_strategic_task_gen": True, }) rules, specs, _ = get_gt_simulator("pybullet_fan") diff --git a/tests/envs/test_pybullet_fan_boundary.py b/tests/envs/test_pybullet_fan_boundary.py index 52cd3b9a5..e4bd08e12 100644 --- a/tests/envs/test_pybullet_fan_boundary.py +++ b/tests/envs/test_pybullet_fan_boundary.py @@ -9,7 +9,8 @@ import pybullet as p from predicators import utils -from predicators.envs.pybullet_fan import PyBulletFanEnv +from predicators.envs.pybullet_fan import HISTORICAL_ARENA_FLAGS, \ + PyBulletFanEnv def test_boundary_state_survives_foreign_object_ids() -> None: @@ -21,6 +22,7 @@ def test_boundary_state_survives_foreign_object_ids() -> None: "seed": 0, "num_train_tasks": 1, "num_test_tasks": 1, + **HISTORICAL_ARENA_FLAGS, }) producer = PyBulletFanEnv(use_gui=False) consumer = PyBulletFanEnv(use_gui=False) diff --git a/tests/envs/test_pybullet_fan_maze.py b/tests/envs/test_pybullet_fan_maze.py index 8588d9a69..94bb88475 100644 --- a/tests/envs/test_pybullet_fan_maze.py +++ b/tests/envs/test_pybullet_fan_maze.py @@ -6,7 +6,8 @@ import pytest from predicators import utils -from predicators.envs.pybullet_fan import PyBulletFanEnv +from predicators.envs.pybullet_fan import HISTORICAL_ARENA_FLAGS, \ + PyBulletFanEnv from predicators.settings import CFG from predicators.structs import State @@ -71,6 +72,7 @@ def test_free_cells_connected() -> None: _MAZE_FLAGS = { + **HISTORICAL_ARENA_FLAGS, "fan_test_num_pos_x": 10, "fan_test_num_pos_y": 9, "fan_test_num_walls_per_task": [16, 20, 24], @@ -82,10 +84,23 @@ def test_free_cells_connected() -> None: def test_default_test_split_is_uniform() -> None: - """The defaults keep the historical 6 x 6 uniform test split; the maze - split is opted into per config.""" + """The defaults are the paper's exposed ramp, with a uniform 3 x 3 test + split and no walls; HISTORICAL_ARENA_FLAGS restores the historical 6 x 6 + uniform split. + + The maze split is opted into per config. + """ utils.reset_config({"env": "pybullet_fan", "seed": 0}) + assert CFG.fan_exposed_transfer and CFG.fan_ramp_transfer assert CFG.fan_test_task_generation == "uniform" + assert (CFG.fan_test_num_pos_x, CFG.fan_test_num_pos_y) == (3, 3) + assert CFG.fan_test_num_walls_per_task == [0] + utils.reset_config({ + "env": "pybullet_fan", + "seed": 0, + **HISTORICAL_ARENA_FLAGS + }) + assert not CFG.fan_exposed_transfer and not CFG.fan_ramp_transfer assert (CFG.fan_test_num_pos_x, CFG.fan_test_num_pos_y) == (6, 6) assert CFG.fan_test_num_walls_per_task == [2, 3] @@ -145,6 +160,7 @@ def test_maze_generation_is_deterministic() -> None: "seed": 3, "num_train_tasks": 1, "num_test_tasks": 2, + **HISTORICAL_ARENA_FLAGS, }) env = PyBulletFanEnv(use_gui=False) try: @@ -161,6 +177,9 @@ def test_maze_generation_is_deterministic() -> None: def test_unknown_generation_rejected() -> None: """An unknown generation mode fails loudly.""" utils.reset_config({ + # The exposed ramp generates its own tasks; the historical arena + # reads fan_test_task_generation. + **HISTORICAL_ARENA_FLAGS, "env": "pybullet_fan", "seed": 0, "num_train_tasks": 1, diff --git a/tests/envs/test_pybullet_fan_transfer.py b/tests/envs/test_pybullet_fan_transfer.py index 235cb5860..3cd93aa55 100644 --- a/tests/envs/test_pybullet_fan_transfer.py +++ b/tests/envs/test_pybullet_fan_transfer.py @@ -57,6 +57,8 @@ def _env(request): "num_test_tasks": 1, "fan_exposed_transfer": True, "fan_inertial_transfer": request.param, + # The exposed deck without the ramp that the defaults add. + "fan_ramp_transfer": False, "fan_train_num_walls_per_task": [0], "fan_test_num_walls_per_task": [0], }) @@ -326,6 +328,10 @@ def test_ramp_fan_banks_have_separate_evenly_spaced_supports(): "fan_exposed_transfer": True, "fan_inertial_transfer": True, "fan_ramp_transfer": True, + # The ramp this layout was centred for, without the benchmark's + # 3 mm rise and longer landing. + "fan_ramp_rise": 0.004, + "fan_ramp_landing_extension": 0.0, "fan_train_num_walls_per_task": [0], "fan_test_num_walls_per_task": [0], }) diff --git a/tests/test_skill_factories_integration.py b/tests/test_skill_factories_integration.py index d5d4591c2..a391312d6 100644 --- a/tests/test_skill_factories_integration.py +++ b/tests/test_skill_factories_integration.py @@ -35,7 +35,8 @@ from predicators.envs import _MOST_RECENT_ENV_INSTANCE from predicators.envs.pybullet_boil import PyBulletBoilEnv from predicators.envs.pybullet_coffee import PyBulletCoffeeEnv -from predicators.envs.pybullet_fan import PyBulletFanEnv +from predicators.envs.pybullet_fan import HISTORICAL_ARENA_FLAGS, \ + PyBulletFanEnv from predicators.envs.pybullet_grow import PyBulletGrowEnv from predicators.ground_truth_models import get_gt_options from predicators.run.setup import _options as setup_options @@ -251,6 +252,7 @@ def _create_coffee_env(): @pytest.fixture(scope="module", name="fan_env") def _create_fan_env(): utils.reset_config({ + **HISTORICAL_ARENA_FLAGS, "env": "pybullet_fan", "use_gui": _GUI_ON, "pybullet_control_mode": "reset",