diff --git a/docs/component-reference.md b/docs/component-reference.md index cbbc84c..abbcef7 100644 --- a/docs/component-reference.md +++ b/docs/component-reference.md @@ -96,3 +96,27 @@ transition 行字段:`from`(默认 any)/ `command`(指令脉冲,优先 完整语义和示例见 BotLab 仓库 `docs/api/` 与 `docs/examples/`: [controller.md](https://github.com/OpenLegged/BotLab) 是 Signal/状态机/policy_bank 的 权威契约,[multi-policy.md](https://github.com/OpenLegged/BotLab) 是多策略完整教程。 + +## Named option parameters + +`project(parameters={"mode": {"label": "Mode", "default": 0, +"options": [{"label": "Stand", "value": 0}, {"label": "Walk", "value": 2}]}})` +creates a dropdown in compatible browser runtimes. Option values are unique safe +integers, labels are non-empty, and the default must belong to the list. Do not +combine `options` with `range`, `step`, or `unit`. Existing range parameters +remain sliders. Native `run.set_parameter()` rejects non-member values. + +The control code still decides how a selection changes behavior. A +`Signal.action(modify=...)` hook may call `sim.reset_to()` when the selection +changes. In the native single-plan runner this reset is applied between plan +execution and physics: the transitional action is discarded, history and last +action are cleared, and the next action is recomputed at the reset frame. A +controller that requests reset again on that first frame is rejected to prevent +an endless reset loop. Native custom-controller/group reset behavior is unchanged. + +For exportable motion switching, prefer the compiled declaration: +`PolicyController(robot, control=control, reset_on_change={"parameter": "mode", +"motions": {0: stand_motion, 2: walk_motion}})`. Every option value must map to a +Motion. The single-controller rule resets to frame zero with root body channel +zero and native joint ordering; it supports editor, standalone browser and native +execution without Python callbacks. It cannot be combined with `action(modify=...)`. diff --git a/src/botlab/_host.py b/src/botlab/_host.py index 30acd20..c700c57 100644 --- a/src/botlab/_host.py +++ b/src/botlab/_host.py @@ -129,11 +129,31 @@ def _finite_number(value: Any, label: str) -> float: def _declared_parameter(pid: str, value: Any) -> dict: if not isinstance(value, dict): raise BotLabError(f"[botlab] Parameter {pid!r} must be a mapping") - known = {"label", "default", "range", "step", "unit"} + known = {"label", "default", "range", "step", "unit", "options"} for key in value: if key not in known: raise BotLabError(f"[botlab] Parameter {pid!r} has unknown field {key!r}") default_value = _finite_number(value.get("default"), f"Parameter {pid!r} default") + options = None + if "options" in value: + if any(key in value for key in ("range", "step", "unit")): + raise BotLabError(f"[botlab] Parameter {pid!r} options cannot be combined with range, step or unit") + if not isinstance(value["options"], list) or not value["options"]: + raise BotLabError(f"[botlab] Parameter {pid!r} options must be a non-empty list") + options, seen = [], set() + for option in value["options"]: + if not isinstance(option, dict) or set(option) != {"label", "value"}: + raise BotLabError(f"[botlab] Parameter {pid!r} option requires only label and value") + label = _text(option["label"], f"Parameter {pid!r} option label") + number = _finite_number(option["value"], f"Parameter {pid!r} option value") + if number != int(number) or abs(number) > 9007199254740991: + raise BotLabError(f"[botlab] Parameter {pid!r} option value must be a safe integer") + if number in seen: + raise BotLabError(f"[botlab] Parameter {pid!r} option values must be unique") + seen.add(number) + options.append({"label": label, "value": number}) + if default_value not in seen: + raise BotLabError(f"[botlab] Parameter {pid!r} default must be one of the option values") low = high = None if "range" in value: if not isinstance(value["range"], (list, tuple)) or len(value["range"]) != 2: @@ -150,6 +170,8 @@ def _declared_parameter(pid: str, value: Any) -> dict: if step <= 0: raise BotLabError(f"[botlab] Parameter {pid!r} step must be positive") result = {"type": "number", "default": default_value} + if options is not None: + result["options"] = options if low is not None: result["min"] = low if high is not None: @@ -357,6 +379,8 @@ def set_parameter(self, pid, value): f"[botlab] Parameter {pid!r} is not declared by this project: {known}" ) value = _finite_number(value, f"Parameter {pid!r} value") + if "options" in parameter and value not in {option["value"] for option in parameter["options"]}: + raise BotLabError(f"[botlab] Parameter {pid!r} value must be one of the option values") low = parameter.get("min") high = parameter.get("max") if (low is not None and value < low) or (high is not None and value > high): diff --git a/src/botlab/_runtime/_host_runtime.py b/src/botlab/_runtime/_host_runtime.py index 0ca8982..8a8e7c7 100644 --- a/src/botlab/_runtime/_host_runtime.py +++ b/src/botlab/_runtime/_host_runtime.py @@ -81,6 +81,9 @@ def __init__(self, physics, display=None, models=None, registry=None, self._step_callback = None self._step_robot_ids = None self._current_frame = None + self._in_plan_step = False + self._pending_plan_reset = None + self._plan_step_offset = 0 self._recorder = None self._substep_observer = None self._custom_step_active = False @@ -142,6 +145,9 @@ def registerPolicyController(self, plan, modifier=None): controllers = plan.get("controllers") if isinstance(plan, dict) else None if controllers is not None: + if any(node.get("data", {}).get("resetOnChange") is not None + for entry in controllers for node in entry.get("nodes", [])): + raise ValueError("reset_on_change requires a single controller") self._register_controller_group(controllers, registry) return @@ -156,6 +162,12 @@ def registerPolicyController(self, plan, modifier=None): self._plan_output = plan.get("output") or {} self._plan_robot_id = plan.get("robot") self._modifier = modifier + reset_specs = [node.get("data", {}).get("resetOnChange") for node in nodes + if node.get("data", {}).get("resetOnChange") is not None] + if reset_specs: + if modifier is not None or len(reset_specs) != 1: + raise ValueError("reset_on_change requires one reset rule and no Python modifier") + modifier = self._motion_reset_modifier(reset_specs[0]) if modifier is not None: self._attach_action_hook(modifier) @@ -314,6 +326,43 @@ def _resolve_model_paths(self, nodes): enriched["modelPath"] = self._path_resolver(source) node["data"] = enriched + def _motion_reset_modifier(self, spec): + import botlab_host + + manifest = botlab_host.project_state().manifest() + parameter = spec.get("parameter") + options = manifest["parameters"].get(parameter, {}).get("options") + mappings = spec.get("motions") + resources = {entry["id"]: entry["kind"] for entry in manifest["resources"]} + if not options or not isinstance(mappings, list) or len(mappings) != len(options): + raise ValueError("reset_on_change must map every option value to a Motion") + motion_ids = {} + for entry in mappings: + value, motion_id = entry.get("value"), entry.get("motionId") + if value not in {option["value"] for option in options} or value in motion_ids or resources.get(motion_id) != "motion": + raise ValueError("reset_on_change must map every option value to a Motion") + motion_ids[value] = motion_id + if spec.get("robotId") != self._plan_robot_id: + raise ValueError("reset_on_change must reset its controller's robot") + active, previous_index = None, -1 + + def modify(action, frame): + nonlocal active, previous_index + selected = botlab_host.parameter(parameter) + if selected not in motion_ids: + raise ValueError("reset_on_change received an unknown option value") + if selected != active or (frame.index == 0 and previous_index > 0): + active, previous_index = selected, -1 + self.requestReset("Parameter changed", { + "motionId": motion_ids[selected], "robotId": spec["robotId"], + "frame": 0, "rootBody": 0, + }) + else: + previous_index = frame.index + return action + + return modify + def _attach_action_hook(self, modifier): hook_nodes = [node for node in self._plan_nodes if node["type"] == "proc-action-hook"] if not hook_nodes: @@ -434,6 +483,9 @@ def updateDiagnostics(self, diagnostics): self._display.update_diagnostics(diagnostics) def requestReset(self, reason, spec=None): + if self._in_plan_step: + self._pending_plan_reset = (reason, spec) + return None result = self._physics.request_reset(reason, spec) if self._recorder is not None: frame = self._current_frame @@ -647,6 +699,8 @@ def reset_for_run(self): """The reset preamble every rollout performs before its first step.""" from ._observations import clear_pose_velocity_states + self._pending_plan_reset = None + self._plan_step_offset = 0 if self._reset_callback is not None: self._call_maybe_async(self._reset_callback()) clear_pose_velocity_states() @@ -664,14 +718,33 @@ def plan_step(self, step_index, last_action): import numpy as np dt = 1.0 / self._control_hz - frame = Frame(dt, step_index * dt, step_index) - self._current_frame = frame - self._physics.fill_observations( - self._executor, - last_action=last_action, - parameters=self._fill_parameters, - ) - self._executor.step(frame=frame) + # A hook may request a scene reset after inference. Discard that plan's + # output and rebuild the first action from reset state before physics. + for attempt in range(2): + index = step_index - self._plan_step_offset + frame = Frame(dt, index * dt, index) + self._current_frame = frame + self._physics.fill_observations( + self._executor, last_action=last_action, parameters=self._fill_parameters, + ) + self._in_plan_step = True + try: + self._executor.step(frame=frame) + finally: + self._in_plan_step = False + if self._pending_plan_reset is None: + break + reason, spec = self._pending_plan_reset + self._pending_plan_reset = None + if attempt: + raise RuntimeError("[botlab] controller requested reset again on its first reset frame") + start_time = self.requestReset(reason, spec) + from ._observations import clear_pose_velocity_states + + clear_pose_velocity_states() + self._executor.reset() + self._plan_step_offset = step_index - round(float(start_time or 0) / dt) + last_action = None action = np.array(self._policy_action(), dtype=np.float32) ctrl, substep_pd = self._control_payload() self._physics.step_substeps( diff --git a/src/botlab/sdk/_controllers.py b/src/botlab/sdk/_controllers.py index aa8d25c..ca88779 100644 --- a/src/botlab/sdk/_controllers.py +++ b/src/botlab/sdk/_controllers.py @@ -40,6 +40,7 @@ def __init__( kp=None, kd=None, effort_limit=None, + reset_on_change=None, ): if not isinstance(robot, Robot): raise TypeError("PolicyController robot must be a BotLab Robot") @@ -61,6 +62,12 @@ def __init__( ) else: target = _as_signal(target, "PolicyController target") + dynamic_gains = {} + gain_inputs = [] + for name, value in (("kps", kp), ("kds", kd)): + if isinstance(value, Signal): + dynamic_gains[f"{name}InputSlot"] = 3 + len(gain_inputs) + gain_inputs.append(value) explicit_pd_values = (kp, kd, effort_limit) dimension = ( robot.action_dimension() @@ -74,12 +81,12 @@ def __init__( "pdType": "position", "kps": ( robot.kp - if kp is None + if kp is None or isinstance(kp, Signal) else _policy_pd_vector(kp, dimension, "PolicyController kp") ), "kds": ( robot.kd - if kd is None + if kd is None or isinstance(kd, Signal) else _policy_pd_vector(kd, dimension, "PolicyController kd") ), "effortLimit": ( @@ -95,16 +102,42 @@ def __init__( robot.id, "actuatorJointOrder", ), + **dynamic_gains, }, ( target, robot.joint_position(order="native", _label="pd-joint-position"), robot.joint_velocity(order="native", _label="pd-joint-velocity"), + *gain_inputs, ), ) sink = Signal("out-mujoco", "controller", {}, (pd,)) self._plan_data, self.modify_action = _control_plan(robot.id, sink, pd) + if reset_on_change is not None: + from ._resources import Motion + if self.modify_action is not None: + raise ValueError("reset_on_change cannot be combined with an action modifier") + if not isinstance(reset_on_change, dict) or set(reset_on_change) != {"parameter", "motions"}: + raise ValueError("reset_on_change requires parameter and motions") + parameter = reset_on_change["parameter"] + choices = reset_on_change["motions"] + if not isinstance(parameter, str) or not parameter.strip(): + raise ValueError("reset_on_change parameter must be non-empty") + if not isinstance(choices, dict) or not choices: + raise ValueError("reset_on_change motions must map option values to Motion resources") + mappings = [] + for value, motion in choices.items(): + if isinstance(value, bool) or not isinstance(value, int) or abs(value) > 9007199254740991: + raise ValueError("reset_on_change values must be safe integers") + if not isinstance(motion, Motion): + raise TypeError("reset_on_change choices must be Motion resources") + mappings.append({"value": value, "motionId": motion.id}) + hook = next(node for node in self._plan_data["nodes"] if node["type"] == "proc-action-hook") + hook["data"]["resetOnChange"] = { + "parameter": parameter, "motions": mappings, "robotId": robot.id, + } + @property def plan(self): return _plan_value(self._plan_data, "PolicyController plan") diff --git a/tests/fixtures/processors.json b/tests/fixtures/processors.json index a91b8bb..25993e1 100644 --- a/tests/fixtures/processors.json +++ b/tests/fixtures/processors.json @@ -1471,6 +1471,78 @@ } ] }, + { + "type": "proc-action-hook", + "config": { + "resetOnChange": { + "parameter": "motion", + "robotId": "robot", + "motions": [ + { + "value": 0, + "motionId": "walk" + }, + { + "value": 2, + "motionId": "run" + } + ] + } + }, + "inputDims": [ + 3 + ], + "steps": [ + { + "inputs": [ + [ + 1, + -2, + 3 + ] + ], + "outputs": [ + [ + 1, + -2, + 3 + ] + ] + }, + { + "inputs": [ + [ + 4, + 5, + 6 + ] + ], + "outputs": [ + [ + 4, + 5, + 6 + ] + ] + }, + { + "inputs": [ + [ + 0, + 0, + 0 + ] + ], + "outputs": [ + [ + 0, + 0, + 0 + ] + ] + } + ] + }, { "type": "proc-action-hook", "config": {}, diff --git a/tests/test_headless.py b/tests/test_headless.py index af5373a..adb5356 100644 --- a/tests/test_headless.py +++ b/tests/test_headless.py @@ -216,3 +216,64 @@ def test_run_load_motion_works_after_close_and_cwd_moves(tmp_path: Path, monkeyp monkeypatch.chdir(tmp_path) # away from the project directory assert run.load_motion("walk.npz")["frame_count"] == 4 + + +def test_option_switch_reset_discards_old_history_and_restarts_clock(pendulum_project): + import numpy as np + + path = pendulum_project / "controller.py" + text = path.read_text().replace('concat, project', 'concat, project, clock, history') + text = text.replace('{"default": 1.0, "range": [0.0, 2.0]}', + '{"default": 0, "options": [{"label": "A", "value": 0}, {"label": "B", "value": 2}]}') + text = text.replace(' observation = concat([robot.commands("gain")])', ''' selected = 0 + def modify(action, frame): + nonlocal selected + value = sim.parameters.get("gain") + if value != selected: + selected = value + sim.request_reset("changed mode") + return action + observation = history(clock(unit="steps") + robot.commands("gain"), 2)[[0]]''') + text = text.replace('.action()', '.action(modify=modify)') + path.write_text(text) + with open_headless(pendulum_project) as run: + run.steps(7) + run.set_parameter("gain", 2) + first = run.step() + assert first.frame.index == 0 + assert first.frame.time == 0 + np.testing.assert_allclose(first.action, [2]) + assert run.step().frame.index == 1 + run.set_parameter("gain", 0) + assert run.step().frame.index == 0 + run.reset() + assert run.step().frame.index == 0 + + +def test_compiled_motion_reset_switch_replays_fresh_run(pendulum_project): + import numpy as np + + from botlab._project import load_project + + path = pendulum_project / "controller.py" + source = path.read_text().replace('concat, project', 'concat, project, Motion, clock, history') + source = source.replace('{"default": 1.0, "range": [0.0, 2.0]}', + '{"default": 0, "options": [{"label": "A", "value": 0}, {"label": "B", "value": 2}]}') + source = source.replace('def create_controller(sim):', 'motions = {0: Motion("a.npz"), 2: Motion("b.npz")}\n\ndef create_controller(sim):') + source = source.replace('concat([robot.commands("gain")])', 'history(clock(unit="steps") + robot.commands("gain"), 2)[[0]]') + source = source.replace('PolicyController(robot, control=control)', 'PolicyController(robot, control=control, reset_on_change={"parameter": "gain", "motions": motions})') + path.write_text(source) + write_motion_npz(pendulum_project / 'a.npz', dofs=1) + write_motion_npz(pendulum_project / 'b.npz', dofs=1) + assert not load_project(pendulum_project).plan_has_modifier + with open_headless(pendulum_project) as run: + run.set_parameter('gain', 2) + expected = [(record.action.copy(), run.physics.data.qpos.copy()) for record in [run.step()]] + run.steps(9) + run.set_parameter('gain', 0) + run.steps(5) + run.set_parameter('gain', 2) + record = run.step() + assert record.frame.index == 0 + np.testing.assert_array_equal(record.action, expected[0][0]) + np.testing.assert_array_equal(run.physics.data.qpos, expected[0][1]) diff --git a/tests/test_host.py b/tests/test_host.py index df93c39..06b2669 100644 --- a/tests/test_host.py +++ b/tests/test_host.py @@ -125,3 +125,28 @@ def test_json_value_normalizes_integer_floats_and_rejects_nan(): assert _host._json_value(1.0, "x") == 1 assert isinstance(_host._json_value(1.0, "x"), int) assert _host._json_value({"b": 1, "a": [True, None]}, "x") == {"a": [True, None], "b": 1} + + +def test_option_parameter_membership(session): + options = [{"label": "Walk", "value": 2}, {"label": "Jump", "value": 7}] + session.declare_project("Options", 50, 200, {"mode": {"default": 2, "options": options}}) + session.set_parameter("mode", 7) + assert session.parameter("mode") == 7 + for value in (3, 2.5, True): + with pytest.raises(BotLabError): + session.set_parameter("mode", value) + + +@pytest.mark.parametrize("spec", [ + {"default": 1, "options": []}, + {"default": 1, "options": [{"label": "A", "value": 2}]}, + {"default": 1, "options": [{"label": "", "value": 1}]}, + {"default": 1, "options": [{"label": "A", "value": 1}] * 2}, + {"default": 1.5, "options": [{"label": "A", "value": 1.5}]}, + {"default": 1, "range": [0, 2], "options": [{"label": "A", "value": 1}]}, + {"default": 1, "step": 1, "options": [{"label": "A", "value": 1}]}, + {"default": 1, "options": [{"label": "A", "value": 1, "extra": True}]}, +]) +def test_invalid_option_parameters(session, spec): + with pytest.raises(BotLabError): + session.declare_project("Options", 50, 200, {"mode": spec})