Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions docs/component-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=...)`.
26 changes: 25 additions & 1 deletion src/botlab/_host.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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):
Expand Down
89 changes: 81 additions & 8 deletions src/botlab/_runtime/_host_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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)

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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(
Expand Down
37 changes: 35 additions & 2 deletions src/botlab/sdk/_controllers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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()
Expand All @@ -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": (
Expand All @@ -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")
Expand Down
72 changes: 72 additions & 0 deletions tests/fixtures/processors.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {},
Expand Down
Loading
Loading